From ecb04fc2f5ddcf46af86a55f5262c87e911aef77 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Wed, 13 Apr 2022 01:12:56 -0400 Subject: [PATCH] Inbound permissions & outbound power levels Note that these only apply to OpenChannels --- ROADMAP.md | 4 +- .../kt/client/client.py | 23 +++++++ .../kt/client/types.py | 23 +++++++ matrix_appservice_kakaotalk/matrix.py | 46 +++++++++++++- matrix_appservice_kakaotalk/portal.py | 60 ++++++++++++++++++- matrix_appservice_kakaotalk/user.py | 23 ++++++- node/src/client.js | 29 +++++++++ 7 files changed, 202 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 7203e8f..0c21e13 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -22,7 +22,7 @@ * [x] Message redactions[1] * [ ] Message reactions * [x] Read receipts - * [ ] Power level + * [x] Power level * [ ] Membership actions * [ ] Invite * [ ] Kick @@ -52,7 +52,7 @@ * [ ] Read receipts * [ ] On backfill * [x] On live event - * [ ] Admin status + * [x] Admin status * [x] Membership actions * [x] Add member * [x] Remove member diff --git a/matrix_appservice_kakaotalk/kt/client/client.py b/matrix_appservice_kakaotalk/kt/client/client.py index 2945e70..bcafa85 100644 --- a/matrix_appservice_kakaotalk/kt/client/client.py +++ b/matrix_appservice_kakaotalk/kt/client/client.py @@ -46,6 +46,7 @@ from ..types.chat import Chatlog, KnownChatType from ..types.chat.attachment import MentionStruct, ReplyAttachment from ..types.client.client_session import LoginResult 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.packet.chat.kickout import KnownKickoutType, KickoutRes from ..types.request import ( @@ -384,6 +385,19 @@ class Client: 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, + ) + # TODO Combine these into one @@ -445,6 +459,14 @@ class Client: OpenLinkChannelUserInfo.deserialize(data["info"]), ) + async def _on_perm_changed(self, data: dict[str, JSON]) -> None: + await self.user.on_perm_changed( + Long.deserialize(data["userId"]), + OpenChannelUserPerm(data["perm"]), + Long.deserialize(data["channelId"]), + str(data["channelType"]), + ) + async def _on_channel_join(self, data: dict[str, JSON]) -> None: await self.user.on_channel_join( ChannelInfo.deserialize(data["channelInfo"]), @@ -504,6 +526,7 @@ class Client: self._add_event_handler("chat_deleted", self._on_chat_deleted) self._add_event_handler("chat_read", self._on_chat_read) 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_left", self._on_channel_left) self._add_event_handler("channel_kicked", self._on_channel_kicked) diff --git a/matrix_appservice_kakaotalk/kt/client/types.py b/matrix_appservice_kakaotalk/kt/client/types.py index 6b2cb91..e782137 100644 --- a/matrix_appservice_kakaotalk/kt/client/types.py +++ b/matrix_appservice_kakaotalk/kt/client/types.py @@ -32,6 +32,7 @@ from ..types.channel.channel_info import NormalChannelInfo from ..types.channel.channel_type import ChannelType from ..types.chat import KnownChatType 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 @@ -91,3 +92,25 @@ TO_MSGTYPE_MAP: dict[MessageType, KnownChatType] = { # https://stackoverflow.com/a/483833 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 diff --git a/matrix_appservice_kakaotalk/matrix.py b/matrix_appservice_kakaotalk/matrix.py index dc8a80b..85b90ca 100644 --- a/matrix_appservice_kakaotalk/matrix.py +++ b/matrix_appservice_kakaotalk/matrix.py @@ -26,10 +26,15 @@ from mautrix.types import ( RedactionEvent, RoomID, SingleReceiptEventContent, + PowerLevelStateEventContent, + StateEvent, UserID, ) -from . import portal as po, user as u +from .kt.client.errors import CommandException +from .kt.client.types import TO_PERM_MAP + +from . import portal as po, puppet as pu, user as u from .db import Message as DBMessage if TYPE_CHECKING: @@ -135,7 +140,7 @@ class MatrixHandler(BaseMatrixHandler): ) return user = await u.User.get_by_mxid(user_id) - if not user: + if not user or not user.is_connected: return portal = await po.Portal.get_by_mxid(room_id) @@ -160,6 +165,39 @@ 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, + ) -> None: + user = await u.User.get_by_mxid(user_id) + if not user or not user.is_connected: + return + + portal = await po.Portal.get_by_mxid(room_id) + if not portal: + return + + for target_mxid, power_level in content.users.items(): + if power_level == prev_content.get_user_level(target_mxid): + continue + puppet = await pu.Puppet.get_by_mxid(target_mxid) + if puppet: + perm = TO_PERM_MAP.get(power_level) + try: + await user.client.send_perm(portal.channel_props, puppet.ktid, perm) + except CommandException: + cls.log.exception( + "Failed to handle power level change (%d->%d) for puppet user %s, so changing it back", + prev_content.get_user_level(target_mxid), + power_level, + target_mxid, + ) + await portal.main_intent.set_power_levels(room_id, prev_content) + async def handle_ephemeral_event( self, evt: ReceiptEvent | Event ) -> None: @@ -175,3 +213,7 @@ class MatrixHandler(BaseMatrixHandler): evt: ReactionEvent await self.handle_reaction(evt.room_id, evt.sender, evt.event_id, evt.content) """ + + 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) diff --git a/matrix_appservice_kakaotalk/portal.py b/matrix_appservice_kakaotalk/portal.py index 9af624b..905a96a 100644 --- a/matrix_appservice_kakaotalk/portal.py +++ b/matrix_appservice_kakaotalk/portal.py @@ -48,6 +48,7 @@ from mautrix.types import ( Membership, MessageEventContent, MessageType, + PowerLevelStateEventContent, RelationType, RoomID, TextMessageEventContent, @@ -80,12 +81,15 @@ from .kt.types.chat.attachment import ( ReplyAttachment, VideoAttachment, ) +from .kt.types.user.channel_user_info import OpenChannelUserInfo +from .kt.types.openlink.open_link_type import OpenChannelUserPerm from .kt.client.types import ( UserInfoUnion, PortalChannelInfo, ChannelProps, TO_MSGTYPE_MAP, + FROM_PERM_MAP, ) from .kt.client.errors import CommandException @@ -234,6 +238,10 @@ class Portal(DBPortal, BasePortal): def is_direct(self) -> bool: return KnownChannelType.is_direct(self.kt_type) + @property + def is_open(self) -> bool: + return KnownChannelType.is_open(self.kt_type) + @property def kt_sender(self) -> int | None: if self.is_direct: @@ -317,8 +325,48 @@ class Portal(DBPortal, BasePortal): if changed or force_save: await self.update_bridge_info() await self.save() + if self.mxid and self.is_open: + user_power_levels = await self._get_mapped_participant_power_levels(info.participants, skip_default=False) + await self.set_power_levels(user_power_levels) 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 async def _reupload_kakaotalk_file( cls, @@ -632,7 +680,8 @@ class Portal(DBPortal, BasePortal): "content": self.bridge_info, }, ] - if KnownChannelType.is_open(info.channel_info.type): + + if self.is_open: initial_state.extend(( { "type": str(EventType.ROOM_JOIN_RULES), @@ -643,6 +692,15 @@ class Portal(DBPortal, BasePortal): "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 = [] if self.config["bridge.encryption.default"] and self.matrix.e2ee: self.encrypted = True diff --git a/matrix_appservice_kakaotalk/user.py b/matrix_appservice_kakaotalk/user.py index 3bfdbac..e092347 100644 --- a/matrix_appservice_kakaotalk/user.py +++ b/matrix_appservice_kakaotalk/user.py @@ -39,7 +39,7 @@ from .db import User as DBUser from .kt.client import Client from .kt.client.errors import AuthenticationRequired, ResponseError -from .kt.client.types import SettingsStruct +from .kt.client.types import SettingsStruct, FROM_PERM_MAP from .kt.types.bson import Long from .kt.types.channel.channel_info import ChannelInfo, NormalChannelInfo, NormalChannelData 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.oauth import OAuthCredential 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.packet.chat.kickout import KnownKickoutType, KickoutRes @@ -55,6 +56,7 @@ 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_READ = Summary("bridge_on_chat_read", "calls to on_chat_read") 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_LEFT = Summary("bridge_on_channel_left", "calls to on_channel_left") METRIC_CHANNEL_KICKED = Summary("bridge_on_channel_kicked", "calls to on_channel_kicked") @@ -745,6 +747,25 @@ class User(DBUser, BaseUser): if puppet: 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) def on_channel_join(self, channel_info: ChannelInfo) -> Awaitable[None]: return self._sync_channel(channel_info) diff --git a/node/src/client.js b/node/src/client.js index 888358e..36858cf 100644 --- a/node/src/client.js +++ b/node/src/client.js @@ -27,6 +27,7 @@ import { /** @typedef {import("node-kakao").ChannelType} ChannelType */ /** @typedef {import("node-kakao").ReplyAttachment} ReplyAttachment */ /** @typedef {import("node-kakao").MentionStruct} MentionStruct */ +/** @typedef {import("node-kakao").OpenChannelUserPerm} OpenChannelUserPerm */ /** @typedef {import("node-kakao/dist/talk").TalkChannelList} TalkChannelList */ import chat from "node-kakao/chat" @@ -135,6 +136,18 @@ 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}`) + return 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.log(`Joined channel ${channel.channelId}`) return this.write("channel_join", { @@ -695,6 +708,21 @@ 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) => { + if (!isChannelTypeOpen(req.channel_props.type)) { + throw Error("Can't send perm on non-open channel") + } + const talkChannel = await this.#getUserChannel(req.mxid, req.channel_props) + return await talkChannel.setUserPerm({ userId: req.user_id }, req.perm) + } + handleUnknownCommand = () => { throw new Error("Unknown command") } @@ -773,6 +801,7 @@ export default class PeerClient { send_media: this.sendMedia, delete_chat: this.deleteChat, mark_read: this.markRead, + send_perm: this.sendPerm, }[req.command] || this.handleUnknownCommand } const resp = { id: req.id }