Compare commits

...

11 Commits

Author SHA1 Message Date
91448c3005 Update roadmap 2022-04-14 04:35:12 -04:00
b85301d59f Don't log contents of RPC calls
It was asking for trouble
2022-04-14 04:27:23 -04:00
0a0812515c Fix replies to relayed users' messages 2022-04-14 04:27:23 -04:00
770b0e447b Try outbound room title & description, and work on outbound room avatar
But they fail with -203 (invalid body)
2022-04-14 04:27:23 -04:00
abf3114203 Better error handling for permission setting and privileged actions 2022-04-14 04:27:23 -04:00
746756cc3f Improved power level bridging 2022-04-14 04:27:23 -04:00
a12efc92c4 Inbound channel photo & description 2022-04-14 04:27:23 -04:00
d843fcf5d2 Reset RPC min broadcast ID when node module resets
Otherwise, bridge module will ignore events until new broadcast ID
catches up to the old one
2022-04-13 05:19:33 -04:00
b29453a871 Awaitable / Promise cleanups 2022-04-13 05:19:33 -04:00
7a78d6ba25 Bypass chat cap when backfilling 2022-04-13 05:19:33 -04:00
ecb04fc2f5 Inbound permissions & outbound power levels
Note that these only apply to OpenChannels
2022-04-13 05:19:32 -04:00
12 changed files with 688 additions and 113 deletions

View File

@ -10,25 +10,20 @@
* [x] Videos * [x] Videos
* [x] Images * [x] Images
* [ ] Locations * [ ] Locations
* [ ] Replies * [x] Replies<sup>[1]</sup>
* [x] In DMs * [x] Mentions<sup>[1]</sup>
* [ ] In multi-user rooms
* [ ] To relay users
* [ ] Mentions
* [x] In DMs
* [ ] In multi-user rooms
* [x] To relay users
* [ ] Polls * [ ] Polls
* [x] Message redactions<sup>[1]</sup> * [x] Message redactions<sup>[2]</sup>
* [ ] Message reactions * [ ] Message reactions
* [x] Read receipts * [x] Read receipts
* [ ] Power level * [x] Power level
* [ ] Membership actions * [ ] Membership actions
* [ ] Invite * [ ] Invite
* [ ] Kick * [ ] Kick
* [ ] Leave * [ ] Leave
* [ ] Room metadata changes * [ ] Room metadata changes
* [ ] Name * [ ] Name
* [ ] Topic
* [ ] Avatar * [ ] Avatar
* [ ] Per-room user nick * [ ] Per-room user nick
* KakaoTalk → Matrix * KakaoTalk → Matrix
@ -52,17 +47,15 @@
* [ ] Read receipts * [ ] Read receipts
* [ ] On backfill * [ ] On backfill
* [x] On live event * [x] On live event
* [ ] Admin status * [x] Admin status
* [x] Membership actions * [x] Membership actions
* [x] Add member * [x] Add member
* [x] Remove member * [x] Remove member
* [x] Leave * [x] Leave
* [ ] Chat metadata changes * [x] Channel metadata
* [x] Title * [x] Name
* [ ] Avatar * [x] Description
* [ ] Initial chat metadata * [x] Cover photo<sup>[3]</sup>
* [x] Title
* [ ] Avatar
* [x] User metadata * [x] User metadata
* [x] Name * [x] Name
* [x] Avatar * [x] Avatar
@ -79,4 +72,6 @@
* [ ] For new KakaoTalk channels * [ ] For new KakaoTalk channels
* [x] Option to use own Matrix account for messages sent from other KakaoTalk clients * [x] Option to use own Matrix account for messages sent from other KakaoTalk clients
<sup>[1]</sup> Only recently-sent messages can be deleted <sup>[1]</sup> Sometimes fails with "Invalid body" error
<sup>[2]</sup> Only recently-sent messages can be deleted
<sup>[3]</sup> Might only get synced on backfill, or on changing channel name/description

View File

