From 770b0e447b88577107f6aebe0808812857a523a7 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Thu, 14 Apr 2022 03:45:01 -0400 Subject: [PATCH] Try outbound room title & description, and work on outbound room avatar But they fail with -203 (invalid body) --- .../kt/client/client.py | 33 ++++ matrix_appservice_kakaotalk/matrix.py | 32 ++-- matrix_appservice_kakaotalk/portal.py | 160 +++++++++++++++--- node/src/client.js | 56 +++++- 4 files changed, 232 insertions(+), 49 deletions(-) diff --git a/matrix_appservice_kakaotalk/kt/client/client.py b/matrix_appservice_kakaotalk/kt/client/client.py index 30e61a7..460d685 100644 --- a/matrix_appservice_kakaotalk/kt/client/client.py +++ b/matrix_appservice_kakaotalk/kt/client/client.py @@ -398,6 +398,39 @@ class Client: 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 diff --git a/matrix_appservice_kakaotalk/matrix.py b/matrix_appservice_kakaotalk/matrix.py index d9a5388..8d6757c 100644 --- a/matrix_appservice_kakaotalk/matrix.py +++ b/matrix_appservice_kakaotalk/matrix.py @@ -26,7 +26,6 @@ from mautrix.types import ( RedactionEvent, RoomID, SingleReceiptEventContent, - PowerLevelStateEventContent, StateEvent, UserID, ) @@ -162,25 +161,6 @@ class MatrixHandler(BaseMatrixHandler): if message: await user.client.mark_read(portal.channel_props, message.ktid) - @classmethod - async def handle_power_level( - cls, - room_id: RoomID, - user_id: UserID, - prev_content: PowerLevelStateEventContent, - content: PowerLevelStateEventContent, - event_id: EventID, - ) -> None: - user = await u.User.get_by_mxid(user_id) - if not user: - return - - portal = await po.Portal.get_by_mxid(room_id) - if not portal: - return - - await portal.handle_matrix_power_level(user, prev_content, content, event_id) - async def handle_ephemeral_event( self, evt: ReceiptEvent | Event ) -> None: @@ -198,5 +178,13 @@ class MatrixHandler(BaseMatrixHandler): """ async def handle_state_event(self, evt: StateEvent) -> None: - if evt.type == EventType.ROOM_POWER_LEVELS: - await self.handle_power_level(evt.room_id, evt.sender, evt.prev_content, evt.content, evt.event_id) + 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) diff --git a/matrix_appservice_kakaotalk/portal.py b/matrix_appservice_kakaotalk/portal.py index a0bcaaa..2b60641 100644 --- a/matrix_appservice_kakaotalk/portal.py +++ b/matrix_appservice_kakaotalk/portal.py @@ -20,6 +20,8 @@ from typing import ( Any, AsyncGenerator, Awaitable, + Callable, + NamedTuple, Pattern, cast, ) @@ -49,8 +51,13 @@ from mautrix.types import ( MessageEventContent, MessageType, PowerLevelStateEventContent, + RoomAvatarStateEventContent, + RoomNameStateEventContent, + RoomTopicStateEventContent, RelationType, RoomID, + StateEvent, + StateEventContent, TextMessageEventContent, UserID, VideoInfo, @@ -118,6 +125,14 @@ class FakeLock: 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) StateHalfShotBridge = EventType.find("uk.half-shot.bridge", EventType.Class.STATE) @@ -199,8 +214,7 @@ class Portal(DBPortal, BasePortal): NotificationDisabler.puppet_cls = p.Puppet 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.TEXT: cls._handle_kakaotalk_text, KnownChatType.REPLY: cls._handle_kakaotalk_reply, @@ -212,6 +226,33 @@ class Portal(DBPortal, BasePortal): 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 async def delete(self) -> None: @@ -1044,51 +1085,50 @@ class Portal(DBPortal, BasePortal): pass """ - async def handle_matrix_power_level( - self, - sender: u.User, - prev_content: PowerLevelStateEventContent, - content: PowerLevelStateEventContent, - event_id: EventID, - ) -> None: + async def handle_matrix_state_event(self, sender: u.User, evt: StateEvent) -> None: try: - await self._handle_matrix_power_level(sender, prev_content, content, event_id) + 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 power level {event_id}: {e}", + 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), - event_id, + evt.event_id, self.mxid, - EventType.ROOM_POWER_LEVELS, + evt.type, error=e, ) - if not isinstance(e, NotImplementedError): - await self._send_bridge_error( - f"{e}. Reverting the power level change...", - thing="power level change" - ) + 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 self.main_intent.set_power_levels(self.mxid, prev_content) + await handler.revert(self, evt.prev_content) else: - await self._send_delivery_receipt(event_id) + await self._send_delivery_receipt(evt.event_id) sender.send_remote_checkpoint( MessageSendCheckpointStatus.SUCCESS, - event_id, + evt.event_id, self.mxid, - EventType.ROOM_POWER_LEVELS, + evt.type, ) - async def _handle_matrix_power_level( + async def _handle_matrix_power_levels( self, sender: u.User, prev_content: PowerLevelStateEventContent, content: PowerLevelStateEventContent, - event_id: EventID, ) -> None: - sender, _ = await self.get_relay_sender(sender, f"power level {event_id}") for target_mxid, power_level in content.users.items(): if power_level == prev_content.get_user_level(target_mxid): continue @@ -1102,6 +1142,74 @@ class Portal(DBPortal, BasePortal): "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: if self.is_direct: self.log.info(f"{user.mxid} left private chat portal with {self.ktid}") @@ -1204,7 +1312,7 @@ class Portal(DBPortal, BasePortal): await intent.ensure_joined(self.mxid) 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_id for event_id in await handler( diff --git a/node/src/client.js b/node/src/client.js index 3920eef..4f5322b 100644 --- a/node/src/client.js +++ b/node/src/client.js @@ -519,7 +519,7 @@ export default class PeerClient { const talkChannel = await userClient.getChannel(channelProps) if (permNeeded) { const permActual = talkChannel.getUserInfo({ userId: userClient.userId }).perm - if (!(permActual in permNeeded)) { + if (permNeeded.indexOf(permActual) == -1) { throw new PermError(permNeeded, permActual, action) } } @@ -807,6 +807,57 @@ export default class PeerClient { 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 = () => { throw new Error("Unknown command") } @@ -886,6 +937,9 @@ export default class PeerClient { delete_chat: this.deleteChat, 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 } const resp = { id: req.id }