Compare commits

...

61 Commits

Author SHA1 Message Date
Tulir Asokan 21c6a7d87f Bump version to 0.11.3 2022-04-17 13:30:38 +03:00
Tulir Asokan 7c2a569235 Remove some unused fields 2022-04-13 14:43:53 +03:00
Tulir Asokan 1f5b91cbec Update mautrix-python 2022-04-09 20:52:45 +03:00
Tulir Asokan 937f37eff0 Don't print generated registration message if config is invalid 2022-04-09 20:46:25 +03:00
Tulir Asokan 4f9f74204a Update dependencies 2022-04-08 18:06:24 +03:00
Tulir Asokan ed6735f10f Fix creating new database 2022-04-06 19:04:12 +03:00
Tulir Asokan 5acd3cf007 Move API version number to endpoint definition 2022-04-06 14:33:03 +03:00
Tulir Asokan 279b997bd3 Add contacts and PM endpoints to OpenAPI spec 2022-04-06 14:29:50 +03:00
Tulir Asokan 4eb6095822 Update provisioning API spec to OpenAPI 3.1.0 2022-04-06 14:06:10 +03:00
Tulir Asokan da5b8556f2 Add phone number field for puppets 2022-04-06 12:49:01 +03:00
Tulir Asokan 261f99ac82 Add provisioning API for listing contacts and starting DMs 2022-04-06 12:40:55 +03:00
Tulir Asokan 61f3c39cc2 Mark reactions as read when reading from Matrix 2022-04-01 15:56:16 +03:00
Tulir Asokan 39ab1d0c22 Fix another bug 2022-03-31 01:58:40 +03:00
Tulir Asokan 8abb9c3884 Fix bugs in Telegram entity parser 2022-03-31 01:53:51 +03:00
Tulir Asokan 58f8ee2ee2 Add config option to mark joined Telegram notices as read automatically 2022-03-30 11:58:40 +03:00
Tulir Asokan 474bcc9544 Update and unpin black 2022-03-28 22:29:22 +03:00
Tulir Asokan a3f4e25101 Fix some bugs and add command to list invite links 2022-03-28 15:49:08 +03:00
Tulir Asokan 8befb664b6 Handle accepted into group action messages 2022-03-28 15:06:35 +03:00
Tulir Asokan 819dd1bcff Allow generating invite links that need join approval 2022-03-28 15:03:22 +03:00
Tulir Asokan 2b8b853fec Add proper message when requesting to join via invite link 2022-03-28 15:03:05 +03:00
Tulir Asokan c536c4a265 Update changelog 2022-03-27 23:39:46 +03:00
Tulir Asokan f13acfe825 Clarify that supergroups are channels in !tg bridge 2022-03-27 23:39:46 +03:00
Sumner Evans 8e763ba067 Merge pull request #775 from mautrix/sumner/bri-2582
async media: add ability to upload media asynchronously
2022-03-27 12:31:39 -06:00
Sumner Evans 8d7cfd8e46 parallel transfer: disable async_upload 2022-03-27 12:26:44 -06:00
Sumner Evans 601058d61c async media: add ability to upload media asynchronously
Co-authored-by: Tulir Asokan <tulir@maunium.net>
2022-03-27 12:26:44 -06:00
Tulir Asokan f8596ef368 Use new ensure_has_html method instead of duplicating code 2022-03-23 19:51:01 +02:00
Tulir Asokan 7f0494d52d Merge remote-tracking branch 'origin/sumner/bri-2496' 2022-03-22 16:29:48 +02:00
Sumner Evans 828478514b Merge pull request #772 from mautrix/fix-kick-from-portals
user: fix bug in kick_from_portals
2022-03-22 08:00:02 -06:00
Tulir Asokan 146f5437d1 Drop Python 3.7 support 2022-03-22 13:44:52 +02:00
Tulir Asokan c28760f2a8 Adjust permission error messages 2022-03-22 13:44:52 +02:00
Tulir Asokan 04f30f6f29 Update mautrix-python 2022-03-22 13:44:52 +02:00
Tulir Asokan caa1d3565b Update changelog 2022-03-22 13:44:52 +02:00
Sumner Evans 1a7a020bb2 backfill: set timestamp on backfilled reactions to message timestamp 2022-03-22 00:48:12 -06:00
Sumner Evans 077ab2bb38 user: fix bug in kick_from_portals 2022-03-22 00:46:32 -06:00
Sumner Evans 6f491bf7d1 Merge pull request #771 from ProkopRandacek/master
Add missing f in front of the f-string
2022-03-21 10:51:51 -06:00
Prokop Randacek 9b80c21d0a add missing F 2022-03-21 10:11:45 +01:00
Tulir Asokan e9dc76a860 Fix public channel mentions always using user instead of portal mxid 2022-03-15 16:32:21 +02:00
Tulir Asokan 9e73324a20 Fix bridge_matrix_leave config option 2022-03-14 12:00:14 +02:00
Tulir Asokan 7df93485d8 Remove extra parameter in call 2022-03-11 12:02:02 +02:00
Tulir Asokan 9018cea5ae Update changelog 2022-03-07 18:52:15 +02:00
Tulir Asokan 32e023231d Catch invalid integers passed to !tg create 2022-03-05 20:16:04 +02:00
Tulir Asokan 4766d14359 Move DM creation code to mautrix-python 2022-03-04 16:12:02 +02:00
Tulir Asokan 526b99ec04 Disable file logging in Docker by default
To enable it, use a custom path that points at a writable volume
2022-03-04 10:57:08 +02:00
Nick Mills-Barrett da132438bd Only change the data directory ownership on Docker start 2022-03-03 18:17:39 +02:00
Tulir Asokan 54176ba2db Fix self parameter name in _mute_room. Fixes #764 2022-03-02 14:33:09 +02:00
Tulir Asokan 1eca3c2ffd Check peer_type in database when manually bridging portal 2022-03-02 14:33:06 +02:00
Tulir Asokan 98142f28cd Improve logging of backfill count 2022-02-28 12:36:43 +02:00
Tulir Asokan 2cf7fc7059 Improve backfilling to fetch less redundant messages 2022-02-28 12:26:24 +02:00
Tulir Asokan a34a18c6cc Deduplicate user joined telegram messages 2022-02-28 11:59:44 +02:00
Tulir Asokan fa738fbadf Fix condition 2022-02-26 17:20:22 +02:00
Tulir Asokan 9ea0516166 Log when tagging and muting rooms 2022-02-25 19:35:05 +02:00
Tulir Asokan b760aadb01 Add custom flag for force sending images as document 2022-02-25 12:38:01 +02:00
Tulir Asokan 24162e14ac Remove msgtype in stickers 2022-02-23 14:36:53 +02:00
Tulir Asokan 9ea495324d Don't try to set room state in non-existent portals 2022-02-23 12:46:16 +02:00
Tulir Asokan 437e86a15b Keep newlines as-is in code blocks 2022-02-23 12:44:56 +02:00
Tulir Asokan d9e0b75e9b Update mautrix-python again 2022-02-22 13:53:43 +02:00
Tulir Asokan 9606518ba7 Update mautrix-python again 2022-02-22 12:40:16 +02:00
Tulir Asokan e2774b830f Update mautrix-python version 2022-02-22 11:58:27 +02:00
Tulir Asokan 951d82ad27 Remove max_document_size option and use media repo config directly 2022-02-20 13:47:40 +02:00
Tulir Asokan 4a55cf589c Add initial db upgrade that jumps to latest version 2022-02-19 00:19:49 +02:00
Tulir Asokan b07d80d876 Add support for converting t.me/c/<id>/<msgid> links 2022-02-18 17:22:26 +02:00
40 changed files with 1751 additions and 1107 deletions
+1 -1
View File
@@ -17,5 +17,5 @@ max_line_length = 99
[*.{yaml,yml,py}] [*.{yaml,yml,py}]
indent_style = space indent_style = space
[{.gitlab-ci.yml,.pre-commit-config.yaml}] [{.gitlab-ci.yml,.pre-commit-config.yaml,mautrix_telegram/web/provisioning/spec.yaml}]
indent_size = 2 indent_size = 2
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
- uses: psf/black@stable - uses: psf/black@stable
with: with:
src: "./mautrix_telegram" src: "./mautrix_telegram"
version: "22.1.0" version: "22.3.0"
- name: pre-commit - name: pre-commit
run: | run: |
pip install pre-commit pip install pre-commit
+5 -8
View File
@@ -7,17 +7,14 @@ repos:
- id: end-of-file-fixer - id: end-of-file-fixer
- id: check-yaml - id: check-yaml
- id: check-added-large-files - id: check-added-large-files
# TODO convert to use the upstream psf/black when - repo: https://github.com/psf/black
# https://github.com/psf/black/issues/2493 gets fixed rev: 22.3.0
- repo: local
hooks: hooks:
- id: black - id: black
name: black language_version: python3
entry: black --check files: ^mautrix_telegram/.*\.pyi?$
language: system
files: ^mautrix_telegram/.*\.py$
- repo: https://github.com/PyCQA/isort - repo: https://github.com/PyCQA/isort
rev: 5.10.1 rev: 5.10.1
hooks: hooks:
- id: isort - id: isort
files: ^mautrix_telegram/.*$ files: ^mautrix_telegram/.*\.pyi?$
+44 -3
View File
@@ -1,3 +1,44 @@
# v0.11.3 (2022-04-17)
**N.B.** This release drops support for old homeservers which don't support the
new `/v3` API endpoints. Synapse 1.48+, Dendrite 0.6.5+ and Conduit 0.4.0+ are
supported. Legacy `r0` API support can be temporarily re-enabled with `pip install mautrix==0.16.0`.
However, this option will not be available in future releases.
### Added
* Added `list-invite-links` command to list invite links in a chat.
* Added option to use [MSC2246] async media uploads.
* Provisioning API for listing contacts and starting private chats.
### Improved
* Dropped Python 3.7 support.
* Telegram->Matrix message formatter will now replace `t.me/c/chatid/messageid`
style links with a link to the bridged Matrix event (in addition to the
previously supported `t.me/username/messageid` links).
* Updated formatting converter to keep newlines in code blocks as `\n` instead
of converting them to `<br/>`.
* Removed `max_document_size` option. The bridge will now fetch the max size
automatically using the media repo config endpoint.
* Removed redundant `msgtype` field in sticker events sent to Matrix.
* Disabled file logging in Docker image by default.
* If you want to enable it, set the `filename` in the file log handler to a
path that is writable, then add `"file"` back to `logging.root.handlers`.
* Reactions are now marked as read when bridging read receipts from Matrix.
### Fixed
* Fixed `!tg bridge` throwing error if the parameter is not an integer
* Fixed `!tg bridge` failing if the command had been previously run with an
incorrectly prefixed chat ID (e.g. `!tg bridge -1234567` followed by
`!tg bridge -1001234567`).
* Fixed `bridge_matrix_leave` config option not actually being used correctly.
* Fixed public channel mentions always bridging into a user mention on Matrix
rather than a room mention.
* The bridge will now make room mentions if the portal exists and fall back
to user mentions otherwise.
* Fixed newlines being lost in unformatted forwarded messages.
[MSC2246]: https://github.com/matrix-org/matrix-spec-proposals/pull/2246
# v0.11.2 (2022-02-14) # v0.11.2 (2022-02-14)
**N.B.** This will be the last release to support Python 3.7. Future versions **N.B.** This will be the last release to support Python 3.7. Future versions
@@ -229,8 +270,8 @@ path.
* Bridging events of a user whose power level is malformed (i.e. a string * Bridging events of a user whose power level is malformed (i.e. a string
instead of an integer) now works. instead of an integer) now works.
[MSC2409]: https://github.com/matrix-org/matrix-doc/pull/2409 [MSC2409]: https://github.com/matrix-org/matrix-spec-proposals/pull/2409
[MSC2778]: https://github.com/matrix-org/matrix-doc/pull/2778 [MSC2778]: https://github.com/matrix-org/matrix-spec-proposals/pull/2778
# v0.8.2 (2020-07-27) # v0.8.2 (2020-07-27)
@@ -278,7 +319,7 @@ update (v0.5.8) and a fix to the Docker image.
* Fixed `sync_direct_chats` option creating non-working portals. * Fixed `sync_direct_chats` option creating non-working portals.
* Fixed video thumbnailing sometimes leaving behind downloaded videos in `/tmp`. * Fixed video thumbnailing sometimes leaving behind downloaded videos in `/tmp`.
[MSC2346]: https://github.com/matrix-org/matrix-doc/pull/2346 [MSC2346]: https://github.com/matrix-org/matrix-spec-proposals/pull/2346
## rc1 (2020-04-25) ## rc1 (2020-04-25)
+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==22.1.0 black>=22.3,<23
+11 -2
View File
@@ -2,7 +2,13 @@
# Define functions. # Define functions.
function fixperms { function fixperms {
chown -R $UID:$GID /data /opt/mautrix-telegram chown -R $UID:$GID /data
# /opt/mautrix-telegram is read-only, so disable file logging if it's pointing there.
if [[ "$(yq e '.logging.handlers.file.filename' /data/config.yaml)" == "./mautrix-telegram.log" ]]; then
yq -I4 e -i 'del(.logging.root.handlers[] | select(. == "file"))' /data/config.yaml
yq -I4 e -i 'del(.logging.handlers.file)' /data/config.yaml
fi
} }
cd /opt/mautrix-telegram cd /opt/mautrix-telegram
@@ -18,7 +24,10 @@ if [ ! -f /data/config.yaml ]; then
fi fi
if [ ! -f /data/registration.yaml ]; then if [ ! -f /data/registration.yaml ]; then
python3 -m mautrix_telegram -g -c /data/config.yaml -r /data/registration.yaml python3 -m mautrix_telegram -g -c /data/config.yaml -r /data/registration.yaml || exit $?
echo "Didn't find a registration file."
echo "Generated one for you."
echo "See https://docs.mau.fi/bridges/general/registering-appservices.html on how to use it."
fixperms fixperms
exit exit
fi fi
+1 -1
View File
@@ -1,2 +1,2 @@
__version__ = "0.11.2" __version__ = "0.11.3"
__author__ = "Tulir Asokan <tulir@maunium.net>" __author__ = "Tulir Asokan <tulir@maunium.net>"
+2 -2
View File
@@ -137,9 +137,9 @@ class CommandHandler(BaseCommandHandler):
async def get_permission_error(self, evt: CommandEvent) -> str | None: async def get_permission_error(self, evt: CommandEvent) -> str | None:
if self.needs_puppeting and not evt.sender.puppet_whitelisted: if self.needs_puppeting and not evt.sender.puppet_whitelisted:
return "This command requires puppeting privileges." return "That command is limited to users with puppeting privileges."
elif self.needs_matrix_puppeting and not evt.sender.matrix_puppet_whitelisted: elif self.needs_matrix_puppeting and not evt.sender.matrix_puppet_whitelisted:
return "This command requires Matrix puppeting privileges." return "That command is limited to users with full puppeting privileges."
return await super().get_permission_error(evt) return await super().get_permission_error(evt)
def has_permission(self, key: HelpCacheKey) -> bool: def has_permission(self, key: HelpCacheKey) -> bool:
+1 -1
View File
@@ -81,5 +81,5 @@ async def enter_matrix_token(evt: CommandEvent) -> EventID:
except InvalidAccessToken: except InvalidAccessToken:
return await evt.reply("Failed to verify access token.") return await evt.reply("Failed to verify access token.")
return await evt.reply( return await evt.reply(
"Replaced your Telegram account's Matrix puppet with {puppet.custom_mxid}." f"Replaced your Telegram account's Matrix puppet with {puppet.custom_mxid}."
) )
+43 -13
View File
@@ -59,17 +59,22 @@ async def bridge(evt: CommandEvent) -> EventID:
# 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]
if tgid_str.startswith("-100"): tgid = None
tgid = TelegramID(int(tgid_str[4:])) try:
peer_type = "channel" if tgid_str.startswith("-100"):
elif tgid_str.startswith("-"): tgid = TelegramID(int(tgid_str[4:]))
tgid = TelegramID(-int(tgid_str)) peer_type = "channel"
peer_type = "chat" elif tgid_str.startswith("-"):
else: tgid = TelegramID(-int(tgid_str))
peer_type = "chat"
except ValueError:
# Invalid integer
pass
if not tgid:
return await evt.reply( return await evt.reply(
"That doesn't seem like a prefixed Telegram chat ID.\n\n" "That doesn't seem like a prefixed Telegram chat ID.\n\n"
"If you did not get the ID using the `/id` bot command, please " "If you did not get the ID using the `/id` bot command, please prefix"
"prefix channel IDs with `-100` and normal group IDs with `-`.\n\n" "channel/supergroup IDs with `-100` and non-super group IDs with `-`.\n\n"
"Bridging private chats to existing rooms is not allowed." "Bridging private chats to existing rooms is not allowed."
) )
@@ -80,7 +85,7 @@ async def bridge(evt: CommandEvent) -> EventID:
"If you're the bridge admin, try " "If you're the bridge admin, try "
"`$cmdprefix+sp filter whitelist <Telegram chat ID>` first." "`$cmdprefix+sp filter whitelist <Telegram chat ID>` first."
) )
if portal.mxid: elif portal.mxid:
has_portal_message = ( has_portal_message = (
"That Telegram chat already has a portal at " "That Telegram chat already has a portal at "
f"[{portal.alias or portal.mxid}](https://matrix.to/#/{portal.mxid}). " f"[{portal.alias or portal.mxid}](https://matrix.to/#/{portal.mxid}). "
@@ -96,7 +101,7 @@ async def bridge(evt: CommandEvent) -> EventID:
"mxid": portal.mxid, "mxid": portal.mxid,
"bridge_to_mxid": room_id, "bridge_to_mxid": room_id,
"tgid": portal.tgid, "tgid": portal.tgid,
"peer_type": portal.peer_type, "peer_type": peer_type,
"force_use_bot": force_use_bot, "force_use_bot": force_use_bot,
} }
return await evt.reply( return await evt.reply(
@@ -112,7 +117,7 @@ async def bridge(evt: CommandEvent) -> EventID:
"action": "Room bridging", "action": "Room bridging",
"bridge_to_mxid": room_id, "bridge_to_mxid": room_id,
"tgid": portal.tgid, "tgid": portal.tgid,
"peer_type": portal.peer_type, "peer_type": peer_type,
"force_use_bot": force_use_bot, "force_use_bot": force_use_bot,
} }
return await evt.reply( return await evt.reply(
@@ -163,6 +168,18 @@ async def confirm_bridge(evt: CommandEvent) -> EventID | None:
is_logged_in = await evt.sender.is_logged_in() and not status["force_use_bot"] is_logged_in = await evt.sender.is_logged_in() and not status["force_use_bot"]
if "mxid" in status: if "mxid" in status:
if portal.peer_type != status["peer_type"]:
evt.log.warning(
"Portal %d in database has mismatching peer type %s (expected %s),"
" trusting database as a room already existed",
portal.tgid,
portal.peer_type,
status["peer_type"],
)
await evt.reply(
"Mismatching peer type in command and portal table, "
"trusting portal as room already existed"
)
ok, coro = await cleanup_old_portal_while_bridging(evt, portal) ok, coro = await cleanup_old_portal_while_bridging(evt, portal)
if not ok: if not ok:
return None return None
@@ -181,6 +198,19 @@ async def confirm_bridge(evt: CommandEvent) -> EventID | None:
"Please use `$cmdprefix+sp continue` to confirm the bridging or " "Please use `$cmdprefix+sp continue` to confirm the bridging or "
"`$cmdprefix+sp cancel` to cancel." "`$cmdprefix+sp cancel` to cancel."
) )
elif portal.peer_type != status["peer_type"]:
evt.log.warning(
"Portal %d in database has mismatching peer type %s (expected %s),"
" trusting new peer type as there's no existing room",
portal.tgid,
portal.peer_type,
status["peer_type"],
)
await evt.reply(
"Mismatching peer type in command and portal table, "
"trusting you as portal room doesn't exist"
)
portal.peer_type = status["peer_type"]
evt.sender.command_status = None evt.sender.command_status = None
async with portal._room_create_lock: async with portal._room_create_lock:
@@ -221,7 +251,7 @@ async def _locked_confirm_bridge(
await portal.save() await portal.save()
await portal.update_bridge_info() await portal.update_bridge_info()
asyncio.create_task(portal.update_matrix_room(user, entity, direct=False, levels=levels)) asyncio.create_task(portal.update_matrix_room(user, entity, levels=levels))
await warn_missing_power(levels, evt) await warn_missing_power(levels, evt)
+107 -17
View File
@@ -25,12 +25,22 @@ from telethon.errors import (
UsernameNotModifiedError, UsernameNotModifiedError,
UsernameOccupiedError, UsernameOccupiedError,
) )
from telethon.helpers import add_surrogate
from telethon.tl.functions.channels import GetFullChannelRequest from telethon.tl.functions.channels import GetFullChannelRequest
from telethon.tl.functions.messages import GetFullChatRequest from telethon.tl.functions.messages import GetExportedChatInvitesRequest, GetFullChatRequest
from telethon.tl.types import (
ChatInviteExported,
InputMessageEntityMentionName,
InputUserSelf,
MessageEntityMention,
TypeInputPeer,
TypeInputUser,
)
from telethon.tl.types.messages import ExportedChatInvites
from mautrix.types import EventID from mautrix.types import EventID
from ... import portal as po from ... import formatter as fmt, portal as po, puppet as pu
from .. import SECTION_MISC, SECTION_PORTAL_MANAGEMENT, CommandEvent, command_handler from .. import SECTION_MISC, SECTION_PORTAL_MANAGEMENT, CommandEvent, command_handler
from .util import user_has_power_level from .util import user_has_power_level
@@ -101,30 +111,37 @@ async def get_id(evt: CommandEvent) -> EventID:
invite_link_usage = ( invite_link_usage = (
"**Usage:** `$cmdprefix+sp invite-link [--uses=<amount>] [--expire=<delta>]`" "**Usage:** `$cmdprefix+sp invite-link "
"[--uses=<amount>] [--expire=<delta>] [--request-needed] -- [title]`"
"\n\n" "\n\n"
"* `--uses`: the number of times the invite link can be used." "* `--uses`: the number of times the invite link can be used."
" Defaults to unlimited.\n" " Defaults to unlimited.\n"
"* `--expire`: the duration after which the link will expire." "* `--expire`: the duration after which the link will expire."
" A number suffixed with d(ay), h(our), m(inute) or s(econd)" " A number suffixed with d(ay), h(our), m(inute) or s(econd)\n"
"* `--request-needed`: should the link require admins to approve joins?\n"
"* `title`: a description of the link (only shown to admins)."
) )
def _parse_flag(args: list[str]) -> tuple[str, str]: def _parse_flag(args: list[str]) -> tuple[str, str]:
arg = args.pop(0).lower() arg = args.pop(0).lower()
if arg == "--":
return "", ""
value = ""
if arg.startswith("--"): if arg.startswith("--"):
value_start = arg.index("=") value_start = arg.find("=")
if value_start: if value_start > 0:
flag = arg[2:value_start] flag = arg[2:value_start]
value = arg[value_start + 1 :] value = arg[value_start + 1 :]
else: else:
flag = arg[2:] flag = arg[2:]
value = args.pop(0).lower() if arg not in ("request", "request-needed"):
value = args.pop(0).lower()
elif arg.startswith("-"): elif arg.startswith("-"):
flag = arg[1] flag = arg[1]
if len(arg) > 3 and arg[2] == "=": if len(arg) > 3 and arg[2] == "=":
value = arg[3:] value = arg[3:]
else: elif arg != "r":
value = args.pop(0).lower() value = args.pop(0).lower()
else: else:
raise ValueError("invalid flag") raise ValueError("invalid flag")
@@ -159,18 +176,24 @@ def _parse_delta(value: str) -> timedelta | None:
@command_handler( @command_handler(
help_section=SECTION_PORTAL_MANAGEMENT, help_section=SECTION_PORTAL_MANAGEMENT,
help_text="Get a Telegram invite link to the current chat.", help_text="Get a Telegram invite link to the current chat.",
help_args="[--uses=<amount>] [--expire=<time delta, e.g. 1d>]", help_args="[--uses=<amount>] [--expire=<time delta, e.g. 1d>] [--request-needed] -- [title]",
) )
async def invite_link(evt: CommandEvent) -> EventID: async def invite_link(evt: CommandEvent) -> EventID:
if not evt.is_portal:
return await evt.reply("This is not a portal room.")
# TODO once we switch to Python 3.9 minimum, use argparse with exit_on_error=False # TODO once we switch to Python 3.9 minimum, use argparse with exit_on_error=False
uses = None uses = None
expire = None expire = None
request_needed = False
while evt.args: while evt.args:
try: try:
flag, value = _parse_flag(evt.args) flag, value = _parse_flag(evt.args)
except (ValueError, IndexError): except (ValueError, IndexError):
return await evt.reply(invite_link_usage) return await evt.reply(invite_link_usage)
if flag in ("uses", "u"): if not flag:
break
elif flag in ("uses", "u"):
try: try:
uses = int(value) uses = int(value)
except ValueError: except ValueError:
@@ -180,23 +203,90 @@ async def invite_link(evt: CommandEvent) -> EventID:
if not expire_delta: if not expire_delta:
await evt.reply("Invalid format for expiry time delta") await evt.reply("Invalid format for expiry time delta")
expire = datetime.now() + expire_delta expire = datetime.now() + expire_delta
elif flag in ("request", "request-needed", "r"):
request_needed = True
title = " ".join(evt.args)
portal = await po.Portal.get_by_mxid(evt.room_id) if evt.portal.peer_type == "user":
if not portal:
return await evt.reply("This is not a portal room.")
if portal.peer_type == "user":
return await evt.reply("You can't invite users to private chats.") return await evt.reply("You can't invite users to private chats.")
try: try:
link = await portal.get_invite_link(evt.sender, uses=uses, expire=expire) link = await evt.portal.get_invite_link(
return await evt.reply(f"Invite link to {portal.title}: {link}") evt.sender, uses=uses, expire=expire, request_needed=request_needed, title=title
)
return await evt.reply(f"Invite link to {evt.portal.title}: {link}")
except ValueError as e: except ValueError as e:
return await evt.reply(e.args[0]) return await evt.reply(e.args[0])
except ChatAdminRequiredError: except ChatAdminRequiredError:
return await evt.reply("You don't have the permission to create an invite link.") return await evt.reply("You don't have the permission to create an invite link.")
async def _format_invite_link(link: ChatInviteExported) -> str:
desc = f"* {link.link}"
if link.title:
desc += f" - {link.title}"
if link.expire_date:
desc += f" \n Expires at {link.expire_date.isoformat()}"
if link.usage_limit:
desc += f" \n Used {link.usage or 0} out of {link.usage_limit} times"
elif link.usage:
desc += f" \n Used {link.usage} times"
else:
desc += " \n Never used"
if link.request_needed:
desc += " \n Join requests enabled - using link requires admin approval"
return desc
async def _hacky_find_mention(evt: CommandEvent) -> TypeInputUser | TypeInputPeer | None:
if len(evt.args) == 0:
return None
text, entities = await fmt.matrix_to_telegram(
evt.sender.client, text=evt.content.body, html=evt.content.formatted_body
)
for entity in entities:
if isinstance(entity, MessageEntityMention):
admin_username = add_surrogate(text)[entity.offset + 1 : entity.offset + entity.length]
return await evt.sender.client.get_input_entity(admin_username)
elif isinstance(entity, InputMessageEntityMentionName):
return entity.user_id
return None
@command_handler(
help_section=SECTION_PORTAL_MANAGEMENT,
help_text="List existing Telegram invite links to the current chat.",
help_args="[creator]",
)
async def list_invite_links(evt: CommandEvent) -> EventID:
admin_id = InputUserSelf()
try:
admin_id = await _hacky_find_mention(evt) or InputUserSelf()
except Exception:
pass
resp: ExportedChatInvites = await evt.sender.client(
GetExportedChatInvitesRequest(
peer=await evt.portal.get_input_entity(evt.sender),
admin_id=admin_id,
limit=100,
)
)
if resp.count == 0:
if isinstance(admin_id, InputUserSelf):
return await evt.reply("You haven't created any invite links to the current chat")
else:
return await evt.reply("That user hasn't created any invite links to the current chat")
formatted_links = "\n".join([await _format_invite_link(link) for link in resp.invites])
if isinstance(admin_id, InputUserSelf):
await evt.reply(f"Your links to this chat:\n\n{formatted_links}")
else:
puppet = await pu.Puppet.get_by_peer(admin_id)
await evt.reply(
f"[{puppet.displayname}](https://matrix.to/#/{puppet.mxid})'s links to this chat:\n\n"
f"{formatted_links}"
)
@command_handler( @command_handler(
help_section=SECTION_PORTAL_MANAGEMENT, help_section=SECTION_PORTAL_MANAGEMENT,
help_text="Upgrade a normal Telegram group to a supergroup.", help_text="Upgrade a normal Telegram group to a supergroup.",
@@ -26,6 +26,7 @@ from telethon.errors import (
EmoticonInvalidError, EmoticonInvalidError,
InviteHashExpiredError, InviteHashExpiredError,
InviteHashInvalidError, InviteHashInvalidError,
InviteRequestSentError,
OptionsTooMuchError, OptionsTooMuchError,
TakeoutInitDelayError, TakeoutInitDelayError,
UserAlreadyParticipantError, UserAlreadyParticipantError,
@@ -171,6 +172,8 @@ async def _join(
return (await evt.sender.client(ImportChatInviteRequest(identifier))), None return (await evt.sender.client(ImportChatInviteRequest(identifier))), None
except UserAlreadyParticipantError: except UserAlreadyParticipantError:
return None, await evt.reply("You are already in that chat.") return None, await evt.reply("You are already in that chat.")
except InviteRequestSentError:
return None, await evt.reply("Invite request sent successfully.")
else: else:
channel = await evt.sender.client.get_entity(identifier) channel = await evt.sender.client.get_entity(identifier)
if not channel: if not channel:
+5 -1
View File
@@ -84,6 +84,10 @@ class Config(BaseBridgeConfig):
copy("appservice.provisioning.enabled") copy("appservice.provisioning.enabled")
copy("appservice.provisioning.prefix") copy("appservice.provisioning.prefix")
if base["appservice.provisioning.prefix"].endswith("/v1"):
base["appservice.provisioning.prefix"] = base["appservice.provisioning.prefix"][
: -len("/v1")
]
copy("appservice.provisioning.shared_secret") copy("appservice.provisioning.shared_secret")
if base["appservice.provisioning.shared_secret"] == "generate": if base["appservice.provisioning.shared_secret"] == "generate":
base["appservice.provisioning.shared_secret"] = self._new_token() base["appservice.provisioning.shared_secret"] = self._new_token()
@@ -137,7 +141,6 @@ class Config(BaseBridgeConfig):
copy("bridge.inline_images") copy("bridge.inline_images")
copy("bridge.image_as_file_size") copy("bridge.image_as_file_size")
copy("bridge.image_as_file_pixels") copy("bridge.image_as_file_pixels")
copy("bridge.max_document_size")
copy("bridge.parallel_file_transfer") copy("bridge.parallel_file_transfer")
copy("bridge.federate_rooms") copy("bridge.federate_rooms")
copy("bridge.animated_sticker.target") copy("bridge.animated_sticker.target")
@@ -160,6 +163,7 @@ class Config(BaseBridgeConfig):
copy("bridge.tag_only_on_create") copy("bridge.tag_only_on_create")
copy("bridge.bridge_matrix_leave") copy("bridge.bridge_matrix_leave")
copy("bridge.kick_on_logout") copy("bridge.kick_on_logout")
copy("bridge.always_read_joined_telegram_notice")
copy("bridge.backfill.invite_own_puppet") copy("bridge.backfill.invite_own_puppet")
copy("bridge.backfill.takeout_limit") copy("bridge.backfill.takeout_limit")
copy("bridge.backfill.initial_limit") copy("bridge.backfill.initial_limit")
+3 -3
View File
@@ -21,7 +21,7 @@ from asyncpg import Record
from attr import dataclass from attr import dataclass
from mautrix.types import EventID, RoomID from mautrix.types import EventID, RoomID
from mautrix.util.async_db import Database from mautrix.util.async_db import Database, Scheme
from ..types import TelegramID from ..types import TelegramID
@@ -76,7 +76,7 @@ class Message:
async def get_first_by_tgids( async def get_first_by_tgids(
cls, tgids: list[TelegramID], tg_space: TelegramID cls, tgids: list[TelegramID], tg_space: TelegramID
) -> list[Message]: ) -> list[Message]:
if cls.db.scheme == "postgres": if cls.db.scheme in (Scheme.POSTGRES, Scheme.COCKROACH):
q = ( q = (
f"SELECT {cls.columns} FROM message" f"SELECT {cls.columns} FROM message"
" WHERE tgid=ANY($1) AND tg_space=$2 AND edit_index=0" " WHERE tgid=ANY($1) AND tg_space=$2 AND edit_index=0"
@@ -123,7 +123,7 @@ class Message:
async def get_by_mxids( async def get_by_mxids(
cls, mxids: list[EventID], mx_room: RoomID, tg_space: TelegramID cls, mxids: list[EventID], mx_room: RoomID, tg_space: TelegramID
) -> list[Message]: ) -> list[Message]:
if cls.db.scheme == "postgres": if cls.db.scheme in (Scheme.POSTGRES, Scheme.COCKROACH):
q = ( q = (
f"SELECT {cls.columns} FROM message" f"SELECT {cls.columns} FROM message"
" WHERE mxid=ANY($1) AND mx_room=$2 AND tg_space=$3" " WHERE mxid=ANY($1) AND mx_room=$2 AND tg_space=$3"
+9 -6
View File
@@ -43,6 +43,7 @@ class Puppet:
displayname_quality: int displayname_quality: int
disable_updates: bool disable_updates: bool
username: str | None username: str | None
phone: str | None
photo_id: str | None photo_id: str | None
avatar_url: ContentURI | None avatar_url: ContentURI | None
name_set: bool name_set: bool
@@ -65,7 +66,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, photo_id, avatar_url, " "displayname_quality, disable_updates, username, phone, photo_id, avatar_url, "
"name_set, avatar_set, is_bot, is_channel, custom_mxid, access_token, next_batch, base_url" "name_set, avatar_set, is_bot, is_channel, custom_mxid, access_token, next_batch, base_url"
) )
@@ -105,6 +106,7 @@ class Puppet:
self.displayname_quality, self.displayname_quality,
self.disable_updates, self.disable_updates,
self.username, self.username,
self.phone,
self.photo_id, self.photo_id,
self.avatar_url, self.avatar_url,
self.name_set, self.name_set,
@@ -121,9 +123,9 @@ class Puppet:
q = """ q = """
UPDATE puppet UPDATE puppet
SET is_registered=$2, displayname=$3, displayname_source=$4, displayname_contact=$5, SET is_registered=$2, displayname=$3, displayname_source=$4, displayname_contact=$5,
displayname_quality=$6, disable_updates=$7, username=$8, photo_id=$9, displayname_quality=$6, disable_updates=$7, username=$8, phone=$9, photo_id=$10,
avatar_url=$10, name_set=$11, avatar_set=$12, is_bot=$13, is_channel=$14, avatar_url=$11, name_set=$12, avatar_set=$13, is_bot=$14, is_channel=$15,
custom_mxid=$15, access_token=$16, next_batch=$17, base_url=$18 custom_mxid=$16, access_token=$17, next_batch=$18, base_url=$19
WHERE id=$1 WHERE id=$1
""" """
await self.db.execute(q, *self._values) await self.db.execute(q, *self._values)
@@ -132,8 +134,9 @@ class Puppet:
q = """ q = """
INSERT INTO puppet ( INSERT INTO puppet (
id, is_registered, displayname, displayname_source, displayname_contact, id, is_registered, displayname, displayname_source, displayname_contact,
displayname_quality, disable_updates, username, photo_id, avatar_url, name_set, displayname_quality, disable_updates, username, phone, photo_id, avatar_url, name_set,
avatar_set, is_bot, is_channel, custom_mxid, access_token, next_batch, base_url avatar_set, is_bot, is_channel, custom_mxid, access_token, next_batch, base_url
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18,
$19)
""" """
await self.db.execute(q, *self._values) await self.db.execute(q, *self._values)
+3 -3
View File
@@ -24,7 +24,7 @@ from telethon.crypto import AuthKey
from telethon.sessions import MemorySession from telethon.sessions import MemorySession
from telethon.tl.types import PeerChannel, PeerChat, PeerUser, updates from telethon.tl.types import PeerChannel, PeerChat, PeerUser, updates
from mautrix.util.async_db import Database from mautrix.util.async_db import Database, Scheme
fake_db = Database.create("") if TYPE_CHECKING else None fake_db = Database.create("") if TYPE_CHECKING else None
@@ -153,7 +153,7 @@ class PgSession(MemorySession):
] = self._entities_to_rows(tlo) ] = self._entities_to_rows(tlo)
if not rows: if not rows:
return return
if self.db.scheme == "postgres": if self.db.scheme == Scheme.POSTGRES:
q = ( q = (
"INSERT INTO telethon_entities (session_id, id, hash, username, phone, name) " "INSERT INTO telethon_entities (session_id, id, hash, username, phone, name) "
"VALUES ($1, unnest($2::bigint[]), unnest($3::bigint[]), " "VALUES ($1, unnest($2::bigint[]), unnest($3::bigint[]), "
@@ -201,7 +201,7 @@ class PgSession(MemorySession):
utils.get_peer_id(PeerChat(key)), utils.get_peer_id(PeerChat(key)),
utils.get_peer_id(PeerChannel(key)), utils.get_peer_id(PeerChannel(key)),
) )
if self.db.scheme == "postgres": if self.db.scheme in (Scheme.POSTGRES, Scheme.COCKROACH):
return await self._select_entity("id=ANY($1)", ids) return await self._select_entity("id=ANY($1)", ids)
else: else:
return await self._select_entity(f"id IN ($1, $2, $3)", *ids) return await self._select_entity(f"id IN ($1, $2, $3)", *ids)
+1
View File
@@ -9,4 +9,5 @@ from . import (
v04_disappearing_messages, v04_disappearing_messages,
v05_channel_ghosts, v05_channel_ghosts,
v06_puppet_avatar_url, v06_puppet_avatar_url,
v07_puppet_phone_number,
) )
@@ -0,0 +1,200 @@
# 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
async def create_v7_tables(conn: Connection) -> int:
await conn.execute(
"""CREATE TABLE "user" (
mxid TEXT PRIMARY KEY,
tgid BIGINT UNIQUE,
tg_username TEXT,
tg_phone TEXT,
is_bot BOOLEAN NOT NULL DEFAULT false,
saved_contacts INTEGER NOT NULL DEFAULT 0
)"""
)
await conn.execute(
"""CREATE TABLE portal (
tgid BIGINT,
tg_receiver BIGINT,
peer_type TEXT NOT NULL,
mxid TEXT UNIQUE,
avatar_url TEXT,
encrypted BOOLEAN NOT NULL DEFAULT false,
username TEXT,
title TEXT,
about TEXT,
photo_id TEXT,
name_set BOOLEAN NOT NULL DEFAULT false,
avatar_set BOOLEAN NOT NULL DEFAULT false,
megagroup BOOLEAN,
config jsonb,
sponsored_event_id TEXT,
sponsored_event_ts BIGINT,
sponsored_msg_random_id bytea,
PRIMARY KEY (tgid, tg_receiver)
)"""
)
await conn.execute(
"""CREATE TABLE message (
mxid TEXT NOT NULL,
mx_room TEXT NOT NULL,
tgid BIGINT,
tg_space BIGINT,
edit_index INTEGER,
redacted BOOLEAN NOT NULL DEFAULT false,
content_hash bytea,
PRIMARY KEY (tgid, tg_space, edit_index),
UNIQUE (mxid, mx_room, tg_space)
)"""
)
await conn.execute(
"""CREATE TABLE reaction (
mxid TEXT NOT NULL,
mx_room TEXT NOT NULL,
msg_mxid TEXT NOT NULL,
tg_sender BIGINT,
reaction TEXT NOT NULL,
PRIMARY KEY (msg_mxid, mx_room, tg_sender),
UNIQUE (mxid, mx_room)
)"""
)
await conn.execute(
"""CREATE TABLE disappearing_message (
room_id TEXT,
event_id TEXT,
expiration_seconds BIGINT,
expiration_ts BIGINT,
PRIMARY KEY (room_id, event_id)
)"""
)
await conn.execute(
"""CREATE TABLE puppet (
id BIGINT PRIMARY KEY,
is_registered BOOLEAN NOT NULL DEFAULT false,
displayname TEXT,
displayname_source BIGINT,
displayname_contact BOOLEAN NOT NULL DEFAULT true,
displayname_quality INTEGER NOT NULL DEFAULT 0,
disable_updates BOOLEAN NOT NULL DEFAULT false,
username TEXT,
phone TEXT,
photo_id TEXT,
avatar_url TEXT,
name_set BOOLEAN NOT NULL DEFAULT false,
avatar_set BOOLEAN NOT NULL DEFAULT false,
is_bot BOOLEAN,
is_channel BOOLEAN NOT NULL DEFAULT false,
access_token TEXT,
custom_mxid TEXT,
next_batch TEXT,
base_url TEXT
)"""
)
await conn.execute(
"""CREATE TABLE telegram_file (
id TEXT PRIMARY KEY,
mxc TEXT NOT NULL,
mime_type TEXT,
was_converted BOOLEAN NOT NULL DEFAULT false,
timestamp BIGINT NOT NULL DEFAULT 0,
size BIGINT,
width INTEGER,
height INTEGER,
thumbnail TEXT,
decryption_info jsonb,
FOREIGN KEY (thumbnail) REFERENCES telegram_file(id)
ON UPDATE CASCADE ON DELETE SET NULL
)"""
)
await conn.execute(
"""CREATE TABLE bot_chat (
id BIGINT PRIMARY KEY,
type TEXT NOT NULL
)"""
)
await conn.execute(
"""CREATE TABLE user_portal (
"user" BIGINT,
portal BIGINT,
portal_receiver BIGINT,
PRIMARY KEY ("user", portal, portal_receiver),
FOREIGN KEY ("user") REFERENCES "user"(tgid) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (portal, portal_receiver) REFERENCES portal(tgid, tg_receiver)
ON DELETE CASCADE ON UPDATE CASCADE
)"""
)
await conn.execute(
"""CREATE TABLE contact (
"user" BIGINT,
contact BIGINT,
PRIMARY KEY ("user", contact),
FOREIGN KEY ("user") REFERENCES "user"(tgid) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (contact) REFERENCES puppet(id) ON DELETE CASCADE ON UPDATE CASCADE
)"""
)
await conn.execute(
"""CREATE TABLE telethon_sessions (
session_id TEXT PRIMARY KEY,
dc_id INTEGER,
server_address TEXT,
port INTEGER,
auth_key bytea
)"""
)
await conn.execute(
"""CREATE TABLE telethon_entities (
session_id TEXT,
id BIGINT,
hash BIGINT NOT NULL,
username TEXT,
phone TEXT,
name TEXT,
PRIMARY KEY (session_id, id)
)"""
)
await conn.execute(
"""CREATE TABLE telethon_sent_files (
session_id TEXT,
md5_digest bytea,
file_size INTEGER,
type INTEGER,
id BIGINT,
hash BIGINT,
PRIMARY KEY (session_id, md5_digest, file_size, type)
)"""
)
await conn.execute(
"""CREATE TABLE telethon_update_state (
session_id TEXT,
entity_id BIGINT,
pts BIGINT,
qts BIGINT,
date BIGINT,
seq BIGINT,
unread_count INTEGER,
PRIMARY KEY (session_id, entity_id)
)"""
)
return 7
@@ -15,29 +15,38 @@
# 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 asyncpg import Connection from mautrix.util.async_db import Connection, Scheme
from . import upgrade_table from . import upgrade_table
from .v00_latest_revision import create_v7_tables
legacy_version_query = "SELECT version_num FROM alembic_version" legacy_version_query = "SELECT version_num FROM alembic_version"
last_legacy_version = "bfc0a39bfe02" last_legacy_version = "bfc0a39bfe02"
def table_exists(scheme: str, name: str) -> str: def table_exists(scheme: str, name: str) -> str:
if scheme == "sqlite": if scheme == Scheme.SQLITE:
return f"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='{name}')" return f"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='{name}')"
elif scheme == "postgres": elif scheme in (Scheme.POSTGRES, Scheme.COCKROACH):
return f"SELECT EXISTS(SELECT FROM information_schema.tables WHERE table_name='{name}')" return f"SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_name='{name}')"
raise RuntimeError("unsupported database scheme") raise RuntimeError("unsupported database scheme")
@upgrade_table.register(description="Initial asyncpg revision") async def first_upgrade_target(conn: Connection, scheme: str) -> int:
async def upgrade_v1(conn: Connection, scheme: str) -> None: is_legacy = await conn.fetchval(table_exists(scheme, "alembic_version"))
# If it's a legacy db, the upgrade process will go to v1 and run each migration up to v7.
# If it's a new db, we'll create the v7 tables directly (see the create_v7_tables call).
return 1 if is_legacy else 7
@upgrade_table.register(description="Initial asyncpg revision", upgrades_to=first_upgrade_target)
async def upgrade_v1(conn: Connection, scheme: str) -> int:
is_legacy = await conn.fetchval(table_exists(scheme, "alembic_version")) is_legacy = await conn.fetchval(table_exists(scheme, "alembic_version"))
if is_legacy: if is_legacy:
await migrate_legacy_to_v1(conn, scheme) await migrate_legacy_to_v1(conn, scheme)
return 1
else: else:
await create_v1_tables(conn) return await create_v7_tables(conn)
async def drop_constraints(conn: Connection, table: str, contype: str) -> None: async def drop_constraints(conn: Connection, table: str, contype: str) -> None:
@@ -178,151 +187,3 @@ async def varchar_to_text(conn: Connection) -> None:
for table, columns in columns_to_adjust.items(): for table, columns in columns_to_adjust.items():
for column in columns: for column in columns:
await conn.execute(f'ALTER TABLE "{table}" ALTER COLUMN {column} TYPE TEXT') await conn.execute(f'ALTER TABLE "{table}" ALTER COLUMN {column} TYPE TEXT')
async def create_v1_tables(conn: Connection) -> None:
await conn.execute(
"""CREATE TABLE "user" (
mxid TEXT PRIMARY KEY,
tgid BIGINT UNIQUE,
tg_username TEXT,
tg_phone TEXT,
is_bot BOOLEAN NOT NULL DEFAULT false,
saved_contacts INTEGER NOT NULL DEFAULT 0
)"""
)
await conn.execute(
"""CREATE TABLE portal (
tgid BIGINT,
tg_receiver BIGINT,
peer_type TEXT NOT NULL,
mxid TEXT UNIQUE,
avatar_url TEXT,
encrypted BOOLEAN NOT NULL DEFAULT false,
username TEXT,
title TEXT,
about TEXT,
photo_id TEXT,
megagroup BOOLEAN,
config jsonb,
PRIMARY KEY (tgid, tg_receiver)
)"""
)
await conn.execute(
"""CREATE TABLE message (
mxid TEXT,
mx_room TEXT,
tgid BIGINT NOT NULL,
tg_space BIGINT NOT NULL,
edit_index INTEGER NOT NULL,
redacted BOOLEAN NOT NULL DEFAULT false,
PRIMARY KEY (tgid, tg_space, edit_index),
UNIQUE (mxid, mx_room, tg_space)
)"""
)
await conn.execute(
"""CREATE TABLE puppet (
id BIGINT PRIMARY KEY,
is_registered BOOLEAN NOT NULL DEFAULT false,
displayname TEXT,
displayname_source BIGINT,
displayname_contact BOOLEAN NOT NULL DEFAULT true,
displayname_quality INTEGER NOT NULL DEFAULT 0,
disable_updates BOOLEAN NOT NULL DEFAULT false,
username TEXT,
photo_id TEXT,
is_bot BOOLEAN,
access_token TEXT,
custom_mxid TEXT,
next_batch TEXT,
base_url TEXT
)"""
)
await conn.execute(
"""CREATE TABLE telegram_file (
id TEXT PRIMARY KEY,
mxc TEXT NOT NULL,
mime_type TEXT,
was_converted BOOLEAN NOT NULL DEFAULT false,
timestamp BIGINT NOT NULL DEFAULT 0,
size BIGINT,
width INTEGER,
height INTEGER,
thumbnail TEXT,
decryption_info jsonb,
FOREIGN KEY (thumbnail) REFERENCES telegram_file(id)
ON UPDATE CASCADE ON DELETE SET NULL
)"""
)
await conn.execute(
"""CREATE TABLE bot_chat (
id BIGINT PRIMARY KEY,
type TEXT NOT NULL
)"""
)
await conn.execute(
"""CREATE TABLE user_portal (
"user" BIGINT,
portal BIGINT,
portal_receiver BIGINT,
PRIMARY KEY ("user", portal, portal_receiver),
FOREIGN KEY ("user") REFERENCES "user"(tgid) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (portal, portal_receiver) REFERENCES portal(tgid, tg_receiver)
ON DELETE CASCADE ON UPDATE CASCADE
)"""
)
await conn.execute(
"""CREATE TABLE contact (
"user" BIGINT,
contact BIGINT,
PRIMARY KEY ("user", contact),
FOREIGN KEY ("user") REFERENCES "user"(tgid) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (contact) REFERENCES puppet(id) ON DELETE CASCADE ON UPDATE CASCADE
)"""
)
await conn.execute(
"""CREATE TABLE telethon_sessions (
session_id TEXT PRIMARY KEY,
dc_id INTEGER,
server_address TEXT,
port INTEGER,
auth_key bytea
)"""
)
await conn.execute(
"""CREATE TABLE telethon_entities (
session_id TEXT,
id BIGINT,
hash BIGINT NOT NULL,
username TEXT,
phone TEXT,
name TEXT,
PRIMARY KEY (session_id, id)
)"""
)
await conn.execute(
"""CREATE TABLE telethon_sent_files (
session_id TEXT,
md5_digest bytea,
file_size INTEGER,
type INTEGER,
id BIGINT,
hash BIGINT,
PRIMARY KEY (session_id, md5_digest, file_size, type)
)"""
)
await conn.execute(
"""CREATE TABLE telethon_update_state (
session_id TEXT,
entity_id BIGINT,
pts BIGINT,
qts BIGINT,
date BIGINT,
seq BIGINT,
unread_count INTEGER,
PRIMARY KEY (session_id, entity_id)
)"""
)
@@ -13,7 +13,7 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from asyncpg import Connection from mautrix.util.async_db import Connection
from . import upgrade_table from . import upgrade_table
+1 -1
View File
@@ -13,7 +13,7 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from asyncpg import Connection from mautrix.util.async_db import Connection
from . import upgrade_table from . import upgrade_table
@@ -13,7 +13,7 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from asyncpg import Connection from mautrix.util.async_db import Connection
from . import upgrade_table from . import upgrade_table
@@ -13,7 +13,7 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from asyncpg import Connection from mautrix.util.async_db import Connection, Scheme
from . import upgrade_table from . import upgrade_table
@@ -21,5 +21,5 @@ from . import upgrade_table
@upgrade_table.register(description="Add separate ghost users for channel senders") @upgrade_table.register(description="Add separate ghost users for channel senders")
async def upgrade_v5(conn: Connection, scheme: str) -> None: async def upgrade_v5(conn: Connection, scheme: str) -> None:
await conn.execute("ALTER TABLE puppet ADD COLUMN is_channel BOOLEAN NOT NULL DEFAULT false") await conn.execute("ALTER TABLE puppet ADD COLUMN is_channel BOOLEAN NOT NULL DEFAULT false")
if scheme == "postgres": if scheme == Scheme.POSTGRES:
await conn.execute("ALTER TABLE puppet ALTER COLUMN is_channel DROP DEFAULT") await conn.execute("ALTER TABLE puppet ALTER COLUMN is_channel DROP DEFAULT")
@@ -13,7 +13,7 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from asyncpg import Connection from mautrix.util.async_db import Connection
from . import upgrade_table from . import upgrade_table
@@ -0,0 +1,23 @@
# 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="Store phone number in puppet table")
async def upgrade_v7(conn: Connection) -> None:
await conn.execute("ALTER TABLE puppet ADD COLUMN phone TEXT")
+3 -3
View File
@@ -21,7 +21,7 @@ from asyncpg import Record
from attr import dataclass from attr import dataclass
from mautrix.types import UserID from mautrix.types import UserID
from mautrix.util.async_db import Database from mautrix.util.async_db import Database, Scheme
from ..types import TelegramID from ..types import TelegramID
@@ -104,7 +104,7 @@ class User:
records = [(self.tgid, puppet_id) for puppet_id in puppets] records = [(self.tgid, puppet_id) for puppet_id in puppets]
async with self.db.acquire() as conn, conn.transaction(): async with self.db.acquire() as conn, conn.transaction():
await conn.execute('DELETE FROM contact WHERE "user"=$1', self.tgid) await conn.execute('DELETE FROM contact WHERE "user"=$1', self.tgid)
if self.db.scheme == "postgres": if self.db.scheme == Scheme.POSTGRES:
await conn.copy_records_to_table("contact", records=records, columns=columns) await conn.copy_records_to_table("contact", records=records, columns=columns)
else: else:
q = 'INSERT INTO contact ("user", contact) VALUES ($1, $2)' q = 'INSERT INTO contact ("user", contact) VALUES ($1, $2)'
@@ -120,7 +120,7 @@ class User:
records = [(self.tgid, tgid, tg_receiver) for tgid, tg_receiver in portals] records = [(self.tgid, tgid, tg_receiver) for tgid, tg_receiver in portals]
async with self.db.acquire() as conn, conn.transaction(): async with self.db.acquire() as conn, conn.transaction():
await conn.execute('DELETE FROM user_portal WHERE "user"=$1', self.tgid) await conn.execute('DELETE FROM user_portal WHERE "user"=$1', self.tgid)
if self.db.scheme == "postgres": if self.db.scheme == Scheme.POSTGRES:
await conn.copy_records_to_table("user_portal", records=records, columns=columns) await conn.copy_records_to_table("user_portal", records=records, columns=columns)
else: else:
q = 'INSERT INTO user_portal ("user", portal, portal_receiver) VALUES ($1, $2, $3)' q = 'INSERT INTO user_portal ("user", portal, portal_receiver) VALUES ($1, $2, $3)'
+6 -3
View File
@@ -16,6 +16,9 @@ homeserver:
status_endpoint: null status_endpoint: null
# Endpoint for reporting per-message status. # Endpoint for reporting per-message status.
message_send_checkpoint_endpoint: null message_send_checkpoint_endpoint: null
# Whether asynchronous uploads via MSC2246 should be enabled for media.
# Requires a media repo that supports MSC2246.
async_media: false
# Application service host/registration related details # Application service host/registration related details
# Changing these values requires regeneration of the registration. # Changing these values requires regeneration of the registration.
@@ -64,7 +67,7 @@ appservice:
# Whether or not the provisioning API should be enabled. # Whether or not the provisioning API should be enabled.
enabled: true enabled: true
# The prefix to use in the provisioning API endpoints. # The prefix to use in the provisioning API endpoints.
prefix: /_matrix/provision/v1 prefix: /_matrix/provision
# The shared secret to authorize users of the API. # The shared secret to authorize users of the API.
# Set to "generate" to generate and save a new token. # Set to "generate" to generate and save a new token.
shared_secret: generate shared_secret: generate
@@ -210,8 +213,6 @@ bridge:
image_as_file_size: 10 image_as_file_size: 10
# Maximum number of pixels in an image before sending to Telegram as a document. Defaults to 1280x1280 = 1638400. # Maximum number of pixels in an image before sending to Telegram as a document. Defaults to 1280x1280 = 1638400.
image_as_file_pixels: 1638400 image_as_file_pixels: 1638400
# Maximum size of Telegram documents in megabytes to bridge.
max_document_size: 100
# Enable experimental parallel file transfer, which makes uploads/downloads much faster by # Enable experimental parallel file transfer, which makes uploads/downloads much faster by
# 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.
@@ -281,6 +282,8 @@ bridge:
bridge_matrix_leave: true bridge_matrix_leave: true
# Should the user be kicked out of all portals when logging out of the bridge? # Should the user be kicked out of all portals when logging out of the bridge?
kick_on_logout: true kick_on_logout: true
# Should the "* user joined Telegram" notice always be marked as read automatically?
always_read_joined_telegram_notice: true
# Settings for backfilling messages from Telegram. # Settings for backfilling messages from Telegram.
backfill: backfill:
# Whether or not the Telegram ghosts of logged in Matrix users should be # Whether or not the Telegram ghosts of logged in Matrix users should be
+59 -28
View File
@@ -20,7 +20,7 @@ import logging
import re import re
from telethon.errors import RPCError from telethon.errors import RPCError
from telethon.helpers import add_surrogate, del_surrogate from telethon.helpers import add_surrogate, del_surrogate, within_surrogate
from telethon.tl.custom import Message from telethon.tl.custom import Message
from telethon.tl.types import ( from telethon.tl.types import (
MessageEntityBlockquote, MessageEntityBlockquote,
@@ -52,9 +52,9 @@ from mautrix.appservice import IntentAPI
from mautrix.types import ( from mautrix.types import (
EventType, EventType,
Format, Format,
InReplyTo,
MessageType, MessageType,
RelatesTo, RelatesTo,
RelationType,
TextMessageEventContent, TextMessageEventContent,
) )
@@ -74,16 +74,13 @@ async def telegram_reply_to_matrix(evt: Message, source: au.AbstractUser) -> Rel
) )
msg = await DBMessage.get_one_by_tgid(TelegramID(evt.reply_to.reply_to_msg_id), space) msg = await DBMessage.get_one_by_tgid(TelegramID(evt.reply_to.reply_to_msg_id), space)
if msg: if msg:
return RelatesTo(rel_type=RelationType.REPLY, event_id=msg.mxid) return RelatesTo(in_reply_to=InReplyTo(event_id=msg.mxid))
return None return None
async def _add_forward_header( async def _add_forward_header(
source: au.AbstractUser, content: TextMessageEventContent, fwd_from: MessageFwdHeader source: au.AbstractUser, content: TextMessageEventContent, fwd_from: MessageFwdHeader
) -> None: ) -> None:
if not content.formatted_body or content.format != Format.HTML:
content.format = Format.HTML
content.formatted_body = escape(content.body)
fwd_from_html, fwd_from_text = None, None fwd_from_html, fwd_from_text = None, None
if isinstance(fwd_from.from_id, PeerUser): if isinstance(fwd_from.from_id, PeerUser):
user = await u.User.get_by_tgid(TelegramID(fwd_from.from_id.user_id)) user = await u.User.get_by_tgid(TelegramID(fwd_from.from_id.user_id))
@@ -139,6 +136,7 @@ async def _add_forward_header(
fwd_from_text = "unknown source" fwd_from_text = "unknown source"
fwd_from_html = f"unknown source" fwd_from_html = f"unknown source"
content.ensure_has_html()
content.body = "\n".join([f"> {line}" for line in content.body.split("\n")]) content.body = "\n".join([f"> {line}" for line in content.body.split("\n")])
content.body = f"Forwarded from {fwd_from_text}:\n{content.body}" content.body = f"Forwarded from {fwd_from_text}:\n{content.body}"
content.formatted_body = ( content.formatted_body = (
@@ -160,8 +158,6 @@ async def _add_reply_header(
if not msg: if not msg:
return return
content.relates_to = RelatesTo(rel_type=RelationType.REPLY, event_id=msg.mxid)
try: try:
event = await main_intent.get_event(msg.mx_room, msg.mxid) event = await main_intent.get_event(msg.mx_room, msg.mxid)
if event.type == EventType.ROOM_ENCRYPTED and source.bridge.matrix.e2ee: if event.type == EventType.ROOM_ENCRYPTED and source.bridge.matrix.e2ee:
@@ -172,6 +168,7 @@ async def _add_reply_header(
content.set_reply(event, displayname=puppet.displayname if puppet else event.sender) content.set_reply(event, displayname=puppet.displayname if puppet else event.sender)
except Exception: except Exception:
log.exception("Failed to get event to add reply fallback") log.exception("Failed to get event to add reply fallback")
content.set_reply(msg.mxid)
async def telegram_to_matrix( async def telegram_to_matrix(
@@ -193,18 +190,13 @@ async def telegram_to_matrix(
if entities: if entities:
content.format = Format.HTML content.format = Format.HTML
html = await _telegram_entities_to_matrix_catch(add_surrogate(content.body), entities) html = await _telegram_entities_to_matrix_catch(add_surrogate(content.body), entities)
content.formatted_body = del_surrogate(html).replace("\n", "<br/>") content.formatted_body = del_surrogate(html)
def force_html():
if not content.formatted_body:
content.format = Format.HTML
content.formatted_body = escape(content.body).replace("\n", "<br/>")
if require_html: if require_html:
force_html() content.ensure_has_html()
if prefix_html: if prefix_html:
force_html() content.ensure_has_html()
content.formatted_body = prefix_html + content.formatted_body content.formatted_body = prefix_html + content.formatted_body
if prefix_text: if prefix_text:
content.body = prefix_text + content.body content.body = prefix_text + content.body
@@ -216,7 +208,7 @@ async def telegram_to_matrix(
await _add_reply_header(source, content, evt, main_intent) await _add_reply_header(source, content, evt, main_intent)
if isinstance(evt, Message) and evt.post and evt.post_author: if isinstance(evt, Message) and evt.post and evt.post_author:
force_html() content.ensure_has_html()
content.body += f"\n- {evt.post_author}" content.body += f"\n- {evt.post_author}"
content.formatted_body += f"<br/><i>- <u>{evt.post_author}</u></i>" content.formatted_body += f"<br/><i>- <u>{evt.post_author}</u></i>"
@@ -234,30 +226,51 @@ async def _telegram_entities_to_matrix_catch(text: str, entities: list[TypeMessa
async def _telegram_entities_to_matrix( async def _telegram_entities_to_matrix(
text: str, entities: list[TypeMessageEntity], offset: int = 0, length: int = None text: str,
entities: list[TypeMessageEntity],
offset: int = 0,
length: int = None,
in_codeblock: bool = False,
) -> str: ) -> str:
def text_to_html(
val: str, _in_codeblock: bool = in_codeblock, escape_html: bool = True
) -> str:
if escape_html:
val = escape(val)
if not _in_codeblock:
val = val.replace("\n", "<br/>")
return val
if not entities: if not entities:
return escape(text) return text_to_html(text)
if length is None: if length is None:
length = len(text) length = len(text)
html = [] html = []
last_offset = 0 last_offset = 0
for i, entity in enumerate(entities): for i, entity in enumerate(entities):
if entity.offset > offset + length: if entity.offset >= offset + length:
break break
relative_offset = entity.offset - offset relative_offset = entity.offset - offset
if relative_offset > last_offset: if relative_offset > last_offset:
html.append(escape(text[last_offset:relative_offset])) html.append(text_to_html(text[last_offset:relative_offset]))
elif relative_offset < last_offset: elif relative_offset < last_offset:
continue continue
while within_surrogate(text, relative_offset, length=length):
relative_offset += 1
while within_surrogate(text, relative_offset + entity.length, length=length):
entity.length += 1
skip_entity = False skip_entity = False
is_code_entity = isinstance(entity, (MessageEntityCode, MessageEntityPre))
entity_text = await _telegram_entities_to_matrix( entity_text = await _telegram_entities_to_matrix(
text=text[relative_offset : relative_offset + entity.length], text=text[relative_offset : relative_offset + entity.length],
entities=entities[i + 1 :], entities=entities[i + 1 :],
offset=entity.offset, offset=entity.offset,
length=entity.length, length=entity.length,
in_codeblock=is_code_entity,
) )
entity_text = text_to_html(entity_text, is_code_entity, escape_html=False)
entity_type = type(entity) entity_type = type(entity)
if entity_type == MessageEntityBold: if entity_type == MessageEntityBold:
@@ -285,7 +298,7 @@ async def _telegram_entities_to_matrix(
elif entity_type == MessageEntityEmail: elif entity_type == MessageEntityEmail:
html.append(f"<a href='mailto:{entity_text}'>{entity_text}</a>") html.append(f"<a href='mailto:{entity_text}'>{entity_text}</a>")
elif entity_type in (MessageEntityTextUrl, MessageEntityUrl): elif entity_type in (MessageEntityTextUrl, MessageEntityUrl):
skip_entity = await _parse_url( await _parse_url(
html, entity_text, entity.url if entity_type == MessageEntityTextUrl else None html, entity_text, entity.url if entity_type == MessageEntityTextUrl else None
) )
elif entity_type in ( elif entity_type in (
@@ -300,7 +313,7 @@ async def _telegram_entities_to_matrix(
else: else:
skip_entity = True skip_entity = True
last_offset = relative_offset + (0 if skip_entity else entity.length) last_offset = relative_offset + (0 if skip_entity else entity.length)
html.append(escape(text[last_offset:])) html.append(text_to_html(text[last_offset:]))
return "".join(html) return "".join(html)
@@ -316,12 +329,24 @@ def _parse_pre(html: list[str], entity_text: str, language: str) -> bool:
async def _parse_mention(html: list[str], entity_text: str) -> bool: async def _parse_mention(html: list[str], entity_text: str) -> bool:
username = entity_text[1:] username = entity_text[1:]
mxid = None
portal = None
# This is a bit complicated because public channels have both Puppet and Portal instances.
# Basically the currently intended output is:
# User/bot mention (bridge user) -> real user mention
# User/bot mention (normal Telegram user) -> ghost user mention
# Public channel with existing portal -> room mention
# Public channel without portal -> ghost user mention
# Other chat -> room mention
user = await u.User.find_by_username(username) or await pu.Puppet.find_by_username(username) user = await u.User.find_by_username(username) or await pu.Puppet.find_by_username(username)
if user: if user:
if isinstance(user, pu.Puppet) and user.is_channel:
portal = await po.Portal.get_by_tgid(user.tgid)
mxid = user.mxid mxid = user.mxid
else: else:
portal = await po.Portal.find_by_username(username) portal = await po.Portal.find_by_username(username)
mxid = portal.alias or portal.mxid if portal else None if portal and (portal.mxid or not user):
mxid = portal.alias or portal.mxid
if mxid: if mxid:
html.append(f"<a href='https://matrix.to/#/{mxid}'>{entity_text}</a>") html.append(f"<a href='https://matrix.to/#/{mxid}'>{entity_text}</a>")
@@ -345,11 +370,15 @@ async def _parse_name_mention(html: list[str], entity_text: str, user_id: Telegr
message_link_regex = re.compile( message_link_regex = re.compile(
r"https?://t(?:elegram)?\.(?:me|dog)/([A-Za-z][A-Za-z0-9_]{3,}[A-Za-z0-9])/([0-9]{1,50})" r"https?://t(?:elegram)?\.(?:me|dog)"
# /username or /c/id
r"/([A-Za-z][A-Za-z0-9_]{3,31}[A-Za-z0-9]|[Cc]/[0-9]{1,20})"
# /messageid
r"/([0-9]{1,20})"
) )
async def _parse_url(html: list[str], entity_text: str, url: str) -> bool: async def _parse_url(html: list[str], entity_text: str, url: str):
url = escape(url) if url else entity_text url = escape(url) if url else entity_text
if not url.startswith(("https://", "http://", "ftp://", "magnet://")): if not url.startswith(("https://", "http://", "ftp://", "magnet://")):
url = "http://" + url url = "http://" + url
@@ -359,11 +388,13 @@ async def _parse_url(html: list[str], entity_text: str, url: str) -> bool:
group, msgid_str = message_link_match.groups() group, msgid_str = message_link_match.groups()
msgid = int(msgid_str) msgid = int(msgid_str)
portal = await po.Portal.find_by_username(group) if group.lower().startswith("c/"):
portal = await po.Portal.get_by_tgid(TelegramID(int(group[2:])))
else:
portal = await po.Portal.find_by_username(group)
if portal: if portal:
message = await DBMessage.get_one_by_tgid(TelegramID(msgid), portal.tgid) message = await DBMessage.get_one_by_tgid(TelegramID(msgid), portal.tgid)
if message: if message:
url = f"https://matrix.to/#/{portal.mxid}/{message.mxid}" url = f"https://matrix.to/#/{portal.mxid}/{message.mxid}"
html.append(f"<a href='{url}'>{entity_text}</a>") html.append(f"<a href='{url}'>{entity_text}</a>")
return False
+18 -90
View File
@@ -61,98 +61,22 @@ class MatrixHandler(BaseMatrixHandler):
self._previously_typing = {} self._previously_typing = {}
async def handle_puppet_invite( async def handle_puppet_group_invite(
self, room_id: RoomID, puppet: pu.Puppet, inviter: u.User, event_id: EventID self,
room_id: RoomID,
puppet: pu.Puppet,
invited_by: u.User,
evt: StateEvent,
members: list[UserID],
) -> None: ) -> None:
intent = puppet.default_mxid_intent
self.log.debug(f"{inviter.mxid} invited puppet for {puppet.tgid} to {room_id}")
if puppet.is_channel:
self.log.debug(f"Rejecting invite for {puppet.tgid} to {room_id}: puppet is a channel")
await intent.leave_room(room_id, reason="Channels can't be invited to chats")
return
if not await inviter.is_logged_in():
self.log.debug(f"Rejecting invite for {puppet.tgid} to {room_id}: user not logged in")
await intent.leave_room(
room_id,
reason="Only users who are logged into the bridge can invite Telegram ghosts.",
)
return
portal = await po.Portal.get_by_mxid(room_id)
if portal:
if portal.peer_type == "user":
await intent.error_and_leave(
room_id, text="You can not invite additional users to private chats."
)
return
await portal.invite_telegram(inviter, puppet)
await intent.join_room(room_id)
return
try:
members = await intent.get_room_members(room_id)
except MatrixError:
self.log.exception(f"Failed to get members after joining {room_id} as {intent.mxid}")
return
if self.az.bot_mxid not in members: if self.az.bot_mxid not in members:
if len(members) > 2: await puppet.default_mxid_intent.leave_room(
await intent.error_and_leave( room_id, reason="This ghost does not join multi-user rooms without the bridge bot."
room_id,
text=None,
html=(
f"Please invite "
f"<a href='https://matrix.to/#/{self.az.bot_mxid}'>the bridge bot</a> "
f"first if you want to create a Telegram chat."
),
)
return
await intent.join_room(room_id)
portal = await po.Portal.get_by_tgid(
puppet.tgid, tg_receiver=inviter.tgid, peer_type="user"
) )
if portal.mxid:
try:
await portal.invite_to_matrix(inviter.mxid)
await intent.send_notice(
room_id,
text=f"You already have a private chat with me: {portal.mxid}",
html=(
"You already have a private chat with me: "
f"<a href='https://matrix.to/#/{portal.mxid}'>Link to room</a>"
),
)
await intent.leave_room(room_id)
return
except MatrixError:
pass
portal.mxid = room_id
e2be_ok = await portal.check_dm_encryption()
await portal.save()
await inviter.register_portal(portal)
if e2be_ok is True:
evt_type, content = await self.e2ee.encrypt(
room_id,
EventType.ROOM_MESSAGE,
TextMessageEventContent(
msgtype=MessageType.NOTICE,
body=(
"Portal to private chat created and end-to-bridge encryption enabled."
),
),
)
await intent.send_message_event(room_id, evt_type, content)
else:
message = "Portal to private chat created."
if e2be_ok is False:
message += "\n\nWarning: Failed to enable end-to-bridge encryption"
await intent.send_notice(room_id, message)
await portal.update_bridge_info()
else: else:
await intent.join_room(room_id) await puppet.default_mxid_intent.send_notice(
await intent.send_notice(
room_id, room_id,
"This puppet will remain inactive until a Telegram chat is created for this room.", "This ghost will remain inactive until a Telegram chat is created for this room.",
) )
async def handle_invite( async def handle_invite(
@@ -163,9 +87,13 @@ class MatrixHandler(BaseMatrixHandler):
return return
await user.ensure_started() await user.ensure_started()
portal = await po.Portal.get_by_mxid(room_id) portal = await po.Portal.get_by_mxid(room_id)
if user and await user.has_full_access(allow_bot=True): if (
if portal and portal.allow_bridging: user
await portal.invite_telegram(inviter, user) and portal
and await user.has_full_access(allow_bot=True)
and portal.allow_bridging
):
await portal.handle_matrix_invite(inviter, user)
async def handle_join(self, room_id: RoomID, user_id: UserID, event_id: EventID) -> None: async def handle_join(self, room_id: RoomID, user_id: UserID, event_id: EventID) -> None:
user = await u.User.get_and_start_by_mxid(user_id) user = await u.User.get_and_start_by_mxid(user_id)
+219 -136
View File
@@ -105,6 +105,7 @@ from telethon.tl.types import (
MessageActionChatEditPhoto, MessageActionChatEditPhoto,
MessageActionChatEditTitle, MessageActionChatEditTitle,
MessageActionChatJoinedByLink, MessageActionChatJoinedByLink,
MessageActionChatJoinedByRequest,
MessageActionChatMigrateTo, MessageActionChatMigrateTo,
MessageActionContactSignUp, MessageActionContactSignUp,
MessageActionGameScore, MessageActionGameScore,
@@ -163,7 +164,7 @@ from telethon.utils import decode_waveform
import magic import magic
from mautrix.appservice import DOUBLE_PUPPET_SOURCE_KEY, IntentAPI from mautrix.appservice import DOUBLE_PUPPET_SOURCE_KEY, IntentAPI
from mautrix.bridge import BasePortal, NotificationDisabler, async_getter_lock from mautrix.bridge import BasePortal, NotificationDisabler, RejectMatrixInvite, async_getter_lock
from mautrix.errors import IntentError, MatrixRequestError, MForbidden from mautrix.errors import IntentError, MatrixRequestError, MForbidden
from mautrix.types import ( from mautrix.types import (
ContentURI, ContentURI,
@@ -448,6 +449,14 @@ class Portal(DBPortal, BasePortal):
# endregion # endregion
# region Matrix -> Telegram metadata # region Matrix -> Telegram metadata
async def save(self) -> None:
if self.deleted:
await super().insert()
await self.postinit()
self.deleted = False
else:
await super().save()
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[InputPeerUser], list[UserID]]: ) -> tuple[list[InputPeerUser], list[UserID]]:
@@ -570,18 +579,25 @@ class Portal(DBPortal, BasePortal):
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()
async def invite_telegram(self, source: u.User, puppet: p.Puppet | au.AbstractUser) -> None: async def handle_matrix_invite(
self, invited_by: u.User, puppet: p.Puppet | au.AbstractUser
) -> None:
if puppet.is_channel: if puppet.is_channel:
raise ValueError("Can't invite channels to chats") raise ValueError("Can't invite channels to chats")
if self.peer_type == "chat": try:
await source.client( if self.peer_type == "chat":
AddChatUserRequest(chat_id=self.tgid, user_id=puppet.tgid, fwd_limit=0) await invited_by.client(
) AddChatUserRequest(chat_id=self.tgid, user_id=puppet.tgid, fwd_limit=0)
elif self.peer_type == "channel": )
await source.client(InviteToChannelRequest(channel=self.peer, users=[puppet.tgid])) elif self.peer_type == "channel":
# We don't care if there are invites for private chat portals with the relaybot. await invited_by.client(
elif not self.bot or self.tg_receiver != self.bot.tgid: InviteToChannelRequest(channel=self.peer, users=[puppet.tgid])
raise ValueError("Invalid peer type for Telegram user invite") )
# We don't care if there are invites for private chat portals with the relaybot.
elif not self.bot or self.tg_receiver != self.bot.tgid:
raise RejectMatrixInvite("You can't invite additional users to private chats.")
except RPCError as e:
raise RejectMatrixInvite(e.message) from e
# endregion # endregion
# region Telegram -> Matrix metadata # region Telegram -> Matrix metadata
@@ -613,15 +629,12 @@ class Portal(DBPortal, BasePortal):
self, self,
user: au.AbstractUser, user: au.AbstractUser,
entity: TypeChat | User, entity: TypeChat | User,
direct: bool = None,
puppet: p.Puppet = None, puppet: p.Puppet = None,
levels: PowerLevelStateEventContent = None, levels: PowerLevelStateEventContent = None,
users: list[User] = None, users: list[User] = None,
) -> None: ) -> None:
if direct is None:
direct = self.peer_type == "user"
try: try:
await self._update_matrix_room(user, entity, direct, puppet, levels, users) await self._update_matrix_room(user, entity, puppet, levels, users)
except Exception: except Exception:
self.log.exception("Fatal error updating Matrix room") self.log.exception("Fatal error updating Matrix room")
@@ -629,12 +642,11 @@ class Portal(DBPortal, BasePortal):
self, self,
user: au.AbstractUser, user: au.AbstractUser,
entity: TypeChat | User, entity: TypeChat | User,
direct: bool,
puppet: p.Puppet = None, puppet: p.Puppet = None,
levels: PowerLevelStateEventContent = None, levels: PowerLevelStateEventContent = None,
users: list[User] = None, users: list[User] = None,
) -> None: ) -> None:
if not direct: if not self.is_direct:
await self.update_info(user, entity) await self.update_info(user, entity)
if not users: if not users:
users = await self._get_users(user, entity) users = await self._get_users(user, entity)
@@ -642,7 +654,7 @@ class Portal(DBPortal, BasePortal):
await self.update_power_levels(users, levels) await self.update_power_levels(users, levels)
else: else:
if not puppet: if not puppet:
puppet = await p.Puppet.get_by_tgid(self.tgid) puppet = await self.get_dm_puppet()
await puppet.update_info(user, entity) await puppet.update_info(user, entity)
await puppet.intent_for(self).join_room(self.mxid) await puppet.intent_for(self).join_room(self.mxid)
await self.update_info_from_puppet(puppet, user, entity.photo) await self.update_info_from_puppet(puppet, user, entity.photo)
@@ -661,12 +673,14 @@ class Portal(DBPortal, BasePortal):
async def update_info_from_puppet( async def update_info_from_puppet(
self, self,
puppet: p.Puppet, puppet: p.Puppet | None = None,
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: if not self.encrypted and not self.private_chat_portal_meta:
return return
if puppet is None:
puppet = await self.get_dm_puppet()
# The bridge bot needs to join for e2ee, but that messes up the default name # 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. # 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)
@@ -690,7 +704,7 @@ class Portal(DBPortal, BasePortal):
except Exception: except Exception:
self.log.exception(f"Failed to get entity through {user.tgid} for update") self.log.exception(f"Failed to get entity through {user.tgid} for update")
return self.mxid return self.mxid
update = self.update_matrix_room(user, entity, self.peer_type == "user") update = self.update_matrix_room(user, entity)
asyncio.create_task(update) asyncio.create_task(update)
await self.invite_to_matrix(invites or []) await self.invite_to_matrix(invites or [])
return self.mxid return self.mxid
@@ -754,7 +768,6 @@ class Portal(DBPortal, BasePortal):
elif not self.allow_bridging: elif not self.allow_bridging:
return None return None
direct = self.peer_type == "user"
invites = invites or [] invites = invites or []
if not entity: if not entity:
@@ -768,14 +781,14 @@ class Portal(DBPortal, BasePortal):
except AttributeError: except AttributeError:
self.title = None self.title = None
if direct and self.tgid == user.tgid: if self.is_direct and self.tgid == user.tgid:
self.title = "Telegram Saved Messages" self.title = "Telegram Saved Messages"
self.about = "Your Telegram cloud storage chat" self.about = "Your Telegram cloud storage chat"
puppet = await p.Puppet.get_by_tgid(self.tgid) if direct else None puppet = await self.get_dm_puppet()
if puppet: if puppet:
await puppet.update_info(user, entity) await puppet.update_info(user, entity)
self._main_intent = puppet.intent_for(self) if direct else self.az.intent self._main_intent = puppet.intent_for(self) if self.is_direct else self.az.intent
if self.peer_type == "channel": if self.peer_type == "channel":
self.megagroup = entity.megagroup self.megagroup = entity.megagroup
@@ -796,7 +809,7 @@ class Portal(DBPortal, BasePortal):
power_levels = putil.get_base_power_levels(self, entity=entity) power_levels = putil.get_base_power_levels(self, entity=entity)
users = None users = None
if not direct: if not self.is_direct:
users = await self._get_users(user, entity) users = await self._get_users(user, entity)
if self.has_bot: if self.has_bot:
extra_invites = self.config["bridge.relaybot.group_chat_invite"] extra_invites = self.config["bridge.relaybot.group_chat_invite"]
@@ -836,9 +849,9 @@ class Portal(DBPortal, BasePortal):
"content": {"algorithm": "m.megolm.v1.aes-sha2"}, "content": {"algorithm": "m.megolm.v1.aes-sha2"},
} }
) )
if direct: if self.is_direct:
create_invites.append(self.az.bot_mxid) create_invites.append(self.az.bot_mxid)
if direct and (self.encrypted or self.private_chat_portal_meta): if self.is_direct and (self.encrypted or self.private_chat_portal_meta):
self.title = puppet.displayname self.title = puppet.displayname
self.avatar_url = puppet.avatar_url self.avatar_url = puppet.avatar_url
self.photo_id = puppet.photo_id self.photo_id = puppet.photo_id
@@ -857,7 +870,7 @@ class Portal(DBPortal, BasePortal):
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=direct, is_direct=self.is_direct,
invitees=create_invites, invitees=create_invites,
name=self.title, name=self.title,
topic=self.about, topic=self.about,
@@ -869,7 +882,7 @@ class Portal(DBPortal, BasePortal):
self.name_set = bool(self.title) self.name_set = bool(self.title)
self.avatar_set = bool(self.avatar_url) self.avatar_set = bool(self.avatar_url)
if self.encrypted and self.matrix.e2ee and direct: if self.encrypted and self.matrix.e2ee and self.is_direct:
try: try:
await self.az.intent.ensure_joined(room_id) await self.az.intent.ensure_joined(room_id)
except Exception: except Exception:
@@ -884,9 +897,7 @@ class Portal(DBPortal, BasePortal):
await self.invite_to_matrix(invites) await self.invite_to_matrix(invites)
update_room = asyncio.create_task( update_room = asyncio.create_task(
self.update_matrix_room( self.update_matrix_room(user, entity, puppet, levels=power_levels, users=users)
user, entity, direct, puppet, levels=power_levels, users=users
)
) )
if self.config["bridge.backfill.initial_limit"] > 0: if self.config["bridge.backfill.initial_limit"] > 0:
@@ -1118,9 +1129,10 @@ class Portal(DBPortal, BasePortal):
return False return False
self.about = about self.about = about
await self._try_set_state( if self.mxid:
sender, EventType.ROOM_TOPIC, RoomTopicStateEventContent(topic=self.about) await self._try_set_state(
) sender, EventType.ROOM_TOPIC, RoomTopicStateEventContent(topic=self.about)
)
if save: if save:
await self.save() await self.save()
return True return True
@@ -1132,14 +1144,15 @@ class Portal(DBPortal, BasePortal):
return False return False
self.title = title self.title = title
try: if self.mxid:
await self._try_set_state( try:
sender, EventType.ROOM_NAME, RoomNameStateEventContent(name=self.title) await self._try_set_state(
) sender, EventType.ROOM_NAME, RoomNameStateEventContent(name=self.title)
self.name_set = True )
except Exception as e: self.name_set = True
self.log.warning(f"Failed to set room name: {e}") except Exception as e:
self.name_set = False 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
@@ -1152,14 +1165,17 @@ class Portal(DBPortal, BasePortal):
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
try: if self.mxid:
await self._try_set_state( try:
None, EventType.ROOM_AVATAR, RoomAvatarStateEventContent(url=self.avatar_url) await self._try_set_state(
) None,
self.avatar_set = True EventType.ROOM_AVATAR,
except Exception as e: RoomAvatarStateEventContent(url=self.avatar_url),
self.log.warning(f"Failed to set room avatar: {e}") )
self.avatar_set = False self.avatar_set = True
except Exception as 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:
return await self._update_avatar(user, photo=photo) return await self._update_avatar(user, photo=photo)
@@ -1197,19 +1213,27 @@ class Portal(DBPortal, BasePortal):
self.photo_id = "" self.photo_id = ""
self.avatar_url = None self.avatar_url = None
elif self.photo_id != photo_id or not self.avatar_url: elif self.photo_id != photo_id or not self.avatar_url:
file = await util.transfer_file_to_matrix(user.client, self.main_intent, loc) file = await util.transfer_file_to_matrix(
user.client,
self.main_intent,
loc,
async_upload=self.config["homeserver.async_media"],
)
if not file: if not file:
return False return False
self.photo_id = photo_id self.photo_id = photo_id
self.avatar_url = file.mxc self.avatar_url = file.mxc
try: if self.mxid:
await self._try_set_state( try:
sender, EventType.ROOM_AVATAR, RoomAvatarStateEventContent(url=self.avatar_url) await self._try_set_state(
) sender,
self.avatar_set = True EventType.ROOM_AVATAR,
except Exception as e: RoomAvatarStateEventContent(url=self.avatar_url),
self.log.warning(f"Failed to set room avatar: {e}") )
self.avatar_set = False self.avatar_set = True
except Exception as e:
self.log.warning(f"Failed to set room avatar: {e}")
self.avatar_set = False
if save: if save:
await self.save() await self.save()
return True return True
@@ -1401,7 +1425,7 @@ class Portal(DBPortal, BasePortal):
f"{message.mxid}/{message.tgid} as read by {user.mxid}/{user.tgid}" f"{message.mxid}/{message.tgid} as read by {user.mxid}/{user.tgid}"
) )
await user.client.send_read_acknowledge( await user.client.send_read_acknowledge(
self.peer, max_id=message.tgid, clear_mentions=True self.peer, max_id=message.tgid, clear_mentions=True, clear_reactions=True
) )
if self.peer_type == "channel" and not self.megagroup: if self.peer_type == "channel" and not self.megagroup:
asyncio.create_task(self._try_handle_read_for_sponsored_msg(user, event_id, timestamp)) asyncio.create_task(self._try_handle_read_for_sponsored_msg(user, event_id, timestamp))
@@ -1448,7 +1472,7 @@ class Portal(DBPortal, BasePortal):
del self.by_mxid[self.mxid] del self.by_mxid[self.mxid]
except KeyError: except KeyError:
pass pass
elif self.config["bridge.kick_on_logout"]: elif self.config["bridge.bridge_matrix_leave"]:
await user.client.delete_dialog(self.peer) await user.client.delete_dialog(self.peer)
async def join_matrix(self, user: u.User, event_id: EventID) -> None: async def join_matrix(self, user: u.User, event_id: EventID) -> None:
@@ -1485,7 +1509,9 @@ class Portal(DBPortal, BasePortal):
return ruds[self.hash_user_id(user_id) % len(ruds)] if ruds else "" return ruds[self.hash_user_id(user_id) % len(ruds)] if ruds else ""
async def _apply_msg_format(self, sender: u.User, content: MessageEventContent) -> None: async def _apply_msg_format(self, sender: u.User, content: MessageEventContent) -> None:
if not isinstance(content, TextMessageEventContent) or content.format != Format.HTML: if isinstance(content, TextMessageEventContent):
content.ensure_has_html()
else:
content.format = Format.HTML content.format = Format.HTML
content.formatted_body = escape_html(content.body).replace("\n", "<br/>") content.formatted_body = escape_html(content.body).replace("\n", "<br/>")
@@ -1506,9 +1532,7 @@ class Portal(DBPortal, BasePortal):
content.formatted_body = Template(tpl).safe_substitute(tpl_args) content.formatted_body = Template(tpl).safe_substitute(tpl_args)
async def _apply_emote_format(self, sender: u.User, content: TextMessageEventContent) -> None: async def _apply_emote_format(self, sender: u.User, content: TextMessageEventContent) -> None:
if content.format != Format.HTML: content.ensure_has_html()
content.format = Format.HTML
content.formatted_body = escape_html(content.body).replace("\n", "<br/>")
tpl = self.get_config("emote_format") tpl = self.get_config("emote_format")
puppet = await p.Puppet.get_by_tgid(sender.tgid) puppet = await p.Puppet.get_by_tgid(sender.tgid)
@@ -1634,6 +1658,9 @@ class Portal(DBPortal, BasePortal):
attributes.append(DocumentAttributeImageSize(w, h)) attributes.append(DocumentAttributeImageSize(w, h))
force_document = force_document or w * h >= max_image_pixels force_document = force_document or w * h >= max_image_pixels
if "fi.mau.telegram.force_document" in content:
force_document = bool(content["fi.mau.telegram.force_document"])
if (mime == "image/png" or mime == "image/jpeg") and not force_document: if (mime == "image/png" or mime == "image/jpeg") and not force_document:
media = InputMediaUploadedPhoto(file_handle) media = InputMediaUploadedPhoto(file_handle)
else: else:
@@ -2154,7 +2181,7 @@ class Portal(DBPortal, BasePortal):
"Failed to fully migrate to upgraded Matrix room: no Telegram user found." "Failed to fully migrate to upgraded Matrix room: no Telegram user found."
) )
return return
await self.update_matrix_room(user, entity, direct=self.peer_type == "user") await self.update_matrix_room(user, entity)
self.log.info(f"{sender} upgraded room from {old_room} to {self.mxid}") self.log.info(f"{sender} upgraded room from {old_room} to {self.mxid}")
await self._send_delivery_receipt(event_id, room_id=old_room) await self._send_delivery_receipt(event_id, room_id=old_room)
@@ -2167,13 +2194,6 @@ class Portal(DBPortal, BasePortal):
self.by_mxid[self.mxid] = self self.by_mxid[self.mxid] = self
await self.save() await self.save()
async def enable_dm_encryption(self) -> bool:
ok = await super().enable_dm_encryption()
if ok:
puppet = await p.Puppet.get_by_tgid(self.tgid)
await self.update_info_from_puppet(puppet)
return ok
# endregion # endregion
# region Telegram -> Matrix bridging # region Telegram -> Matrix bridging
@@ -2210,7 +2230,11 @@ class Portal(DBPortal, BasePortal):
) )
return await self._send_message(intent, content, timestamp=evt.date) return await self._send_message(intent, content, timestamp=evt.date)
file = await util.transfer_file_to_matrix( file = await util.transfer_file_to_matrix(
source.client, intent, loc, encrypt=self.encrypted source.client,
intent,
loc,
encrypt=self.encrypted,
async_upload=self.config["homeserver.async_media"],
) )
if not file: if not file:
return None return None
@@ -2354,7 +2378,7 @@ class Portal(DBPortal, BasePortal):
attrs = self._parse_telegram_document_attributes(document.attributes) attrs = self._parse_telegram_document_attributes(document.attributes)
if document.size > self.config["bridge.max_document_size"] * 1000**2: if document.size > self.matrix.media_config.upload_size:
name = attrs.name or "" name = attrs.name or ""
caption = f"\n{evt.message}" if evt.message else "" caption = f"\n{evt.message}" if evt.message else ""
# TODO encrypt # TODO encrypt
@@ -2376,6 +2400,7 @@ class Portal(DBPortal, BasePortal):
filename=attrs.name, filename=attrs.name,
parallel_id=parallel_id, parallel_id=parallel_id,
encrypt=self.encrypted, encrypt=self.encrypted,
async_upload=self.config["homeserver.async_media"],
) )
if not file: if not file:
return None return None
@@ -2423,6 +2448,8 @@ class Portal(DBPortal, BasePortal):
"image/": MessageType.IMAGE, "image/": MessageType.IMAGE,
}.get(info.mimetype[:6], MessageType.FILE), }.get(info.mimetype[:6], MessageType.FILE),
) )
if event_type == EventType.STICKER:
content.msgtype = None
if attrs.is_audio: if attrs.is_audio:
content["org.matrix.msc1767.audio"] = {"duration": attrs.duration * 1000} content["org.matrix.msc1767.audio"] = {"duration": attrs.duration * 1000}
if attrs.waveform: if attrs.waveform:
@@ -2511,7 +2538,11 @@ class Portal(DBPortal, BasePortal):
beeper_link_preview["og:image:height"] = largest_size.h beeper_link_preview["og:image:height"] = largest_size.h
beeper_link_preview["og:image:width"] = largest_size.w beeper_link_preview["og:image:width"] = largest_size.w
file = await util.transfer_file_to_matrix( file = await util.transfer_file_to_matrix(
source.client, intent, loc, encrypt=self.encrypted source.client,
intent,
loc,
encrypt=self.encrypted,
async_upload=self.config["homeserver.async_media"],
) )
if file.decryption_info: if file.decryption_info:
@@ -2758,7 +2789,7 @@ class Portal(DBPortal, BasePortal):
"chats": self.peer_type == "chat", "chats": self.peer_type == "chat",
"users": self.peer_type == "user", "users": self.peer_type == "user",
"channels": (self.peer_type == "channel" and not self.megagroup), "channels": (self.peer_type == "channel" and not self.megagroup),
"max_file_size": min(self.config["bridge.max_document_size"], 2000) * 1024 * 1024, "max_file_size": min(self.matrix.media_config.upload_size, 2000 * 1024 * 1024),
} }
async def backfill( async def backfill(
@@ -2776,7 +2807,7 @@ class Portal(DBPortal, BasePortal):
source: u.User, source: u.User,
is_initial: bool = False, is_initial: bool = False,
limit: int | None = None, limit: int | None = None,
last_id: int | None = None, last_tgid: int | None = None,
) -> None: ) -> None:
limit = limit or ( limit = limit or (
self.config["bridge.backfill.initial_limit"] self.config["bridge.backfill.initial_limit"]
@@ -2787,43 +2818,40 @@ class Portal(DBPortal, BasePortal):
return return
if not self.config["bridge.backfill.normal_groups"] and self.peer_type == "chat": if not self.config["bridge.backfill.normal_groups"] and self.peer_type == "chat":
return return
last = await DBMessage.find_last( last_in_room = await DBMessage.find_last(
self.mxid, (source.tgid if self.peer_type != "channel" else self.tgid) self.mxid, (source.tgid if self.peer_type != "channel" else self.tgid)
) )
min_id = last.tgid if last else 0 min_id = last_in_room.tgid if last_in_room else 0
if last_id is None: if last_tgid is None:
messages = await source.client.get_messages(self.peer, limit=1) messages = await source.client.get_messages(self.peer, limit=1)
if not messages: if not messages:
# The chat seems empty # The chat seems empty
return return
last_id = messages[0].id last_tgid = messages[0].id
if last_id <= min_id: if last_tgid <= min_id or (last_tgid == 1 and self.peer_type == "channel"):
# Nothing to backfill # Nothing to backfill
return return
if limit < 0: if limit < 0:
limit = last_id - min_id limit = last_tgid - min_id
self.log.debug( limit_type = "unlimited"
f"Backfilling approximately {last_id - min_id} messages through {source.mxid}"
)
elif self.peer_type == "channel": elif self.peer_type == "channel":
# This is a channel or supergroup, so we'll backfill messages based on the ID. min_id = max(last_tgid - limit, min_id)
# There are some cases, such as deleted messages, where this may backfill less # This is now just an approximate message count, not the actual limit.
# messages than the limit. limit = last_tgid - min_id
min_id = max(last_id - limit, min_id) limit_type = "channel"
self.log.debug(
f"Backfilling messages after ID {min_id} (last message: {last_id}) "
f"through {source.mxid}"
)
else: else:
# Private chats and normal groups don't have their own message ID namespace, # This limit will be higher than the actual message count if there are any messages
# which means we'll have to fetch messages a different way. # in other DMs or normal groups, but that's not too bad.
# The _backfill_messages method will detect min_id=None and not use reverse=True limit = min(last_tgid - min_id, limit)
min_id = None limit_type = "dm/minigroup"
self.log.debug(f"Backfilling up to {limit} messages through {source.mxid}") self.log.debug(
f"Backfilling up to {limit} messages after ID {min_id} through {source.mxid} "
f"(last message: {last_tgid}, limit type: {limit_type})"
)
with self.backfill_lock: with self.backfill_lock:
await self._backfill(source, min_id, limit) await self._backfill(source, min_id, limit)
async def _backfill(self, source: u.User, min_id: int | None, limit: int) -> None: async def _backfill(self, source: u.User, min_id: int, limit: int) -> None:
self.backfill_leave = set() self.backfill_leave = set()
if ( if (
self.peer_type == "user" self.peer_type == "user"
@@ -2841,38 +2869,55 @@ class Portal(DBPortal, BasePortal):
if limit > self.config["bridge.backfill.takeout_limit"]: if limit > self.config["bridge.backfill.takeout_limit"]:
self.log.debug(f"Opening takeout client for {source.tgid}") self.log.debug(f"Opening takeout client for {source.tgid}")
async with client.takeout(**self._takeout_options) as takeout: async with client.takeout(**self._takeout_options) as takeout:
count = await self._backfill_messages(source, min_id, limit, takeout) count, handled = await self._backfill_messages(source, min_id, limit, takeout)
else: else:
count = await self._backfill_messages(source, min_id, limit, client) count, handled = await self._backfill_messages(source, min_id, limit, client)
for intent in self.backfill_leave: for intent in self.backfill_leave:
self.log.trace("Leaving room with %s post-backfill", intent.mxid) self.log.trace("Leaving room with %s post-backfill", intent.mxid)
await intent.leave_room(self.mxid) await intent.leave_room(self.mxid)
self.backfill_leave = None self.backfill_leave = None
self.log.info("Backfilled %d messages through %s", count, source.mxid) self.log.info(
"Backfilled %d (of %d fetched) messages through %s", handled, count, source.mxid
)
async def _backfill_messages( async def _backfill_messages(
self, source: u.User, min_id: int | None, limit: int, client: MautrixTelegramClient self, source: u.User, min_id: int, limit: int, client: MautrixTelegramClient
) -> int: ) -> tuple[int, int]:
count = 0 count = handled_count = 0
entity = await self.get_input_entity(source) entity = await self.get_input_entity(source)
if min_id is not None: if self.peer_type == "channel":
# This is a channel or supergroup, so we'll backfill messages based on the ID.
# There are some cases, such as deleted messages, where this may backfill less
# messages than the limit.
self.log.debug(f"Iterating all messages starting with {min_id} (approx: {limit})") self.log.debug(f"Iterating all messages starting with {min_id} (approx: {limit})")
messages = client.iter_messages(entity, reverse=True, min_id=min_id) messages = client.iter_messages(entity, reverse=True, min_id=min_id)
async for message in messages: async for message in messages:
await self._handle_telegram_backfill_message(source, message)
count += 1 count += 1
was_handled = await self._handle_telegram_backfill_message(source, message)
handled_count += 1 if was_handled else 0
else: else:
self.log.debug(f"Fetching up to {limit} most recent messages") # Private chats and normal groups don't have their own message ID namespace,
messages = await client.get_messages(entity, limit=limit) # which means we'll have to fetch messages a different way.
self.log.debug(
f"Fetching up to {limit} most recent messages, ignoring anything before {min_id}"
)
messages = await client.get_messages(entity, min_id=min_id, limit=limit)
for message in reversed(messages): for message in reversed(messages):
await self._handle_telegram_backfill_message(source, message)
count += 1 count += 1
return count if message.id <= min_id:
self.log.trace(
f"Skipping {message.id} in backfill response as it's lower than "
f"the last bridged message ({min_id})"
)
continue
was_handled = await self._handle_telegram_backfill_message(source, message)
handled_count += 1 if was_handled else 0
return count, handled_count
async def _handle_telegram_backfill_message( async def _handle_telegram_backfill_message(
self, source: au.AbstractUser, msg: Message | MessageService self, source: au.AbstractUser, msg: Message | MessageService
) -> None: ) -> bool:
if msg.from_id and isinstance(msg.from_id, (PeerUser, PeerChannel)): if msg.from_id and isinstance(msg.from_id, (PeerUser, PeerChannel)):
sender = await p.Puppet.get_by_peer(msg.from_id) sender = await p.Puppet.get_by_peer(msg.from_id)
elif isinstance(msg.peer_id, PeerUser): elif isinstance(msg.peer_id, PeerUser):
@@ -2885,14 +2930,17 @@ class Portal(DBPortal, BasePortal):
if isinstance(msg, MessageService): if isinstance(msg, MessageService):
if isinstance(msg.action, MessageActionContactSignUp): if isinstance(msg.action, MessageActionContactSignUp):
await self.handle_telegram_joined(source, sender, msg, backfill=True) await self.handle_telegram_joined(source, sender, msg, backfill=True)
return True
else: else:
self.log.debug( self.log.debug(
f"Unhandled service message {type(msg.action).__name__} in backfill" f"Unhandled service message {type(msg.action).__name__} in backfill"
) )
elif isinstance(msg, Message): elif isinstance(msg, Message):
await self.handle_telegram_message(source, sender, msg) await self.handle_telegram_message(source, sender, msg)
return True
else: else:
self.log.debug(f"Unhandled message type {type(msg).__name__} in backfill") self.log.debug(f"Unhandled message type {type(msg).__name__} in backfill")
return False
def _split_dm_reaction_counts(self, counts: list[ReactionCount]) -> list[MessagePeerReaction]: def _split_dm_reaction_counts(self, counts: list[ReactionCount]) -> list[MessagePeerReaction]:
if len(counts) == 1: if len(counts) == 1:
@@ -2931,9 +2979,10 @@ class Portal(DBPortal, BasePortal):
msg_id: TelegramID, msg_id: TelegramID,
data: MessageReactions, data: MessageReactions,
dbm: DBMessage | None = None, dbm: DBMessage | None = None,
timestamp: datetime | None = None,
) -> None: ) -> None:
try: try:
await self.handle_telegram_reactions(source, msg_id, data, dbm) await self.handle_telegram_reactions(source, msg_id, data, dbm, timestamp)
except Exception: except Exception:
self.log.exception(f"Error handling reactions in message {msg_id}") self.log.exception(f"Error handling reactions in message {msg_id}")
@@ -2943,6 +2992,7 @@ class Portal(DBPortal, BasePortal):
msg_id: TelegramID, msg_id: TelegramID,
data: MessageReactions, data: MessageReactions,
dbm: DBMessage | None = None, dbm: DBMessage | None = None,
timestamp: datetime | None = None,
) -> None: ) -> None:
if self.peer_type == "channel" and not self.megagroup: if self.peer_type == "channel" and not self.megagroup:
# We don't know who reacted in a channel, so we can't bridge it properly either # We don't know who reacted in a channel, so we can't bridge it properly either
@@ -2971,10 +3021,16 @@ 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(dbm, recent_reactions, total_count) await self._handle_telegram_reactions_locked(
dbm, recent_reactions, total_count, timestamp=timestamp
)
async def _handle_telegram_reactions_locked( async def _handle_telegram_reactions_locked(
self, msg: DBMessage, reaction_list: list[MessagePeerReaction], total_count: int self,
msg: DBMessage,
reaction_list: list[MessagePeerReaction],
total_count: int,
timestamp: datetime | None = None,
) -> None: ) -> None:
reactions = { reactions = {
p.Puppet.get_id_from_peer(reaction.peer_id): reaction.reaction p.Puppet.get_id_from_peer(reaction.peer_id): reaction.reaction
@@ -3002,7 +3058,7 @@ class Portal(DBPortal, BasePortal):
self.log.debug(f"Bridging reaction {new_emoji} by {sender} to {msg.tgid}") self.log.debug(f"Bridging reaction {new_emoji} 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, variation_selector.add(new_emoji) msg.mx_room, msg.mxid, variation_selector.add(new_emoji), timestamp=timestamp
) )
await DBReaction( await DBReaction(
mxid=mxid, mxid=mxid,
@@ -3028,7 +3084,7 @@ class Portal(DBPortal, BasePortal):
intent = puppet.intent_for(self) intent = puppet.intent_for(self)
await intent.redact(changed_reaction.mx_room, changed_reaction.mxid) await intent.redact(changed_reaction.mx_room, changed_reaction.mxid)
changed_reaction.mxid = await intent.react( changed_reaction.mxid = await intent.react(
msg.mx_room, msg.mxid, variation_selector.add(new_emoji) msg.mx_room, msg.mxid, variation_selector.add(new_emoji), timestamp=timestamp
) )
changed_reaction.reaction = new_emoji changed_reaction.reaction = new_emoji
await changed_reaction.save() await changed_reaction.save()
@@ -3180,17 +3236,14 @@ class Portal(DBPortal, BasePortal):
await dbm.insert() await dbm.insert()
await DBMessage.replace_temp_mxid(temporary_identifier, self.mxid, event_id) await DBMessage.replace_temp_mxid(temporary_identifier, self.mxid, event_id)
except (IntegrityError, UniqueViolationError) as e: except (IntegrityError, UniqueViolationError) as e:
self.log.exception( self.log.exception(f"{type(e).__name__} while saving message mapping")
f"{e.__class__.__name__} while saving message mapping. "
"This might mean that an update was handled after it left the "
"dedup cache queue. You can try enabling bridge.deduplication."
"pre_db_check in the config."
)
await intent.redact(self.mxid, event_id) await intent.redact(self.mxid, event_id)
return return
if isinstance(evt, Message) and evt.reactions: if isinstance(evt, Message) and evt.reactions:
asyncio.create_task( asyncio.create_task(
self.try_handle_telegram_reactions(source, dbm.tgid, evt.reactions, dbm=dbm) self.try_handle_telegram_reactions(
source, dbm.tgid, evt.reactions, dbm=dbm, timestamp=evt.date
)
) )
await self._send_delivery_receipt(event_id) await self._send_delivery_receipt(event_id)
@@ -3200,7 +3253,11 @@ class Portal(DBPortal, BasePortal):
if source.is_relaybot and self.config["bridge.ignore_unbridged_group_chat"]: if source.is_relaybot and self.config["bridge.ignore_unbridged_group_chat"]:
return False return False
create_and_exit = (MessageActionChatCreate, MessageActionChannelCreate) create_and_exit = (MessageActionChatCreate, MessageActionChannelCreate)
create_and_continue = (MessageActionChatAddUser, MessageActionChatJoinedByLink) create_and_continue = (
MessageActionChatAddUser,
MessageActionChatJoinedByLink,
MessageActionChatJoinedByRequest,
)
if isinstance(action, create_and_exit) or isinstance(action, create_and_continue): if isinstance(action, create_and_exit) or isinstance(action, create_and_continue):
await self.create_matrix_room( await self.create_matrix_room(
source, invites=[source.mxid], update_if_exists=isinstance(action, create_and_exit) source, invites=[source.mxid], update_if_exists=isinstance(action, create_and_exit)
@@ -3230,7 +3287,7 @@ class Portal(DBPortal, BasePortal):
elif isinstance(action, MessageActionChatAddUser): elif isinstance(action, MessageActionChatAddUser):
for user_id in action.users: for user_id in action.users:
await self._add_telegram_user(TelegramID(user_id), source) await self._add_telegram_user(TelegramID(user_id), source)
elif isinstance(action, MessageActionChatJoinedByLink): elif isinstance(action, (MessageActionChatJoinedByLink, MessageActionChatJoinedByRequest)):
await self._add_telegram_user(sender.id, source) await self._add_telegram_user(sender.id, source)
elif isinstance(action, MessageActionChatDeleteUser): elif isinstance(action, MessageActionChatDeleteUser):
await self._delete_telegram_user(TelegramID(action.user_id), sender) await self._delete_telegram_user(TelegramID(action.user_id), sender)
@@ -3257,6 +3314,15 @@ class Portal(DBPortal, BasePortal):
backfill: bool = False, backfill: bool = False,
) -> None: ) -> None:
assert isinstance(update.action, MessageActionContactSignUp) assert isinstance(update.action, MessageActionContactSignUp)
msg = await DBMessage.get_one_by_tgid(TelegramID(update.id), source.tgid)
if msg:
self.log.debug(
f"Ignoring new user message {update.id} (src {source.tgid}) as it was already "
f"handled into {msg.mxid}."
)
return
content = TextMessageEventContent(msgtype=MessageType.EMOTE, body="joined Telegram") content = TextMessageEventContent(msgtype=MessageType.EMOTE, body="joined Telegram")
event_id = await self._send_message( event_id = await self._send_message(
sender.intent_for(self), content, timestamp=update.date sender.intent_for(self), content, timestamp=update.date
@@ -3268,10 +3334,7 @@ class Portal(DBPortal, BasePortal):
tg_space=source.tgid, tg_space=source.tgid,
edit_index=0, edit_index=0,
).insert() ).insert()
# Automatically mark the notice as read if we're backfilling messages, mostly so that if self.config["bridge.always_read_joined_telegram_notice"]:
# empty rooms created before the notice was added wouldn't become unread when the notice
# is backfilled in.
if backfill:
double_puppet = await p.Puppet.get_by_tgid(source.tgid) double_puppet = await p.Puppet.get_by_tgid(source.tgid)
if double_puppet and double_puppet.is_real_user: if double_puppet and double_puppet.is_real_user:
await double_puppet.intent.mark_read(self.mxid, event_id) await double_puppet.intent.mark_read(self.mxid, event_id)
@@ -3389,7 +3452,12 @@ class Portal(DBPortal, BasePortal):
raise raise
async def get_invite_link( async def get_invite_link(
self, user: u.User, uses: int | None = None, expire: datetime | None = None self,
user: u.User,
uses: int | None = None,
expire: datetime | None = None,
request_needed: bool = False,
title: str | None = None,
) -> str: ) -> str:
if self.peer_type == "user": if self.peer_type == "user":
raise ValueError("You can't invite users to private chats.") raise ValueError("You can't invite users to private chats.")
@@ -3397,7 +3465,11 @@ class Portal(DBPortal, BasePortal):
return f"https://t.me/{self.username}" return f"https://t.me/{self.username}"
link = await user.client( link = await user.client(
ExportChatInviteRequest( ExportChatInviteRequest(
peer=await self.get_input_entity(user), expire_date=expire, usage_limit=uses peer=await self.get_input_entity(user),
expire_date=expire,
usage_limit=uses,
request_needed=request_needed,
title=title,
) )
) )
return link.link return link.link
@@ -3442,6 +3514,12 @@ class Portal(DBPortal, BasePortal):
del self.by_mxid[self.mxid] del self.by_mxid[self.mxid]
except KeyError: except KeyError:
pass pass
self.name_set = False
self.avatar_set = False
self.about = None
self.sponsored_event_id = None
self.sponsored_event_ts = None
self.sponsored_msg_random_id = None
await super().delete() await super().delete()
await DBMessage.delete_all(self.mxid) await DBMessage.delete_all(self.mxid)
await DBReaction.delete_all(self.mxid) await DBReaction.delete_all(self.mxid)
@@ -3450,8 +3528,13 @@ class Portal(DBPortal, BasePortal):
# endregion # endregion
# region Class instance lookup # region Class instance lookup
async def get_dm_puppet(self) -> p.Puppet | None:
if not self.is_direct:
return None
return await p.Puppet.get_by_tgid(self.tgid)
async def postinit(self) -> None: async def postinit(self) -> None:
puppet = await p.Puppet.get_by_tgid(self.tgid) if self.is_direct else None puppet = await self.get_dm_puppet()
self._main_intent = puppet.intent_for(self) if self.is_direct else self.az.intent self._main_intent = puppet.intent_for(self) if self.is_direct else self.az.intent
if self.tgid: if self.tgid:
@@ -62,7 +62,6 @@ media_content_table = {
class PortalDedup: class PortalDedup:
pre_db_check: bool = False
cache_queue_length: int = 256 cache_queue_length: int = 256
_dedup: deque[bytes | int] _dedup: deque[bytes | int]
+19 -1
View File
@@ -24,6 +24,7 @@ from telethon.tl.types import (
ChatPhoto, ChatPhoto,
ChatPhotoEmpty, ChatPhotoEmpty,
InputPeerPhotoFileLocation, InputPeerPhotoFileLocation,
InputPeerUser,
PeerChannel, PeerChannel,
PeerChat, PeerChat,
PeerUser, PeerUser,
@@ -72,6 +73,7 @@ class Puppet(DBPuppet, BasePuppet):
displayname_quality: int = 0, displayname_quality: int = 0,
disable_updates: bool = False, disable_updates: bool = False,
username: str | None = None, username: str | None = None,
phone: str | None = None,
photo_id: str | None = None, photo_id: str | None = None,
avatar_url: ContentURI | None = None, avatar_url: ContentURI | None = None,
name_set: bool = False, name_set: bool = False,
@@ -92,6 +94,7 @@ class Puppet(DBPuppet, BasePuppet):
displayname_quality=displayname_quality, displayname_quality=displayname_quality,
disable_updates=disable_updates, disable_updates=disable_updates,
username=username, username=username,
phone=phone,
photo_id=photo_id, photo_id=photo_id,
avatar_url=avatar_url, avatar_url=avatar_url,
name_set=name_set, name_set=name_set,
@@ -128,6 +131,16 @@ class Puppet(DBPuppet, BasePuppet):
PeerChannel(channel_id=self.tgid) if self.is_channel else PeerUser(user_id=self.tgid) PeerChannel(channel_id=self.tgid) if self.is_channel else PeerUser(user_id=self.tgid)
) )
@property
def contact_info(self) -> dict:
return {
"name": self.displayname,
"username": self.username,
"phone": f"+{self.phone.lstrip('+')}" if self.phone else None,
"is_bot": self.is_bot,
"avatar_url": self.avatar_url,
}
@property @property
def plain_displayname(self) -> str: def plain_displayname(self) -> str:
return self.displayname_template.parse(self.displayname) or self.displayname return self.displayname_template.parse(self.displayname) or self.displayname
@@ -252,6 +265,10 @@ class Puppet(DBPuppet, BasePuppet):
self.username = info.username self.username = info.username
changed = True changed = True
if getattr(info, "phone", None) and self.phone != info.phone:
self.phone = info.phone
changed = True
if not self.disable_updates: if not self.disable_updates:
try: try:
changed = await self.update_displayname(source, info) or changed changed = await self.update_displayname(source, info) or changed
@@ -359,6 +376,7 @@ class Puppet(DBPuppet, BasePuppet):
location=InputPeerPhotoFileLocation( location=InputPeerPhotoFileLocation(
peer=await self.get_input_entity(source), photo_id=photo.photo_id, big=True peer=await self.get_input_entity(source), photo_id=photo.photo_id, big=True
), ),
async_upload=self.config["homeserver.async_media"],
) )
if not file: if not file:
return False return False
@@ -413,7 +431,7 @@ class Puppet(DBPuppet, BasePuppet):
@staticmethod @staticmethod
def get_id_from_peer(peer: TypePeer | User | Channel) -> TelegramID: def get_id_from_peer(peer: TypePeer | User | Channel) -> TelegramID:
if isinstance(peer, PeerUser): if isinstance(peer, (PeerUser, InputPeerUser)):
return TelegramID(peer.user_id) return TelegramID(peer.user_id)
elif isinstance(peer, PeerChannel): elif isinstance(peer, PeerChannel):
return TelegramID(peer.channel_id) return TelegramID(peer.channel_id)
+28 -7
View File
@@ -269,6 +269,13 @@ class User(DBUser, AbstractUser, BaseUser):
return None return None
return await pu.Puppet.get_by_tgid(self.tgid) return await pu.Puppet.get_by_tgid(self.tgid)
async def get_portal_with(self, puppet: pu.Puppet, create: bool = True) -> po.Portal | None:
if not self.tgid:
return None
return await po.Portal.get_by_tgid(
puppet.tgid, tg_receiver=self.tgid, peer_type="user" if create else None
)
async def stop(self) -> None: async def stop(self) -> None:
if self._track_connection_task: if self._track_connection_task:
self._track_connection_task.cancel() self._track_connection_task.cancel()
@@ -372,7 +379,7 @@ class User(DBUser, AbstractUser, BaseUser):
if not self.config["bridge.kick_on_logout"]: if not self.config["bridge.kick_on_logout"]:
return return
portals = await self.get_cached_portals() portals = await self.get_cached_portals()
for _, portal in portals.values(): for portal in portals.values():
if not portal or portal.deleted or not portal.mxid or portal.has_bot: if not portal or portal.deleted or not portal.mxid or portal.has_bot:
continue continue
if portal.peer_type == "user": if portal.peer_type == "user":
@@ -462,17 +469,22 @@ class User(DBUser, AbstractUser, BaseUser):
if active and tag_info is None: if active and tag_info is None:
tag_info = RoomTagInfo(order=0.5) tag_info = RoomTagInfo(order=0.5)
tag_info[DOUBLE_PUPPET_SOURCE_KEY] = self.bridge.name tag_info[DOUBLE_PUPPET_SOURCE_KEY] = self.bridge.name
self.log.debug("Adding tag {tag} to {portal.mxid}/{portal.tgid}")
await puppet.intent.set_room_tag(portal.mxid, tag, tag_info) await puppet.intent.set_room_tag(portal.mxid, tag, tag_info)
elif ( elif (
not active and tag_info and tag_info.get(DOUBLE_PUPPET_SOURCE_KEY) == self.bridge.name not active and tag_info and tag_info.get(DOUBLE_PUPPET_SOURCE_KEY) == self.bridge.name
): ):
self.log.debug("Removing tag {tag} from {portal.mxid}/{portal.tgid}")
await puppet.intent.remove_room_tag(portal.mxid, tag) await puppet.intent.remove_room_tag(portal.mxid, tag)
async def _mute_room(cls, puppet: pu.Puppet, portal: po.Portal, mute_until: datetime) -> None: async def _mute_room(self, puppet: pu.Puppet, portal: po.Portal, mute_until: datetime) -> None:
if not cls.config["bridge.mute_bridging"] or not portal or not portal.mxid: if not self.config["bridge.mute_bridging"] or not portal or not portal.mxid:
return return
now = datetime.utcnow().replace(tzinfo=timezone.utc) now = datetime.utcnow().replace(tzinfo=timezone.utc)
if mute_until is not None and mute_until > now: if mute_until is not None and mute_until > now:
self.log.debug(
f"Muting {portal.mxid}/{portal.tgid} (muted until {mute_until} on Telegram)"
)
await puppet.intent.set_push_rule( await puppet.intent.set_push_rule(
PushRuleScope.GLOBAL, PushRuleScope.GLOBAL,
PushRuleKind.ROOM, PushRuleKind.ROOM,
@@ -484,6 +496,7 @@ class User(DBUser, AbstractUser, BaseUser):
await puppet.intent.remove_push_rule( await puppet.intent.remove_push_rule(
PushRuleScope.GLOBAL, PushRuleKind.ROOM, portal.mxid PushRuleScope.GLOBAL, PushRuleKind.ROOM, portal.mxid
) )
self.log.debug(f"Unmuted {portal.mxid}/{portal.tgid}")
except MNotFound: except MNotFound:
pass pass
@@ -646,20 +659,28 @@ class User(DBUser, AbstractUser, BaseUser):
acc = (acc * 20261 + contact) & 0xFFFFFFFF acc = (acc * 20261 + contact) & 0xFFFFFFFF
return acc & 0x7FFFFFFF return acc & 0x7FFFFFFF
async def sync_contacts(self) -> None: async def sync_contacts(self, get_info: bool = False) -> dict[TelegramID, dict]:
existing_contacts = await self.get_contacts() existing_contacts = await self.get_contacts()
contact_hash = self._hash_contacts(self.saved_contacts, existing_contacts) contact_hash = self._hash_contacts(self.saved_contacts, existing_contacts)
response = await self.client(GetContactsRequest(hash=contact_hash)) response = await self.client(GetContactsRequest(hash=contact_hash))
if isinstance(response, ContactsNotModified): if isinstance(response, ContactsNotModified):
return if get_info:
return {
tgid: (await pu.Puppet.get_by_tgid(tgid)).contact_info
for tgid in existing_contacts
}
return {}
self.log.debug(f"Updating contacts of {self.name}...") self.log.debug(f"Updating contacts of {self.name}...")
if self.saved_contacts != response.saved_count: if self.saved_contacts != response.saved_count:
self.saved_contacts = response.saved_count self.saved_contacts = response.saved_count
await self.save() await self.save()
contacts = {}
for user in response.users: for user in response.users:
puppet = await pu.Puppet.get_by_tgid(user.id) puppet: pu.Puppet = await pu.Puppet.get_by_tgid(user.id)
await puppet.update_info(self, user) await puppet.update_info(self, user)
await self.set_contacts(user.id for user in response.users) contacts[user.id] = puppet.contact_info
await self.set_contacts(contacts.keys())
return contacts
# endregion # endregion
# region Class instance lookup # region Class instance lookup
+14 -3
View File
@@ -144,6 +144,7 @@ async def transfer_thumbnail_to_matrix(
custom_data: bytes | None = None, custom_data: bytes | None = None,
width: int | None = None, width: int | None = None,
height: int | None = None, height: int | None = None,
async_upload: bool = False,
) -> DBTelegramFile | None: ) -> DBTelegramFile | None:
if not Image or not VideoFileClip: if not Image or not VideoFileClip:
return None return None
@@ -178,7 +179,7 @@ async def transfer_thumbnail_to_matrix(
if encrypt: if encrypt:
file, decryption_info = encrypt_attachment(file) file, decryption_info = encrypt_attachment(file)
upload_mime_type = "application/octet-stream" upload_mime_type = "application/octet-stream"
content_uri = await intent.upload_media(file, upload_mime_type) content_uri = await intent.upload_media(file, upload_mime_type, async_upload=async_upload)
if decryption_info: if decryption_info:
decryption_info.url = content_uri decryption_info.url = content_uri
@@ -220,6 +221,7 @@ async def transfer_file_to_matrix(
filename: str | None = None, filename: str | None = None,
encrypt: bool = False, encrypt: bool = False,
parallel_id: int | None = None, parallel_id: int | None = None,
async_upload: bool = False,
) -> DBTelegramFile | None: ) -> DBTelegramFile | None:
location_id = _location_to_id(location) location_id = _location_to_id(location)
if not location_id: if not location_id:
@@ -246,6 +248,7 @@ async def transfer_file_to_matrix(
filename, filename,
encrypt, encrypt,
parallel_id, parallel_id,
async_upload=async_upload,
) )
@@ -260,6 +263,7 @@ async def _unlocked_transfer_file_to_matrix(
filename: str | None, filename: str | None,
encrypt: bool, encrypt: bool,
parallel_id: int | None, parallel_id: int | None,
async_upload: bool = False,
) -> DBTelegramFile | None: ) -> DBTelegramFile | None:
db_file = await DBTelegramFile.get(loc_id) db_file = await DBTelegramFile.get(loc_id)
if db_file: if db_file:
@@ -305,7 +309,7 @@ async def _unlocked_transfer_file_to_matrix(
if encrypt and encrypt_attachment: if encrypt and encrypt_attachment:
file, decryption_info = encrypt_attachment(file) file, decryption_info = encrypt_attachment(file)
upload_mime_type = "application/octet-stream" upload_mime_type = "application/octet-stream"
content_uri = await intent.upload_media(file, upload_mime_type) content_uri = await intent.upload_media(file, upload_mime_type, async_upload=async_upload)
if decryption_info: if decryption_info:
decryption_info.url = content_uri decryption_info.url = content_uri
@@ -325,7 +329,13 @@ async def _unlocked_transfer_file_to_matrix(
thumbnail = thumbnail.location thumbnail = thumbnail.location
try: try:
db_file.thumbnail = await transfer_thumbnail_to_matrix( db_file.thumbnail = await transfer_thumbnail_to_matrix(
client, intent, thumbnail, video=file, mime_type=mime_type, encrypt=encrypt client,
intent,
thumbnail,
video=file,
mime_type=mime_type,
encrypt=encrypt,
async_upload=async_upload,
) )
except FileIdInvalidError: except FileIdInvalidError:
log.warning(f"Failed to transfer thumbnail for {thumbnail!s}", exc_info=True) log.warning(f"Failed to transfer thumbnail for {thumbnail!s}", exc_info=True)
@@ -340,6 +350,7 @@ async def _unlocked_transfer_file_to_matrix(
mime_type=converted_anim.thumbnail_mime, mime_type=converted_anim.thumbnail_mime,
width=converted_anim.width, width=converted_anim.width,
height=converted_anim.height, height=converted_anim.height,
async_upload=async_upload,
) )
try: try:
+64 -6
View File
@@ -21,7 +21,7 @@ import json
import logging import logging
from aiohttp import web from aiohttp import web
from telethon.tl.types import ChannelForbidden, ChatForbidden, TypeChat from telethon.tl.types import ChannelForbidden, ChatForbidden, TypeChat, User as TLUser
from telethon.utils import get_peer_id, resolve_id from telethon.utils import get_peer_id, resolve_id
from mautrix.appservice import AppService from mautrix.appservice import AppService
@@ -53,18 +53,20 @@ class ProvisioningAPI(AuthAPI):
self.app = web.Application(loop=bridge.loop, middlewares=[self.error_middleware]) self.app = web.Application(loop=bridge.loop, middlewares=[self.error_middleware])
portal_prefix = "/portal/{mxid:![^/]+}" portal_prefix = "/v1/portal/{mxid:![^/]+}"
self.app.router.add_route("GET", f"{portal_prefix}", self.get_portal_by_mxid) self.app.router.add_route("GET", f"{portal_prefix}", self.get_portal_by_mxid)
self.app.router.add_route("GET", "/portal/{tgid:-[0-9]+}", self.get_portal_by_tgid) self.app.router.add_route("GET", "/v1/portal/{tgid:-[0-9]+}", self.get_portal_by_tgid)
self.app.router.add_route( self.app.router.add_route(
"POST", portal_prefix + "/connect/{chat_id:-[0-9]+}", self.connect_chat "POST", portal_prefix + "/connect/{chat_id:-[0-9]+}", self.connect_chat
) )
self.app.router.add_route("POST", f"{portal_prefix}/create", self.create_chat) self.app.router.add_route("POST", f"{portal_prefix}/create", self.create_chat)
self.app.router.add_route("POST", f"{portal_prefix}/disconnect", self.disconnect_chat) self.app.router.add_route("POST", f"{portal_prefix}/disconnect", self.disconnect_chat)
user_prefix = "/user/{mxid:@[^:]*:[^/]+}" user_prefix = "/v1/user/{mxid:@[^:]*:[^/]+}"
self.app.router.add_route("GET", f"{user_prefix}", self.get_user_info) self.app.router.add_route("GET", f"{user_prefix}", self.get_user_info)
self.app.router.add_route("GET", f"{user_prefix}/chats", self.get_chats) self.app.router.add_route("GET", f"{user_prefix}/chats", self.get_chats)
self.app.router.add_route("GET", f"{user_prefix}/contacts", self.get_contacts)
self.app.router.add_route("POST", f"{user_prefix}/pm/{{identifier}}", self.start_dm)
self.app.router.add_route("POST", f"{user_prefix}/logout", self.logout) self.app.router.add_route("POST", f"{user_prefix}/logout", self.logout)
self.app.router.add_route("POST", f"{user_prefix}/login/bot_token", self.send_bot_token) self.app.router.add_route("POST", f"{user_prefix}/login/bot_token", self.send_bot_token)
@@ -212,7 +214,7 @@ class ProvisioningAPI(AuthAPI):
portal.photo_id = "" portal.photo_id = ""
await portal.save() await portal.save()
asyncio.create_task(portal.update_matrix_room(user, entity, direct=False, levels=levels)) asyncio.create_task(portal.update_matrix_room(user, entity, levels=levels))
return web.Response(status=202, body="{}") return web.Response(status=202, body="{}")
@@ -393,6 +395,62 @@ class ProvisioningAPI(AuthAPI):
] ]
) )
async def get_contacts(self, request: web.Request) -> web.Response:
data, user, err = await self.get_user_request_info(request, expect_logged_in=True)
if err is not None:
return err
return web.json_response(data=await user.sync_contacts())
async def start_dm(self, request: web.Request) -> web.Response:
data, user, err = await self.get_user_request_info(request, expect_logged_in=True)
if err is not None:
return err
try:
identifier: str | int = request.match_info["identifier"]
if isinstance(identifier, str) and identifier.isdecimal():
identifier = int(identifier)
target = await user.client.get_entity(identifier)
except ValueError:
return web.json_response(
{
"error": "Invalid user identifier or user not found.",
"errcode": "M_NOT_FOUND",
},
status=404,
)
if not target:
return web.json_response(
{
"error": "User not found.",
"errcode": "M_NOT_FOUND",
},
status=404,
)
elif not isinstance(target, TLUser):
return web.json_response(
{
"error": "Identifier is not a user.",
},
status=400,
)
portal = await Portal.get_by_entity(target, tg_receiver=user.tgid)
puppet = await portal.get_dm_puppet()
if portal.mxid:
just_created = False
else:
await portal.create_matrix_room(user, target, [user.mxid])
just_created = True
return web.json_response(
{
"room_id": portal.mxid,
"just_created": just_created,
"id": portal.tgid,
"contact_info": puppet.contact_info,
},
status=201 if just_created else 200,
)
async def send_bot_token(self, request: web.Request) -> web.Response: async def send_bot_token(self, request: web.Request) -> web.Response:
data, user, err = await self.get_user_request_info(request) data, user, err = await self.get_user_request_info(request)
if err is not None: if err is not None:
@@ -574,7 +632,7 @@ class ProvisioningAPI(AuthAPI):
data = None data = None
if want_data and (request.method == "POST" or request.method == "PUT"): if want_data and (request.method == "POST" or request.method == "PUT"):
data = await self.get_data(request) data = await self.get_data(request)
if not data: if data is None:
return ( return (
None, None,
None, None,
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -18,7 +18,7 @@ moviepy>=1,<2
phonenumbers>=8,<9 phonenumbers>=8,<9
#/metrics #/metrics
prometheus_client>=0.6,<0.14 prometheus_client>=0.6,<0.15
#/e2be #/e2be
python-olm>=3,<4 python-olm>=3,<4
+1 -1
View File
@@ -4,9 +4,9 @@ force_to_top = "typing"
from_first = true from_first = true
combine_as_imports = true combine_as_imports = true
known_first_party = "mautrix" known_first_party = "mautrix"
known_third_party = "telethon"
line_length = 99 line_length = 99
[tool.black] [tool.black]
line-length = 99 line-length = 99
target-version = ["py38"] target-version = ["py38"]
required-version = "22.1.0"
+2 -2
View File
@@ -3,10 +3,10 @@ python-magic>=0.4,<0.5
commonmark>=0.8,<0.10 commonmark>=0.8,<0.10
aiohttp>=3,<4 aiohttp>=3,<4
yarl>=1,<2 yarl>=1,<2
mautrix>=0.14.9,<0.15 mautrix>=0.16.0,<0.17
#telethon>=1.24,<1.25 #telethon>=1.24,<1.25
# Fork to make session storage async and update to layer 138 # Fork to make session storage async and update to layer 138
tulir-telethon==1.25.0a5 tulir-telethon==1.25.0a7
asyncpg>=0.20,<0.26 asyncpg>=0.20,<0.26
mako>=1,<2 mako>=1,<2
setuptools setuptools
+1 -1
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.7", python_requires="~=3.8",
classifiers=[ classifiers=[
"Development Status :: 4 - Beta", "Development Status :: 4 - Beta",