@ -38,10 +38,12 @@ class Portal:
kt_type: ChannelType kt_type: ChannelType
mxid: RoomID | None mxid: RoomID | None
name: str | None name: str | None
description: str | None
photo_id: str | None photo_id: str | None
avatar_url: ContentURI | None avatar_url: ContentURI | None
encrypted: bool encrypted: bool
name_set: bool name_set: bool
topic_set: bool
avatar_set: bool avatar_set: bool
relay_user_id: UserID | None relay_user_id: UserID | None
@ -56,7 +58,7 @@ class Portal:
@classmethod @classmethod
async def get_by_ktid(cls, ktid: int, kt_receiver: int) -> Portal | None: async def get_by_ktid(cls, ktid: int, kt_receiver: int) -> Portal | None:
q = """ q = """
SELECT ktid, kt_receiver, kt_type, mxid, name, photo_id, avatar_url, encrypted, SELECT ktid, kt_receiver, kt_type, mxid, name, description, photo_id, avatar_url, encrypted,
name_set, avatar_set, relay_user_id name_set, avatar_set, relay_user_id
FROM portal WHERE ktid=$1 AND kt_receiver=$2 FROM portal WHERE ktid=$1 AND kt_receiver=$2
""" """
@ -66,7 +68,7 @@ class Portal:
@classmethod @classmethod
async def get_by_mxid(cls, mxid: RoomID) -> Portal | None: async def get_by_mxid(cls, mxid: RoomID) -> Portal | None:
q = """ q = """
SELECT ktid, kt_receiver, kt_type, mxid, name, photo_id, avatar_url, encrypted, SELECT ktid, kt_receiver, kt_type, mxid, name, description, photo_id, avatar_url, encrypted,
name_set, avatar_set, relay_user_id name_set, avatar_set, relay_user_id
FROM portal WHERE mxid=$1 FROM portal WHERE mxid=$1
""" """
@ -76,7 +78,7 @@ class Portal:
@classmethod @classmethod
async def get_all_by_receiver(cls, kt_receiver: int) -> list[Portal]: async def get_all_by_receiver(cls, kt_receiver: int) -> list[Portal]:
q = """ q = """
SELECT ktid, kt_receiver, kt_type, mxid, name, photo_id, avatar_url, encrypted, SELECT ktid, kt_receiver, kt_type, mxid, name, description, photo_id, avatar_url, encrypted,
name_set, avatar_set, relay_user_id name_set, avatar_set, relay_user_id
FROM portal WHERE kt_receiver=$1 FROM portal WHERE kt_receiver=$1
""" """
@ -86,7 +88,7 @@ class Portal:
@classmethod @classmethod
async def all(cls) -> list[Portal]: async def all(cls) -> list[Portal]:
q = """ q = """
SELECT ktid, kt_receiver, kt_type, mxid, name, photo_id, avatar_url, encrypted, SELECT ktid, kt_receiver, kt_type, mxid, name, description, photo_id, avatar_url, encrypted,
name_set, avatar_set, relay_user_id name_set, avatar_set, relay_user_id
FROM portal FROM portal
""" """
@ -101,6 +103,7 @@ class Portal:
self.kt_type, self.kt_type,
self.mxid, self.mxid,
self.name, self.name,
self.description,
self.photo_id, self.photo_id,
self.avatar_url, self.avatar_url,
self.encrypted, self.encrypted,
@ -111,9 +114,9 @@ class Portal:
async def insert(self) -> None: async def insert(self) -> None:
q = """ q = """
INSERT INTO portal (ktid, kt_receiver, kt_type, mxid, name, photo_id, avatar_url, INSERT INTO portal (ktid, kt_receiver, kt_type, mxid, name, description, photo_id, avatar_url,
encrypted, name_set, avatar_set, relay_user_id) encrypted, name_set, avatar_set, relay_user_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
""" """
await self.db.execute(q, *self._values) await self.db.execute(q, *self._values)
@ -123,8 +126,8 @@ class Portal:
async def save(self) -> None: async def save(self) -> None:
q = """ q = """
UPDATE portal SET kt_type=$3, mxid=$4, name=$5, photo_id=$6, avatar_url=$7, UPDATE portal SET kt_type=$3, mxid=$4, name=$5, description=$6, photo_id=$7, avatar_url=$8,
encrypted=$8, name_set=$9, avatar_set=$10, relay_user_id=$11 encrypted=$9, name_set=$10, avatar_set=$11, relay_user_id=$12
WHERE ktid=$1 AND kt_receiver=$2 WHERE ktid=$1 AND kt_receiver=$2
""" """
await self.db.execute(q, *self._values) await self.db.execute(q, *self._values)

View File

@ -3,3 +3,4 @@ from mautrix.util.async_db import UpgradeTable
upgrade_table = UpgradeTable() upgrade_table = UpgradeTable()
from . import v01_initial_revision from . import v01_initial_revision
from . import v02_channel_meta

View File

@ -0,0 +1,24 @@
# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge.
# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti
#
# 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="Support channel descriptions")
async def upgrade_v2(conn: Connection) -> None:
await conn.execute("ALTER TABLE portal ADD COLUMN description TEXT")
await conn.execute("ALTER TABLE portal ADD COLUMN topic_set BOOLEAN NOT NULL DEFAULT false")

View File

@ -35,7 +35,7 @@ from ..kt.types.chat.attachment import ReplyAttachment, MentionStruct
from ..kt.client.types import TO_MSGTYPE_MAP from ..kt.client.types import TO_MSGTYPE_MAP
from .. import puppet as pu, user as u from .. import portal as po, puppet as pu, user as u
from ..db import Message as DBMessage from ..db import Message as DBMessage
@ -90,20 +90,23 @@ class ToKakaoTalkParser(MatrixParser[KakaoTalkFormatString]):
fs = KakaoTalkFormatString fs = KakaoTalkFormatString
async def _get_id_from_mxid(mxid: UserID) -> Long | None: async def _get_id_from_mxid(mxid: UserID, portal: po.Portal) -> Long | None:
user = await u.User.get_by_mxid(mxid, create=False) orig_sender = await u.User.get_by_mxid(mxid, create=False)
if user and user.ktid: if orig_sender and orig_sender.ktid:
return user.ktid return orig_sender.ktid
else: elif orig_sender:
puppet = await pu.Puppet.get_by_mxid(mxid, create=False) sender, _ = await portal.get_relay_sender(orig_sender, "relation")
return puppet.ktid if puppet else None if sender and sender.ktid:
return sender.ktid
puppet = await pu.Puppet.get_by_mxid(mxid, create=False)
return puppet.ktid if puppet else None
async def matrix_to_kakaotalk( async def matrix_to_kakaotalk(
content: MessageEventContent, content: MessageEventContent,
room_id: RoomID, room_id: RoomID,
log: TraceLogger, log: TraceLogger,
intent: IntentAPI, portal: po.Portal,
skip_reply: bool = False skip_reply: bool = False
) -> SendParams: ) -> SendParams:
# NOTE By design, this *throws* if user intent can't be matched (i.e. if a reply can't be created) # NOTE By design, this *throws* if user intent can't be matched (i.e. if a reply can't be created)
@ -115,17 +118,17 @@ async def matrix_to_kakaotalk(
" to bridge text message reply metadata to KakaoTalk" " to bridge text message reply metadata to KakaoTalk"
) )
try: try:
src_event = await intent.get_event(room_id, message.mxid) src_event = await portal.main_intent.get_event(room_id, message.mxid)
except: except:
log.exception(f"Failed to find Matrix event for reply target {message.mxid}") log.exception(f"Failed to find Matrix event for reply target {message.mxid}")
raise raise
src_kt_sender = await _get_id_from_mxid(src_event.sender) src_kt_sender = await _get_id_from_mxid(src_event.sender, portal)
if src_kt_sender is None: if src_kt_sender is None:
raise ValueError( raise ValueError(
f"Found no KakaoTalk user ID for reply target sender {src_event.sender}" f"Found no KakaoTalk user ID for reply target sender {src_event.sender}"
) )
content.trim_reply_fallback() content.trim_reply_fallback()
src_converted = await matrix_to_kakaotalk(src_event.content, room_id, log, intent, skip_reply=True) src_converted = await matrix_to_kakaotalk(src_event.content, room_id, log, portal, skip_reply=True)
if src_event.content.relates_to.rel_type == RelationType.REPLY: if src_event.content.relates_to.rel_type == RelationType.REPLY:
src_type = KnownChatType.REPLY src_type = KnownChatType.REPLY
src_message = src_converted.text src_message = src_converted.text
@ -158,7 +161,7 @@ async def matrix_to_kakaotalk(
mentions_by_user: dict[Long, MentionStruct] = {} mentions_by_user: dict[Long, MentionStruct] = {}
# Make sure to not create remote mentions for any remote user not in the room # Make sure to not create remote mentions for any remote user not in the room
if parsed.entities: if parsed.entities:
joined_members = set(await intent.get_room_members(room_id)) joined_members = set(await portal.main_intent.get_room_members(room_id))
last_offset = 0 last_offset = 0
at = 0 at = 0
for mention in sorted(parsed.entities, key=lambda entity: entity.offset): for mention in sorted(parsed.entities, key=lambda entity: entity.offset):

View File

@ -46,6 +46,7 @@ from ..types.chat import Chatlog, KnownChatType
from ..types.chat.attachment import MentionStruct, ReplyAttachment from ..types.chat.attachment import MentionStruct, ReplyAttachment
from ..types.client.client_session import LoginResult from ..types.client.client_session import LoginResult
from ..types.oauth import OAuthCredential, OAuthInfo from ..types.oauth import OAuthCredential, OAuthInfo
from ..types.openlink.open_link_type import OpenChannelUserPerm
from ..types.openlink.open_link_user_info import OpenLinkChannelUserInfo from ..types.openlink.open_link_user_info import OpenLinkChannelUserInfo
from ..types.packet.chat.kickout import KnownKickoutType, KickoutRes from ..types.packet.chat.kickout import KnownKickoutType, KickoutRes
from ..types.request import ( from ..types.request import (
@ -126,7 +127,7 @@ class Client:
@classmethod @classmethod
async def register_device(cls, passcode: str, **req: JSON) -> None: async def register_device(cls, passcode: str, **req: JSON) -> None:
"""Register a (fake) device that will be associated with the provided login credentials.""" """Register a (fake) device that will be associated with the provided login credentials."""
await cls._api_request_void("register_device", passcode=passcode, is_secret=True, **req) await cls._api_request_void("register_device", passcode=passcode, **req)
@classmethod @classmethod
async def login(cls, **req: JSON) -> OAuthCredential: async def login(cls, **req: JSON) -> OAuthCredential:
@ -135,7 +136,7 @@ class Client:
Must have first called register_device with these credentials. Must have first called register_device with these credentials.
""" """
# NOTE Actually returns an auth LoginData, but this only needs an OAuthCredential # NOTE Actually returns an auth LoginData, but this only needs an OAuthCredential
return await cls._api_request_result(OAuthCredential, "login", is_secret=True, **req) return await cls._api_request_result(OAuthCredential, "login", **req)
# endregion # endregion
@ -357,9 +358,6 @@ class Client:
width=width, width=width,
height=height, height=height,
ext=ext, ext=ext,
# Don't log the bytes
# TODO Disable logging per-argument, not per-command
is_secret=True
) )
async def delete_chat( async def delete_chat(
@ -384,6 +382,52 @@ class Client:
read_until_chat_id=read_until_chat_id.serialize(), read_until_chat_id=read_until_chat_id.serialize(),
) )
async def send_perm(
self,
channel_props: ChannelProps,
user_id: Long,
perm: OpenChannelUserPerm,
) -> None:
return await self._api_user_request_void(
"send_perm",
channel_props=channel_props.serialize(),
user_id=user_id.serialize(),
perm=perm,
)
async def set_channel_name(
self,
channel_props: ChannelProps,
name: str,
) -> None:
return await self._api_user_request_void(
"set_channel_name",
channel_props=channel_props.serialize(),
name=name,
)
async def set_channel_description(
self,
channel_props: ChannelProps,
description: str,
) -> None:
return await self._api_user_request_void(
"set_channel_description",
channel_props=channel_props.serialize(),
description=description,
)
async def set_channel_photo(
self,
channel_props: ChannelProps,
photo_url: str,
) -> None:
return await self._api_user_request_void(
"set_channel_photo",
channel_props=channel_props.serialize(),
photo_url=photo_url,
)
# TODO Combine these into one # TODO Combine these into one
@ -416,15 +460,15 @@ class Client:
# region listeners # region listeners
async def _on_chat(self, data: dict[str, JSON]) -> None: def _on_chat(self, data: dict[str, JSON]) -> Awaitable[None]:
await self.user.on_chat( return self.user.on_chat(
Chatlog.deserialize(data["chatlog"]), Chatlog.deserialize(data["chatlog"]),
Long.deserialize(data["channelId"]), Long.deserialize(data["channelId"]),
str(data["channelType"]), str(data["channelType"]),
) )
async def _on_chat_deleted(self, data: dict[str, JSON]) -> None: def _on_chat_deleted(self, data: dict[str, JSON]) -> Awaitable[None]:
await self.user.on_chat_deleted( return self.user.on_chat_deleted(
Long.deserialize(data["chatId"]), Long.deserialize(data["chatId"]),
Long.deserialize(data["senderId"]), Long.deserialize(data["senderId"]),
int(data["timestamp"]), int(data["timestamp"]),
@ -432,68 +476,83 @@ class Client:
str(data["channelType"]), str(data["channelType"]),
) )
async def _on_chat_read(self, data: dict[str, JSON]) -> None: def _on_chat_read(self, data: dict[str, JSON]) -> Awaitable[None]:
await self.user.on_chat_read( return self.user.on_chat_read(
Long.deserialize(data["chatId"]), Long.deserialize(data["chatId"]),
Long.deserialize(data["senderId"]), Long.deserialize(data["senderId"]),
Long.deserialize(data["channelId"]), Long.deserialize(data["channelId"]),
str(data["channelType"]), str(data["channelType"]),
) )
async def _on_profile_changed(self, data: dict[str, JSON]) -> None: def _on_profile_changed(self, data: dict[str, JSON]) -> Awaitable[None]:
await self.user.on_profile_changed( return self.user.on_profile_changed(
OpenLinkChannelUserInfo.deserialize(data["info"]), OpenLinkChannelUserInfo.deserialize(data["info"]),
) )
async def _on_channel_join(self, data: dict[str, JSON]) -> None: def _on_perm_changed(self, data: dict[str, JSON]) -> Awaitable[None]:
await self.user.on_channel_join( return self.user.on_perm_changed(
ChannelInfo.deserialize(data["channelInfo"]), Long.deserialize(data["userId"]),
) OpenChannelUserPerm(data["perm"]),
async def _on_channel_left(self, data: dict[str, JSON]) -> None:
await self.user.on_channel_left(
Long.deserialize(data["channelId"]), Long.deserialize(data["channelId"]),
str(data["channelType"]), str(data["channelType"]),
) )
async def _on_channel_kicked(self, data: dict[str, JSON]) -> None: def _on_channel_join(self, data: dict[str, JSON]) -> Awaitable[None]:
await self.user.on_channel_kicked( return self.user.on_channel_join(
ChannelInfo.deserialize(data["channelInfo"]),
)
def _on_channel_left(self, data: dict[str, JSON]) -> Awaitable[None]:
return self.user.on_channel_left(
Long.deserialize(data["channelId"]),
str(data["channelType"]),
)
def _on_channel_kicked(self, data: dict[str, JSON]) -> Awaitable[None]:
return self.user.on_channel_kicked(
Long.deserialize(data["userId"]), Long.deserialize(data["userId"]),
Long.deserialize(data["senderId"]), Long.deserialize(data["senderId"]),
Long.deserialize(data["channelId"]), Long.deserialize(data["channelId"]),
str(data["channelType"]), str(data["channelType"]),
) )
async def _on_user_join(self, data: dict[str, JSON]) -> None: def _on_user_join(self, data: dict[str, JSON]) -> Awaitable[None]:
await self.user.on_user_join( return self.user.on_user_join(
Long.deserialize(data["userId"]), Long.deserialize(data["userId"]),
Long.deserialize(data["channelId"]), Long.deserialize(data["channelId"]),
str(data["channelType"]), str(data["channelType"]),
) )
async def _on_user_left(self, data: dict[str, JSON]) -> None: def _on_user_left(self, data: dict[str, JSON]) -> Awaitable[None]:
await self.user.on_user_left( return self.user.on_user_left(
Long.deserialize(data["userId"]), Long.deserialize(data["userId"]),
Long.deserialize(data["channelId"]), Long.deserialize(data["channelId"]),
str(data["channelType"]), str(data["channelType"]),
) )
def _on_channel_meta_change(self, data: dict[str, JSON]) -> Awaitable[None]:
return self.user.on_channel_meta_change(
PortalChannelInfo.deserialize(data["info"]),
Long.deserialize(data["channelId"]),
str(data["channelType"]),
)
async def _on_listen_disconnect(self, data: dict[str, JSON]) -> None:
def _on_listen_disconnect(self, data: dict[str, JSON]) -> Awaitable[None]:
try: try:
res = KickoutRes.deserialize(data) res = KickoutRes.deserialize(data)
except Exception: except Exception:
self.log.exception("Invalid kickout reason, defaulting to None") self.log.exception("Invalid kickout reason, defaulting to None")
res = None res = None
await self._on_disconnect(res) return self._on_disconnect(res)
async def _on_switch_server(self) -> None: def _on_switch_server(self) -> Awaitable[None]:
# TODO Reconnect automatically instead # TODO Reconnect automatically instead
await self._on_disconnect(KickoutRes(KnownKickoutType.CHANGE_SERVER)) return self._on_disconnect(KickoutRes(KnownKickoutType.CHANGE_SERVER))
async def _on_disconnect(self, res: KickoutRes | None) -> None: def _on_disconnect(self, res: KickoutRes | None) -> Awaitable[None]:
self._stop_listen() self._stop_listen()
await self.user.on_disconnect(res) return self.user.on_disconnect(res)
def _on_error(self, data: dict[str, JSON]) -> Awaitable[None]: def _on_error(self, data: dict[str, JSON]) -> Awaitable[None]:
return self.user.on_error(data) return self.user.on_error(data)
@ -504,11 +563,13 @@ class Client:
self._add_event_handler("chat_deleted", self._on_chat_deleted) self._add_event_handler("chat_deleted", self._on_chat_deleted)
self._add_event_handler("chat_read", self._on_chat_read) self._add_event_handler("chat_read", self._on_chat_read)
self._add_event_handler("profile_changed", self._on_profile_changed) self._add_event_handler("profile_changed", self._on_profile_changed)
self._add_event_handler("perm_changed", self._on_perm_changed)
self._add_event_handler("channel_join", self._on_channel_join) self._add_event_handler("channel_join", self._on_channel_join)
self._add_event_handler("channel_left", self._on_channel_left) self._add_event_handler("channel_left", self._on_channel_left)
self._add_event_handler("channel_kicked", self._on_channel_kicked) self._add_event_handler("channel_kicked", self._on_channel_kicked)
self._add_event_handler("user_join", self._on_user_join) self._add_event_handler("user_join", self._on_user_join)
self._add_event_handler("user_left", self._on_user_left) self._add_event_handler("user_left", self._on_user_left)
self._add_event_handler("channel_meta_change", self._on_channel_meta_change)
self._add_event_handler("disconnected", self._on_listen_disconnect) self._add_event_handler("disconnected", self._on_listen_disconnect)
self._add_event_handler("switch_server", self._on_switch_server) self._add_event_handler("switch_server", self._on_switch_server)
self._add_event_handler("error", self._on_error) self._add_event_handler("error", self._on_error)

View File

@ -32,6 +32,7 @@ from ..types.channel.channel_info import NormalChannelInfo
from ..types.channel.channel_type import ChannelType from ..types.channel.channel_type import ChannelType
from ..types.chat import KnownChatType from ..types.chat import KnownChatType
from ..types.openlink.open_channel_info import OpenChannelInfo from ..types.openlink.open_channel_info import OpenChannelInfo
from ..types.openlink.open_link_type import OpenChannelUserPerm
from ..types.user.channel_user_info import NormalChannelUserInfo, OpenChannelUserInfo from ..types.user.channel_user_info import NormalChannelUserInfo, OpenChannelUserInfo
@ -68,8 +69,9 @@ setattr(UserInfoUnion, "deserialize", deserialize_user_info_union)
@dataclass @dataclass
class PortalChannelInfo(SerializableAttrs): class PortalChannelInfo(SerializableAttrs):
name: str name: str
participants: list[UserInfoUnion] description: Optional[str] = None
# TODO Image photoURL: Optional[str] = None
participants: Optional[list[UserInfoUnion]] = None # May set to None to skip participant update
channel_info: Optional[ChannelInfoUnion] = None # Should be set manually by caller channel_info: Optional[ChannelInfoUnion] = None # Should be set manually by caller
@ -91,3 +93,25 @@ TO_MSGTYPE_MAP: dict[MessageType, KnownChatType] = {
# https://stackoverflow.com/a/483833 # https://stackoverflow.com/a/483833
FROM_MSGTYPE_MAP: dict[KnownChatType, MessageType] = {v: k for k, v in TO_MSGTYPE_MAP.items()} FROM_MSGTYPE_MAP: dict[KnownChatType, MessageType] = {v: k for k, v in TO_MSGTYPE_MAP.items()}
# TODO Consider allowing custom power/perm mappings
FROM_PERM_MAP: dict[OpenChannelUserPerm, int] = {
OpenChannelUserPerm.OWNER: 100,
OpenChannelUserPerm.MANAGER: 50,
# TODO Decide on an appropriate value for this
#OpenChannelUserPerm.BOT: 101,
# NOTE Intentionally skipping OpenChannelUserPerm.NONE
}
# NOTE Using a class to make it look like a dict
class TO_PERM_MAP:
@staticmethod
def get(key: int, default: Optional[OpenChannelUserPerm] = None) -> OpenChannelUserPerm:
if key >= 100:
return OpenChannelUserPerm.OWNER
elif key >= 50:
return OpenChannelUserPerm.MANAGER
else:
return default or OpenChannelUserPerm.NONE

View File

@ -26,6 +26,7 @@ from mautrix.types import (
RedactionEvent, RedactionEvent,
RoomID, RoomID,
SingleReceiptEventContent, SingleReceiptEventContent,
StateEvent,
UserID, UserID,
) )
@ -135,7 +136,7 @@ class MatrixHandler(BaseMatrixHandler):
) )
return return
user = await u.User.get_by_mxid(user_id) user = await u.User.get_by_mxid(user_id)
if not user: if not user or not user.is_connected:
return return
portal = await po.Portal.get_by_mxid(room_id) portal = await po.Portal.get_by_mxid(room_id)
@ -175,3 +176,15 @@ class MatrixHandler(BaseMatrixHandler):
evt: ReactionEvent evt: ReactionEvent
await self.handle_reaction(evt.room_id, evt.sender, evt.event_id, evt.content) await self.handle_reaction(evt.room_id, evt.sender, evt.event_id, evt.content)
""" """
async def handle_state_event(self, evt: StateEvent) -> None:
if po.Portal.supports_state_event(evt.type):
user = await u.User.get_by_mxid(evt.sender)
if not user:
return
portal = await po.Portal.get_by_mxid(evt.room_id)
if not portal:
return
await portal.handle_matrix_state_event(user, evt)

View File

@ -20,6 +20,8 @@ from typing import (
Any, Any,
AsyncGenerator, AsyncGenerator,
Awaitable, Awaitable,
Callable,
NamedTuple,
Pattern, Pattern,
cast, cast,
) )
@ -48,8 +50,14 @@ from mautrix.types import (
Membership, Membership,
MessageEventContent, MessageEventContent,
MessageType, MessageType,
PowerLevelStateEventContent,
RoomAvatarStateEventContent,
RoomNameStateEventContent,
RoomTopicStateEventContent,
RelationType, RelationType,
RoomID, RoomID,
StateEvent,
StateEventContent,
TextMessageEventContent, TextMessageEventContent,
UserID, UserID,
VideoInfo, VideoInfo,
@ -80,12 +88,16 @@ from .kt.types.chat.attachment import (
ReplyAttachment, ReplyAttachment,
VideoAttachment, VideoAttachment,
) )
from .kt.types.user.channel_user_info import OpenChannelUserInfo
from .kt.types.openlink.open_link_type import OpenChannelUserPerm
from .kt.client.types import ( from .kt.client.types import (
UserInfoUnion, UserInfoUnion,
PortalChannelInfo, PortalChannelInfo,
ChannelProps, ChannelProps,
TO_MSGTYPE_MAP, TO_MSGTYPE_MAP,
FROM_PERM_MAP,
TO_PERM_MAP,
) )
from .kt.client.errors import CommandException from .kt.client.errors import CommandException
@ -113,6 +125,14 @@ class FakeLock:
pass pass
class StateEventHandler(NamedTuple):
# TODO Can this use Generic to force the two StateEventContent parameters to be of the same type?
# Or, just have a single StateEvent parameter
apply: Callable[[u.User, StateEventContent, StateEventContent], Awaitable[None]]
revert: Callable[[StateEventContent], Awaitable[None]]
action_name: str
StateBridge = EventType.find("m.bridge", EventType.Class.STATE) StateBridge = EventType.find("m.bridge", EventType.Class.STATE)
StateHalfShotBridge = EventType.find("uk.half-shot.bridge", EventType.Class.STATE) StateHalfShotBridge = EventType.find("uk.half-shot.bridge", EventType.Class.STATE)
@ -142,10 +162,12 @@ class Portal(DBPortal, BasePortal):
kt_type: ChannelType, kt_type: ChannelType,
mxid: RoomID | None = None, mxid: RoomID | None = None,
name: str | None = None, name: str | None = None,
description: str | None = None,
photo_id: str | None = None, photo_id: str | None = None,
avatar_url: ContentURI | None = None, avatar_url: ContentURI | None = None,
encrypted: bool = False, encrypted: bool = False,
name_set: bool = False, name_set: bool = False,
topic_set: bool = False,
avatar_set: bool = False, avatar_set: bool = False,
relay_user_id: UserID | None = None, relay_user_id: UserID | None = None,
) -> None: ) -> None:
@ -155,10 +177,12 @@ class Portal(DBPortal, BasePortal):
kt_type, kt_type,
mxid, mxid,
name, name,
description,
photo_id, photo_id,
avatar_url, avatar_url,
encrypted, encrypted,
name_set, name_set,
topic_set,
avatar_set, avatar_set,
relay_user_id, relay_user_id,
) )
@ -190,8 +214,7 @@ class Portal(DBPortal, BasePortal):
NotificationDisabler.puppet_cls = p.Puppet NotificationDisabler.puppet_cls = p.Puppet
NotificationDisabler.config_enabled = cls.config["bridge.backfill.disable_notifications"] NotificationDisabler.config_enabled = cls.config["bridge.backfill.disable_notifications"]
# TODO More cls._CHAT_TYPE_HANDLER_MAP = {
cls._chat_type_handler_map = {
KnownChatType.FEED: cls._handle_kakaotalk_feed, KnownChatType.FEED: cls._handle_kakaotalk_feed,
KnownChatType.TEXT: cls._handle_kakaotalk_text, KnownChatType.TEXT: cls._handle_kakaotalk_text,
KnownChatType.REPLY: cls._handle_kakaotalk_reply, KnownChatType.REPLY: cls._handle_kakaotalk_reply,
@ -203,6 +226,33 @@ class Portal(DBPortal, BasePortal):
16385: cls._handle_kakaotalk_deleted, 16385: cls._handle_kakaotalk_deleted,
} }
cls._STATE_EVENT_HANDLER_MAP: dict[EventType, StateEventHandler] = {
EventType.ROOM_POWER_LEVELS: StateEventHandler(
cls._handle_matrix_power_levels,
cls._revert_matrix_power_levels,
"power level"
),
EventType.ROOM_NAME: StateEventHandler(
cls._handle_matrix_room_name,
cls._revert_matrix_room_name,
"room name"
),
EventType.ROOM_TOPIC: StateEventHandler(
cls._handle_matrix_room_topic,
cls._revert_matrix_room_topic,
"room topic"
),
EventType.ROOM_AVATAR: StateEventHandler(
cls._handle_matrix_room_avatar,
cls._revert_matrix_room_avatar,
"room avatar"
),
}
@classmethod
def supports_state_event(cls, evt_type: EventType) -> bool:
return evt_type in cls._STATE_EVENT_HANDLER_MAP
# region DB conversion # region DB conversion
async def delete(self) -> None: async def delete(self) -> None:
@ -234,6 +284,10 @@ class Portal(DBPortal, BasePortal):
def is_direct(self) -> bool: def is_direct(self) -> bool:
return KnownChannelType.is_direct(self.kt_type) return KnownChannelType.is_direct(self.kt_type)
@property
def is_open(self) -> bool:
return KnownChannelType.is_open(self.kt_type)
@property @property
def kt_sender(self) -> int | None: def kt_sender(self) -> int | None:
if self.is_direct: if self.is_direct:
@ -309,16 +363,57 @@ class Portal(DBPortal, BasePortal):
changed = any( changed = any(
await asyncio.gather( await asyncio.gather(
self._update_name(info.name), self._update_name(info.name),
# TODO self._update_description(info.description),
#self._update_photo(source, info.image), self._update_photo(source, info.photoURL),
) )
) )
changed = await self._update_participants(source, info.participants) or changed if info.participants is not None:
changed = await self._update_participants(source, info.participants) or changed
if self.mxid and self.is_open:
user_power_levels = await self._get_mapped_participant_power_levels(info.participants, skip_default=False)
asyncio.create_task(self.set_power_levels(user_power_levels))
if changed or force_save: if changed or force_save:
await self.update_bridge_info() await self.update_bridge_info()
await self.save() await self.save()
return info return info
async def _get_mapped_participant_power_levels(self, participants: list[UserInfoUnion], skip_default: bool) -> dict[UserID, int]:
user_power_levels: dict[UserID, int] = {}
default_value = None if skip_default else 0
for participant in participants:
if not isinstance(participant, OpenChannelUserInfo):
self.log.warning(f"Info object for participant {participant.userId} of open channel is not an OpenChannelUserInfo")
continue
power_level = FROM_PERM_MAP.get(participant.perm, default_value)
if power_level is None:
continue
await self.update_mapped_ktid_power_levels(user_power_levels, participant.userId, power_level)
return user_power_levels
@staticmethod
async def update_mapped_ktid_power_levels(user_power_levels: dict[UserID, int], ktid: int, power_level: int) -> None:
user = await u.User.get_by_ktid(ktid)
if user:
user_power_levels[user.mxid] = power_level
puppet = await p.Puppet.get_by_ktid(ktid)
if puppet:
user_power_levels[puppet.mxid] = power_level
async def set_power_levels(self, user_power_levels: dict[UserID, int]) -> None:
if self.mxid and user_power_levels:
changed = False
power_levels = await self.main_intent.get_power_levels(self.mxid)
for user, power_level in user_power_levels.items():
changed = power_levels.ensure_user_level(user, power_level) or changed
if changed:
await self.main_intent.set_power_levels(self.mxid, power_levels)
@staticmethod
async def get_mapped_ktid_power_levels(ktid: int, power_level: int) -> dict[UserID, int]:
user_power_levels: dict[UserID, int] = {}
await Portal.update_mapped_ktid_power_levels(user_power_levels, ktid, power_level)
return user_power_levels
@classmethod @classmethod
async def _reupload_kakaotalk_file( async def _reupload_kakaotalk_file(
cls, cls,
@ -386,23 +481,37 @@ class Portal(DBPortal, BasePortal):
return True return True
return False return False
""" async def _update_description(self, description: str | None) -> bool:
async def _update_photo(self, source: u.User, photo: graphql.Picture) -> bool: if self.description != description or not self.topic_set:
self.log.trace("Updating description %s -> %s", self.description, description)
self.description = description
if self.mxid and (self.encrypted or not self.is_direct):
try:
await self.main_intent.set_room_topic(self.mxid, self.description)
self.topic_set = True
except Exception:
self.log.exception("Failed to set room description")
self.topic_set = False
return True
return False
async def _update_photo(self, source: u.User, photo_id: str | None) -> bool:
if self.is_direct and not self.encrypted: if self.is_direct and not self.encrypted:
return False return False
photo_id = self.get_photo_id(photo) if self.photo_id is not None and photo_id is None:
self.log.warning("Portal previously had a photo_id, but new photo_id is None. Leaving it as it is")
return False
if self.photo_id != photo_id or not self.avatar_set: if self.photo_id != photo_id or not self.avatar_set:
self.photo_id = photo_id self.photo_id = photo_id
if photo: if photo_id:
if self.photo_id != photo_id or not self.avatar_url: if self.photo_id != photo_id or not self.avatar_url:
# Reset avatar_url first in case the upload fails # Reset avatar_url first in case the upload fails
self.avatar_url = None self.avatar_url = None
self.avatar_url = await p.Puppet.reupload_avatar( self.avatar_url = await p.Puppet.reupload_avatar(
source, source,
self.main_intent, self.main_intent,
photo.uri, photo_id,
self.ktid, self.ktid,
use_graph=self.is_direct and (photo.height or 0) < 500,
) )
else: else:
self.avatar_url = ContentURI("") self.avatar_url = ContentURI("")
@ -415,7 +524,6 @@ class Portal(DBPortal, BasePortal):
self.avatar_set = False self.avatar_set = False
return True return True
return False return False
"""
async def _update_photo_from_puppet(self, puppet: p.Puppet) -> bool: async def _update_photo_from_puppet(self, puppet: p.Puppet) -> bool:
if self.photo_id == puppet.photo_id and self.avatar_set: if self.photo_id == puppet.photo_id and self.avatar_set:
@ -632,7 +740,8 @@ class Portal(DBPortal, BasePortal):
"content": self.bridge_info, "content": self.bridge_info,
}, },
] ]
if KnownChannelType.is_open(info.channel_info.type):
if self.is_open:
initial_state.extend(( initial_state.extend((
{ {
"type": str(EventType.ROOM_JOIN_RULES), "type": str(EventType.ROOM_JOIN_RULES),
@ -643,6 +752,15 @@ class Portal(DBPortal, BasePortal):
"content": {"guest_access": "forbidden"}, "content": {"guest_access": "forbidden"},
}, },
)) ))
user_power_levels = await self._get_mapped_participant_power_levels(info.participants, skip_default=True)
user_power_levels[self.main_intent.mxid] = 1 + FROM_PERM_MAP.get(OpenChannelUserPerm.OWNER)
initial_state.append(
{
"type": str(EventType.ROOM_POWER_LEVELS),
"content": PowerLevelStateEventContent(users=user_power_levels).serialize()
}
)
invites = [] invites = []
if self.config["bridge.encryption.default"] and self.matrix.e2ee: if self.config["bridge.encryption.default"] and self.matrix.e2ee:
self.encrypted = True self.encrypted = True
@ -809,7 +927,7 @@ class Portal(DBPortal, BasePortal):
async def _send_chat( async def _send_chat(
self, sender: u.User, message: TextMessageEventContent, event_id: EventID | None = None self, sender: u.User, message: TextMessageEventContent, event_id: EventID | None = None
) -> Long: ) -> Long:
converted = await matrix_to_kakaotalk(message, self.mxid, self.log, self.main_intent) converted = await matrix_to_kakaotalk(message, self.mxid, self.log, self)
try: try:
return await sender.client.send_chat( return await sender.client.send_chat(
self.channel_props, self.channel_props,
@ -930,6 +1048,8 @@ class Portal(DBPortal, BasePortal):
sender, _ = await self.get_relay_sender(sender, f"redaction {event_id}") sender, _ = await self.get_relay_sender(sender, f"redaction {event_id}")
if not sender: if not sender:
raise Exception("not logged in") raise Exception("not logged in")
elif not sender.is_connected:
raise Exception("not connected to KakaoTalk chats")
message = await DBMessage.get_by_mxid(event_id, self.mxid) message = await DBMessage.get_by_mxid(event_id, self.mxid)
if message: if message:
if not message.ktid: if not message.ktid:
@ -965,6 +1085,131 @@ class Portal(DBPortal, BasePortal):
pass pass
""" """
async def handle_matrix_state_event(self, sender: u.User, evt: StateEvent) -> None:
try:
handler: StateEventHandler = self._STATE_EVENT_HANDLER_MAP[evt.type]
except KeyError:
# Misses should be guarded by supports_state_event, but handle this just in case
self.log.error(f"Skipping Matrix state event {evt.event_id} of unsupported type {evt.type}")
return
try:
effective_sender, _ = await self.get_relay_sender(sender, f"{handler.action_name} {evt.event_id}")
await handler.apply(self, effective_sender, evt.prev_content, evt.content)
except Exception as e:
self.log.error(
f"Failed to handle Matrix {handler.action_name} {evt.event_id}: {e}",
exc_info=not isinstance(e, NotImplementedError),
)
sender.send_remote_checkpoint(
self._status_from_exception(e),
evt.event_id,
self.mxid,
evt.type,
error=e,
)
change = f"{handler.action_name} change"
await self._send_bridge_error(
f"{e}. Reverting the {change}...",
thing=change
)
# NOTE Redacting instead doesn't work
await handler.revert(self, evt.prev_content)
else:
await self._send_delivery_receipt(evt.event_id)
sender.send_remote_checkpoint(
MessageSendCheckpointStatus.SUCCESS,
evt.event_id,
self.mxid,
evt.type,
)
async def _handle_matrix_power_levels(
self,
sender: u.User,
prev_content: PowerLevelStateEventContent,
content: PowerLevelStateEventContent,
) -> None:
for target_mxid, power_level in content.users.items():
if power_level == prev_content.get_user_level(target_mxid):
continue
puppet = await p.Puppet.get_by_mxid(target_mxid)
if puppet:
if sender and sender.is_connected:
perm = TO_PERM_MAP.get(power_level)
await sender.client.send_perm(self.channel_props, puppet.ktid, perm)
else:
raise Exception(
"Only users connected to KakaoTalk can set power levels of KakaoTalk users"
)
async def _revert_matrix_power_levels(self, prev_content: PowerLevelStateEventContent) -> None:
await self.main_intent.set_power_levels(self.mxid, prev_content)
async def _handle_matrix_room_name(
self,
sender: u.User,
prev_content: RoomNameStateEventContent,
content: RoomNameStateEventContent,
) -> None:
if content.name == prev_content.name:
return
if not (sender and sender.is_connected):
raise Exception(
"Only users connected to KakaoTalk can set the name of a KakaoTalk channel"
)
await sender.client.set_channel_name(self.channel_props, content.name)
self.name = content.name
self.name_set = True
await self.save()
async def _revert_matrix_room_name(self, prev_content: RoomNameStateEventContent) -> None:
await self.main_intent.set_room_name(self.mxid, prev_content.name)
async def _handle_matrix_room_topic(
self,
sender: u.User,
prev_content: RoomTopicStateEventContent,
content: RoomTopicStateEventContent,
) -> None:
if content.topic == prev_content.topic:
return
if not (sender and sender.is_connected):
raise Exception(
"Only users connected to KakaoTalk can set the description of a KakaoTalk channel"
)
await sender.client.set_channel_description(self.channel_props, content.topic)
self.description = content.topic
self.topic_set = True
await self.save()
async def _revert_matrix_room_topic(self, prev_content: RoomTopicStateEventContent) -> None:
await self.main_intent.set_room_topic(self.mxid, prev_content.topic)
async def _handle_matrix_room_avatar(
self,
sender: u.User,
prev_content: RoomAvatarStateEventContent,
content: RoomAvatarStateEventContent,
) -> None:
if content.url == prev_content.url:
return
if not (sender and sender.is_connected):
raise Exception(
"Only users connected to KakaoTalk can set the photo of a KakaoTalk channel"
)
raise NotImplementedError("Changing the room avatar is not supported by the KakaoTalk bridge.")
""" TODO
photo_url = str(self.main_intent.api.get_download_url(content.url))
await sender.client.set_channel_photo(self.channel_props, photo_url)
self.photo_id = photo_url
self.avatar_url = content.url
self.avatar_set = True
await self.save()
"""
async def _revert_matrix_room_avatar(self, prev_content: RoomAvatarStateEventContent) -> None:
await self.main_intent.set_room_avatar(self.mxid, prev_content.url)
async def handle_matrix_leave(self, user: u.User) -> None: async def handle_matrix_leave(self, user: u.User) -> None:
if self.is_direct: if self.is_direct:
self.log.info(f"{user.mxid} left private chat portal with {self.ktid}") self.log.info(f"{user.mxid} left private chat portal with {self.ktid}")
@ -1067,7 +1312,7 @@ class Portal(DBPortal, BasePortal):
await intent.ensure_joined(self.mxid) await intent.ensure_joined(self.mxid)
self._backfill_leave.add(intent) self._backfill_leave.add(intent)
handler = self._chat_type_handler_map.get(chat.type, Portal._handle_kakaotalk_unsupported) handler = self._CHAT_TYPE_HANDLER_MAP.get(chat.type, Portal._handle_kakaotalk_unsupported)
event_ids = [ event_ids = [
event_id for event_id in event_id for event_id in
await handler( await handler(

View File

@ -166,6 +166,8 @@ class RPCClient:
self._on_disconnect() self._on_disconnect()
def _on_disconnect(self) -> None: def _on_disconnect(self) -> None:
self._req_id = 0
self._min_broadcast_id = 0
self._reader = None self._reader = None
self._writer = None self._writer = None
self._is_connected.clear() self._is_connected.clear()
@ -293,11 +295,11 @@ class RPCClient:
except: except:
self.log.exception("Failed to handle incoming request %s", line_str) self.log.exception("Failed to handle incoming request %s", line_str)
async def _raw_request(self, command: str, is_secret: bool = False, **data: JSON) -> asyncio.Future[JSON]: async def _raw_request(self, command: str, **data: JSON) -> asyncio.Future[JSON]:
req_id = self._next_req_id req_id = self._next_req_id
future = self._response_waiters[req_id] = self.loop.create_future() future = self._response_waiters[req_id] = self.loop.create_future()
req = {"id": req_id, "command": command, **data} req = {"id": req_id, "command": command, **data}
self.log.debug("Request %d: %s %s", req_id, command, data if not is_secret else "<REDACTED>") self.log.debug("Request %d: %s", req_id, command)
assert self._writer is not None assert self._writer is not None
self._writer.write(json.dumps(req).encode("utf-8")) self._writer.write(json.dumps(req).encode("utf-8"))
self._writer.write(b"\n") self._writer.write(b"\n")

View File

@ -39,7 +39,7 @@ from .db import User as DBUser
from .kt.client import Client from .kt.client import Client
from .kt.client.errors import AuthenticationRequired, ResponseError from .kt.client.errors import AuthenticationRequired, ResponseError
from .kt.client.types import SettingsStruct from .kt.client.types import PortalChannelInfo, SettingsStruct, FROM_PERM_MAP
from .kt.types.bson import Long from .kt.types.bson import Long
from .kt.types.channel.channel_info import ChannelInfo, NormalChannelInfo, NormalChannelData from .kt.types.channel.channel_info import ChannelInfo, NormalChannelInfo, NormalChannelData
from .kt.types.channel.channel_type import ChannelType, KnownChannelType from .kt.types.channel.channel_type import ChannelType, KnownChannelType
@ -47,6 +47,7 @@ from .kt.types.chat.chat import Chatlog
from .kt.types.client.client_session import LoginDataItem, LoginResult from .kt.types.client.client_session import LoginDataItem, LoginResult
from .kt.types.oauth import OAuthCredential from .kt.types.oauth import OAuthCredential
from .kt.types.openlink.open_channel_info import OpenChannelData, OpenChannelInfo from .kt.types.openlink.open_channel_info import OpenChannelData, OpenChannelInfo
from .kt.types.openlink.open_link_type import OpenChannelUserPerm
from .kt.types.openlink.open_link_user_info import OpenLinkChannelUserInfo from .kt.types.openlink.open_link_user_info import OpenLinkChannelUserInfo
from .kt.types.packet.chat.kickout import KnownKickoutType, KickoutRes from .kt.types.packet.chat.kickout import KnownKickoutType, KickoutRes
@ -54,7 +55,9 @@ METRIC_CONNECT_AND_SYNC = Summary("bridge_connect_and_sync", "calls to connect_a
METRIC_CHAT = Summary("bridge_on_chat", "calls to on_chat") METRIC_CHAT = Summary("bridge_on_chat", "calls to on_chat")
METRIC_CHAT_DELETED = Summary("bridge_on_chat_deleted", "calls to on_chat_deleted") METRIC_CHAT_DELETED = Summary("bridge_on_chat_deleted", "calls to on_chat_deleted")
METRIC_CHAT_READ = Summary("bridge_on_chat_read", "calls to on_chat_read") METRIC_CHAT_READ = Summary("bridge_on_chat_read", "calls to on_chat_read")
METRIC_CHANNEL_META_CHANGE = Summary("bridge_on_channel_meta_change", "calls to on_channel_meta_change")
METRIC_PROFILE_CHANGE = Summary("bridge_on_profile_changed", "calls to on_profile_changed") METRIC_PROFILE_CHANGE = Summary("bridge_on_profile_changed", "calls to on_profile_changed")
METRIC_PERM_CHANGE = Summary("bridge_on_perm_changed", "calls to on_perm_changed")
METRIC_CHANNEL_JOIN = Summary("bridge_on_channel_join", "calls to on_channel_join") METRIC_CHANNEL_JOIN = Summary("bridge_on_channel_join", "calls to on_channel_join")
METRIC_CHANNEL_LEFT = Summary("bridge_on_channel_left", "calls to on_channel_left") METRIC_CHANNEL_LEFT = Summary("bridge_on_channel_left", "calls to on_channel_left")
METRIC_CHANNEL_KICKED = Summary("bridge_on_channel_kicked", "calls to on_channel_kicked") METRIC_CHANNEL_KICKED = Summary("bridge_on_channel_kicked", "calls to on_channel_kicked")
@ -739,12 +742,46 @@ class User(DBUser, BaseUser):
await portal.backfill_lock.wait(f"read receipt from {sender_id}") await portal.backfill_lock.wait(f"read receipt from {sender_id}")
await portal.handle_kakaotalk_chat_read(self, puppet, chat_id) await portal.handle_kakaotalk_chat_read(self, puppet, chat_id)
@async_time(METRIC_CHANNEL_META_CHANGE)
async def on_channel_meta_change(
self,
info: PortalChannelInfo,
channel_id: Long,
channel_type: ChannelType,
) -> None:
portal = await po.Portal.get_by_ktid(
channel_id,
kt_receiver=self.ktid,
kt_type=channel_type,
)
if portal:
await portal.update_info(self, info)
@async_time(METRIC_PROFILE_CHANGE) @async_time(METRIC_PROFILE_CHANGE)
async def on_profile_changed(self, info: OpenLinkChannelUserInfo) -> None: async def on_profile_changed(self, info: OpenLinkChannelUserInfo) -> None:
puppet = await pu.Puppet.get_by_ktid(info.userId) puppet = await pu.Puppet.get_by_ktid(info.userId)
if puppet: if puppet:
await puppet.update_info_from_participant(self, info) await puppet.update_info_from_participant(self, info)
@async_time(METRIC_PERM_CHANGE)
async def on_perm_changed(
self,
user_id: Long,
perm: OpenChannelUserPerm,
channel_id: Long,
channel_type: ChannelType,
) -> None:
portal = await po.Portal.get_by_ktid(
channel_id,
kt_receiver=self.ktid,
kt_type=channel_type,
create=False,
)
if portal and portal.mxid:
power_level = FROM_PERM_MAP.get(perm, 0)
user_power_levels = await po.Portal.get_mapped_ktid_power_levels(user_id, power_level)
await portal.set_power_levels(user_power_levels)
@async_time(METRIC_CHANNEL_JOIN) @async_time(METRIC_CHANNEL_JOIN)
def on_channel_join(self, channel_info: ChannelInfo) -> Awaitable[None]: def on_channel_join(self, channel_info: ChannelInfo) -> Awaitable[None]:
return self._sync_channel(channel_info) return self._sync_channel(channel_info)

View File

@ -29,6 +29,9 @@ import {
/** @typedef {import("node-kakao").MentionStruct} MentionStruct */ /** @typedef {import("node-kakao").MentionStruct} MentionStruct */
/** @typedef {import("node-kakao/dist/talk").TalkChannelList} TalkChannelList */ /** @typedef {import("node-kakao/dist/talk").TalkChannelList} TalkChannelList */
import pkg from "node-kakao"
const { OpenChannelUserPerm } = pkg
import chat from "node-kakao/chat" import chat from "node-kakao/chat"
const { KnownChatType } = chat const { KnownChatType } = chat
@ -58,6 +61,34 @@ ServiceApiClient.prototype.requestFriendList = async function() {
} }
class CustomError extends Error {}
class PermError extends CustomError {
/** @type {Map<OpenChannelUserPerm, string> */
static #PERM_NAMES = new Map([
[OpenChannelUserPerm.OWNER, "the channel owner"],
[OpenChannelUserPerm.MANAGER, "channel admininstrators"],
[OpenChannelUserPerm.BOT, "bots"],
[OpenChannelUserPerm.NONE, "registered KakaoTalk users"],
])
/**
* @param {?OpenChannelUserPerm[]} permNeeded
* @param {?OpenChannelUserPerm} permActual
*/
constructor(permNeeded, permActual, action) {
const who =
!permActual
? "In this channel, no one"
: "Only " + permNeeded
.map(v => PermError.#PERM_NAMES.get(v))
.reduce((prev, curr) => prev += ` and ${curr}`)
super(`${who} can ${action}`)
this.name = this.constructor.name
}
}
class UserClient { class UserClient {
static #initializing = false static #initializing = false
@ -70,21 +101,23 @@ class UserClient {
/** /**
* DO NOT CONSTRUCT DIRECTLY. Callers should use {@link UserClient#create} instead. * DO NOT CONSTRUCT DIRECTLY. Callers should use {@link UserClient#create} instead.
* @param {Long} userId
* @param {string} mxid * @param {string} mxid
* @param {PeerClient} peerClient TODO Make RPC user-specific instead of needing this * @param {PeerClient} peerClient TODO Make RPC user-specific instead of needing this
*/ */
constructor(mxid, peerClient) { constructor(userId, mxid, peerClient) {
if (!UserClient.#initializing) { if (!UserClient.#initializing) {
throw new Error("Private constructor") throw new Error("Private constructor")
} }
UserClient.#initializing = false UserClient.#initializing = false
this.userId = userId
this.mxid = mxid this.mxid = mxid
this.peerClient = peerClient this.peerClient = peerClient
this.#talkClient.on("chat", (data, channel) => { this.#talkClient.on("chat", (data, channel) => {
this.log(`${data.chat.logId} received in channel ${channel.channelId}`) this.log(`${data.chat.logId} received in channel ${channel.channelId}`)
return this.write("chat", { this.write("chat", {
//is_sequential: true, // TODO Make sequential per user & channel (if it isn't already) //is_sequential: true, // TODO Make sequential per user & channel (if it isn't already)
chatlog: data.chat, chatlog: data.chat,
channelId: channel.channelId, channelId: channel.channelId,
@ -94,7 +127,7 @@ class UserClient {
this.#talkClient.on("chat_deleted", (feedChatlog, channel, feed) => { this.#talkClient.on("chat_deleted", (feedChatlog, channel, feed) => {
this.log(`${feed.logId} deleted in channel ${channel.channelId} by user ${feedChatlog.sender.userId}`) this.log(`${feed.logId} deleted in channel ${channel.channelId} by user ${feedChatlog.sender.userId}`)
return this.write("chat_deleted", { this.write("chat_deleted", {
chatId: feed.logId, chatId: feed.logId,
senderId: feedChatlog.sender.userId, senderId: feedChatlog.sender.userId,
timestamp: feedChatlog.sendAt, timestamp: feedChatlog.sendAt,
@ -105,7 +138,7 @@ class UserClient {
this.#talkClient.on("message_hidden", (hideLog, channel, feed) => { this.#talkClient.on("message_hidden", (hideLog, channel, feed) => {
this.log(`Message ${feed.logId} hid from channel ${channel.channelId} by user ${hideLog.sender.userId}`) this.log(`Message ${feed.logId} hid from channel ${channel.channelId} by user ${hideLog.sender.userId}`)
return this.write("chat_deleted", { this.write("chat_deleted", {
chatId: feed.logId, chatId: feed.logId,
senderId: hideLog.sender.userId, senderId: hideLog.sender.userId,
timestamp: hideLog.sendAt, timestamp: hideLog.sendAt,
@ -116,7 +149,7 @@ class UserClient {
this.#talkClient.on("chat_read", (chat, channel, reader) => { this.#talkClient.on("chat_read", (chat, channel, reader) => {
this.log(`${chat.logId} read in channel ${channel.channelId} by ${reader.userId}`) this.log(`${chat.logId} read in channel ${channel.channelId} by ${reader.userId}`)
return this.write("chat_read", { this.write("chat_read", {
chatId: chat.logId, chatId: chat.logId,
senderId: reader.userId, senderId: reader.userId,
channelId: channel.channelId, channelId: channel.channelId,
@ -126,7 +159,7 @@ class UserClient {
this.#talkClient.on("profile_changed", (channel, lastInfo, user) => { this.#talkClient.on("profile_changed", (channel, lastInfo, user) => {
this.log(`Profile of ${user.userId} changed (channel: ${channel ? channel.channelId : "None"})`) this.log(`Profile of ${user.userId} changed (channel: ${channel ? channel.channelId : "None"})`)
return this.write("profile_changed", { this.write("profile_changed", {
info: user, info: user,
/* TODO Is this ever a per-channel profile change? /* TODO Is this ever a per-channel profile change?
channelId: channel.channelId, channelId: channel.channelId,
@ -135,16 +168,28 @@ class UserClient {
}) })
}) })
this.#talkClient.on("perm_changed", (channel, lastInfo, user) => {
// TODO Fix the type hint on lastInfo and user: they should each be a OpenChannelUserInfo, not just a ChannelUserInfo
this.log(`Perms of user ${user.userId} in channel ${channel.channelId} changed from ${lastInfo.perm} to ${user.perm}`)
this.write("perm_changed", {
is_sequential: true,
userId: user.userId,
perm: user.perm,
channelId: channel.channelId,
channelType: channel.info.type,
})
})
this.#talkClient.on("channel_join", channel => { this.#talkClient.on("channel_join", channel => {
this.log(`Joined channel ${channel.channelId}`) this.log(`Joined channel ${channel.channelId}`)
return this.write("channel_join", { this.write("channel_join", {
channelInfo: channel.info, channelInfo: channel.info,
}) })
}) })
this.#talkClient.on("channel_left", channel => { this.#talkClient.on("channel_left", channel => {
this.log(`Left channel ${channel.channelId}`) this.log(`Left channel ${channel.channelId}`)
return this.write("channel_left", { this.write("channel_left", {
channelId: channel.channelId, channelId: channel.channelId,
channelType: channel.info.type, channelType: channel.info.type,
}) })
@ -152,7 +197,7 @@ class UserClient {
this.#talkClient.on("channel_kicked", (kickedLog, channel, feed) => { this.#talkClient.on("channel_kicked", (kickedLog, channel, feed) => {
this.log(`User ${feed.member.userId} kicked from channel ${channel.channelId} by user ${kickedLog.sender.userId}`) this.log(`User ${feed.member.userId} kicked from channel ${channel.channelId} by user ${kickedLog.sender.userId}`)
return this.write("channel_kicked", { this.write("channel_kicked", {
userId: feed.member.userId, userId: feed.member.userId,
senderId: kickedLog.sender.userId, senderId: kickedLog.sender.userId,
channelId: channel.channelId, channelId: channel.channelId,
@ -162,7 +207,7 @@ class UserClient {
this.#talkClient.on("user_join", (joinLog, channel, user, feed) => { this.#talkClient.on("user_join", (joinLog, channel, user, feed) => {
this.log(`User ${user.userId} joined channel ${channel.channelId}`) this.log(`User ${user.userId} joined channel ${channel.channelId}`)
return this.write("user_join", { this.write("user_join", {
userId: user.userId, userId: user.userId,
channelId: channel.channelId, channelId: channel.channelId,
channelType: channel.info.type, channelType: channel.info.type,
@ -171,31 +216,58 @@ class UserClient {
this.#talkClient.on("user_left", (leftLog, channel, user, feed) => { this.#talkClient.on("user_left", (leftLog, channel, user, feed) => {
this.log(`User ${user.userId} left channel ${channel.channelId}`) this.log(`User ${user.userId} left channel ${channel.channelId}`)
return this.write("user_left", { this.write("user_left", {
userId: user.userId, userId: user.userId,
channelId: channel.channelId, channelId: channel.channelId,
channelType: channel.info.type, channelType: channel.info.type,
}) })
}) })
this.#talkClient.on("meta_change", (channel, type, newMeta) => {
this.log(`Channel ${channel.channelId} metadata changed`)
})
this.#talkClient.on("push_packet", (method, data) => {
// TODO Find a better way to do this...but data doesn't have much.
if (method == "SYNCLINKUP") {
if (!data?.ol) return
const linkURL = data.ol?.lu
if (!linkURL) return
for (const channel of this.#talkClient.channelList.open.all()) {
if (channel.info.openLink?.linkURL == linkURL) {
this.write("channel_meta_change", {
info: {
name: data.ol?.ln,
description: data.ol?.desc || null,
photoURL: data.ol?.liu || null,
},
channelId: channel.channelId,
channelType: channel.info.type,
})
break
}
}
}
})
this.#talkClient.on("disconnected", (reason) => { this.#talkClient.on("disconnected", (reason) => {
this.log(`Disconnected (reason=${reason})`) this.log(`Disconnected (reason=${reason})`)
this.disconnect() this.disconnect()
return this.write("disconnected", { this.write("disconnected", {
reason: reason, reason: reason,
}) })
}) })
this.#talkClient.on("switch_server", () => { this.#talkClient.on("switch_server", () => {
this.log(`Server switch requested`) this.log(`Server switch requested`)
return this.write("switch_server", { this.write("switch_server", {
is_sequential: true, is_sequential: true,
}) })
}) })
this.#talkClient.on("error", (err) => { this.#talkClient.on("error", (err) => {
this.log(`Client error: ${err}`) this.log(`Client error: ${err}`)
return this.write("error", { this.write("error", {
error: err, error: err,
}) })
}) })
@ -208,7 +280,7 @@ class UserClient {
*/ */
static async create(mxid, credential, peerClient) { static async create(mxid, credential, peerClient) {
this.#initializing = true this.#initializing = true
const userClient = new UserClient(mxid, peerClient) const userClient = new UserClient(credential.userId, mxid, peerClient)
userClient.#serviceClient = await ServiceApiClient.create(credential) userClient.#serviceClient = await ServiceApiClient.create(credential)
return userClient return userClient
@ -438,9 +510,20 @@ export default class PeerClient {
/** /**
* @param {string} mxid * @param {string} mxid
* @param {ChannelProps} channelProps * @param {ChannelProps} channelProps
* @param {?OpenChannelUserPerm[]} permNeeded If set, throw if the user's permission level matches none of the values in this list.
* @param {?string} action The action requiring permission, to be used in an error message if throwing..
* @throws {PermError} if the user does not have the specified permission level.
*/ */
async #getUserChannel(mxid, channelProps) { async #getUserChannel(mxid, channelProps, permNeeded, action) {
return await this.#getUser(mxid).getChannel(channelProps) const userClient = this.#getUser(mxid)
const talkChannel = await userClient.getChannel(channelProps)
if (permNeeded) {
const permActual = talkChannel.getUserInfo({ userId: userClient.userId }).perm
if (permNeeded.indexOf(permActual) == -1) {
throw new PermError(permNeeded, permActual, action)
}
}
return talkChannel
} }
/** /**
@ -555,8 +638,10 @@ export default class PeerClient {
return makeCommandResult({ return makeCommandResult({
name: talkChannel.getDisplayName(), name: talkChannel.getDisplayName(),
description: talkChannel.info.openLink?.description,
// TODO Find out why linkCoverURL is blank, despite having updated the channel!
photoURL: talkChannel.info.openLink?.linkCoverURL || null,
participants: Array.from(talkChannel.getAllUserInfo()), participants: Array.from(talkChannel.getAllUserInfo()),
// TODO Image
}) })
} }
@ -575,13 +660,23 @@ export default class PeerClient {
* @param {string} req.mxid * @param {string} req.mxid
* @param {ChannelProps} req.channel_props * @param {ChannelProps} req.channel_props
* @param {?Long} req.sync_from * @param {?Long} req.sync_from
* @param {?Number} req.limit * @param {?number} req.limit
*/ */
getChats = async (req) => { getChats = async (req) => {
const talkChannel = await this.#getUserChannel(req.mxid, req.channel_props) const talkChannel = await this.#getUserChannel(req.mxid, req.channel_props)
const res = await talkChannel.getChatListFrom(req.sync_from) const res = await talkChannel.getChatListFrom(req.sync_from)
if (res.success && 0 < req.limit && req.limit < res.result.length) { if (!res.success) return res
while (true) {
const nextRes = await talkChannel.getChatListFrom(
res.result[res.result.length - 1].logId
)
if (!nextRes.success) return nextRes
if (!nextRes.result.length) break
res.result.push(...nextRes.result)
}
if (0 < req.limit && req.limit < res.result.length) {
res.result.splice(0, res.result.length - req.limit) res.result.splice(0, res.result.length - req.limit)
} }
return res return res
@ -695,6 +790,74 @@ export default class PeerClient {
}) })
} }
/**
* @param {Object} req
* @param {string} req.mxid
* @param {ChannelProps} req.channel_props
* @param {Long} req.user_id
* @param {OpenChannelUserPerm} req.perm
*/
sendPerm = async (req) => {
const talkChannel = await this.#getUserChannel(
req.mxid,
req.channel_props,
[OpenChannelUserPerm.OWNER],
"change user permissions"
)
return await talkChannel.setUserPerm({ userId: req.user_id }, req.perm)
}
/**
* @param {Object} req
* @param {string} req.mxid
* @param {ChannelProps} req.channel_props
* @param {string} req.name
*/
setChannelName = async (req) => {
const talkChannel = await this.#getUserChannel(
req.mxid,
req.channel_props,
[OpenChannelUserPerm.OWNER],
"change channel name"
)
return await talkChannel.setTitleMeta(req.name)
}
/**
* @param {Object} req
* @param {string} req.mxid
* @param {ChannelProps} req.channel_props
* @param {string} req.description
*/
setChannelDescription = async (req) => {
const talkChannel = await this.#getUserChannel(
req.mxid,
req.channel_props,
[OpenChannelUserPerm.OWNER],
"change channel description"
)
return await talkChannel.setNoticeMeta(req.description)
}
/**
* @param {Object} req
* @param {string} req.mxid
* @param {ChannelProps} req.channel_props
* @param {string} req.photo_url
*/
setChannelPhoto = async (req) => {
const talkChannel = await this.#getUserChannel(
req.mxid,
req.channel_props,
[OpenChannelUserPerm.OWNER],
"change channel photo"
)
return await talkChannel.setProfileMeta({
imageUrl: req.photo_url,
fullImageUrl: req.photo_url,
})
}
handleUnknownCommand = () => { handleUnknownCommand = () => {
throw new Error("Unknown command") throw new Error("Unknown command")
} }
@ -773,6 +936,10 @@ export default class PeerClient {
send_media: this.sendMedia, send_media: this.sendMedia,
delete_chat: this.deleteChat, delete_chat: this.deleteChat,
mark_read: this.markRead, mark_read: this.markRead,
send_perm: this.sendPerm,
set_channel_name: this.setChannelName,
set_channel_description: this.setChannelDescription,
set_channel_photo: this.setChannelPhoto,
}[req.command] || this.handleUnknownCommand }[req.command] || this.handleUnknownCommand
} }
const resp = { id: req.id } const resp = { id: req.id }
@ -789,7 +956,7 @@ export default class PeerClient {
} }
} else { } else {
resp.command = "error" resp.command = "error"
resp.error = err.toString() resp.error = err instanceof CustomError ? err.message : err.toString()
this.log(`Error handling request ${resp.id} ${err.stack}`) this.log(`Error handling request ${resp.id} ${err.stack}`)
// TODO Check if session is broken. If it is, close the PeerClient // TODO Check if session is broken. If it is, close the PeerClient
} }