diff --git a/matrix_appservice_kakaotalk/kt/client/client.py b/matrix_appservice_kakaotalk/kt/client/client.py index fb07e36..38f6873 100644 --- a/matrix_appservice_kakaotalk/kt/client/client.py +++ b/matrix_appservice_kakaotalk/kt/client/client.py @@ -38,8 +38,13 @@ from mautrix.util.logging import TraceLogger from ...config import Config from ...rpc import EventHandler, RPCClient -from ..types.api.struct import FriendListStruct -from ..types.api.struct.profile import ProfileReqStruct, ProfileStruct +from ..types.api.struct import ( + FriendListStruct, + FriendReqStruct, + FriendStruct, + ProfileReqStruct, + ProfileStruct, +) from ..types.bson import Long from ..types.channel.channel_info import ChannelInfo from ..types.chat import Chatlog, KnownChatType @@ -60,6 +65,7 @@ from ..types.request import ( from .types import ( ChannelProps, PortalChannelInfo, + PortalChannelParticipantInfo, SettingsStruct, UserInfoUnion, ) @@ -276,6 +282,13 @@ class Client: channel_props=channel_props.serialize(), ) + def get_portal_channel_participant_info(self, channel_props: ChannelProps) -> Awaitable[PortalChannelParticipantInfo]: + return self._api_user_request_result( + PortalChannelParticipantInfo, + "get_portal_channel_participant_info", + channel_props=channel_props.serialize(), + ) + def get_participants(self, channel_props: ChannelProps) -> Awaitable[list[UserInfoUnion]]: return self._api_user_request_result( ResultListType(UserInfoUnion), @@ -511,6 +524,7 @@ class Client: return self.user.on_perm_changed( Long.deserialize(data["userId"]), OpenChannelUserPerm(data["perm"]), + Long.deserialize(data["senderId"]), Long.deserialize(data["channelId"]), str(data["channelType"]), ) diff --git a/matrix_appservice_kakaotalk/kt/client/types.py b/matrix_appservice_kakaotalk/kt/client/types.py index 6c41a09..983fbdc 100644 --- a/matrix_appservice_kakaotalk/kt/client/types.py +++ b/matrix_appservice_kakaotalk/kt/client/types.py @@ -66,12 +66,17 @@ def deserialize_user_info_union(data: JSON) -> UserInfoUnion: setattr(UserInfoUnion, "deserialize", deserialize_user_info_union) +@dataclass +class PortalChannelParticipantInfo(SerializableAttrs): + participants: list[UserInfoUnion] + kickedUserIds: list[Long] + @dataclass class PortalChannelInfo(SerializableAttrs): name: str description: Optional[str] = None photoURL: Optional[str] = None - participants: Optional[list[UserInfoUnion]] = None # May set to None to skip participant update + participantInfo: Optional[PortalChannelParticipantInfo] = None # May set to None to skip participant update channel_info: Optional[ChannelInfoUnion] = None # Should be set manually by caller @@ -96,22 +101,22 @@ FROM_MSGTYPE_MAP: dict[KnownChatType, MessageType] = {v: k for k, v in TO_MSGTYP # TODO Consider allowing custom power/perm mappings +# But must update default user level & permissions to match! 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 + OpenChannelUserPerm.BOT: 25, + OpenChannelUserPerm.NONE: 0 } -# NOTE Using a class to make it look like a dict +# NOTE Using a class to make this look like a dict class TO_PERM_MAP: @staticmethod - def get(key: int, default: Optional[OpenChannelUserPerm] = None) -> OpenChannelUserPerm: + def get(key: int) -> OpenChannelUserPerm: if key >= 100: return OpenChannelUserPerm.OWNER - elif key >= 50: + if key >= 50: return OpenChannelUserPerm.MANAGER - else: - return default or OpenChannelUserPerm.NONE + return OpenChannelUserPerm.NONE diff --git a/matrix_appservice_kakaotalk/portal.py b/matrix_appservice_kakaotalk/portal.py index 7b927f3..ac760c5 100644 --- a/matrix_appservice_kakaotalk/portal.py +++ b/matrix_appservice_kakaotalk/portal.py @@ -88,12 +88,13 @@ 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.types.user.channel_user_info import OpenChannelUserInfo from .kt.client.types import ( UserInfoUnion, PortalChannelInfo, + PortalChannelParticipantInfo, ChannelProps, TO_MSGTYPE_MAP, FROM_PERM_MAP, @@ -356,7 +357,7 @@ class Portal(DBPortal, BasePortal): force_save: bool = False, ) -> PortalChannelInfo: if not info: - self.log.debug("Called update_info with no info, fetching channel info...") + self.log.debug("Called update_info with no info, fetching it now...") info = await source.client.get_portal_channel_info(self.channel_props) changed = False if not self.is_direct: @@ -367,31 +368,25 @@ class Portal(DBPortal, BasePortal): self._update_photo(source, info.photoURL), ) ) - 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 info.participantInfo: + changed = await self._update_participants(source, info.participantInfo) or changed if changed or force_save: await self.update_bridge_info() await self.save() return info - async def _get_mapped_participant_power_levels(self, participants: list[UserInfoUnion], skip_default: bool) -> dict[UserID, int]: + async def _get_mapped_participant_power_levels(self, participants: list[UserInfoUnion]) -> 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) + await self._update_mapped_ktid_power_levels(user_power_levels, participant.userId, participant.perm) return user_power_levels @staticmethod - async def update_mapped_ktid_power_levels(user_power_levels: dict[UserID, int], ktid: int, power_level: int) -> None: + async def _update_mapped_ktid_power_levels(user_power_levels: dict[UserID, int], ktid: int, perm: OpenChannelUserPerm) -> None: + power_level = FROM_PERM_MAP[perm] user = await u.User.get_by_ktid(ktid) if user: user_power_levels[user.mxid] = power_level @@ -399,20 +394,46 @@ class Portal(DBPortal, BasePortal): 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 + async def _set_user_power_levels(self, sender: p.Puppet | None, user_power_levels: dict[UserID, int]) -> None: + if not self.mxid: + return + orig_power_levels = await self.main_intent.get_power_levels(self.mxid) + user_power_levels = {k: v for k, v in user_power_levels.items() if orig_power_levels.get_user_level(k) != v} + if not user_power_levels: + return + joined_puppets = { + puppet.mxid: puppet for puppet in [ + await p.Puppet.get_by_custom_mxid(mxid) or await p.Puppet.get_by_mxid(mxid) + for mxid in await self.main_intent.get_room_members(self.mxid) + ] if puppet + } + sender_intent = sender.intent_for(self) if sender else self.main_intent + admin_level = orig_power_levels.get_user_level(sender_intent.mxid) + demoter_ids: list[UserID] = [] + power_levels = PowerLevelStateEventContent(**orig_power_levels.serialize()) + for user_id, new_level in user_power_levels.items(): + curr_level = orig_power_levels.get_user_level(user_id) + if curr_level < admin_level or user_id == sender_intent.mxid: + # TODO Consider capping the power level here, instead of letting the attempt fail later + power_levels.set_user_level(user_id, new_level) + elif user_id in joined_puppets: + demoter_ids.append(user_id) + else: + # This is either a non-joined puppet or a non-puppet user + self.log.warning(f"Can't change power level of more powerful user {user_id}") + try: + await sender_intent.set_power_levels(self.mxid, power_levels) + except: + self.log.exception("Failed to set power level") + if demoter_ids: + power_levels = PowerLevelStateEventContent(**orig_power_levels.serialize()) + for demoter_id in demoter_ids: + power_levels.set_user_level(demoter_id, user_power_levels[demoter_id]) + try: + await joined_puppets[demoter_id].intent_for(self).set_power_levels(self.mxid, power_levels) + except: + self.log.exception("Failed to set power level") + power_levels.set_user_level(demoter_id, orig_power_levels[demoter_id]) @classmethod async def _reupload_kakaotalk_file( @@ -593,16 +614,72 @@ class Portal(DBPortal, BasePortal): # await self.sync_per_room_nick(puppet, nick_map[puppet.ktid]) return changed - - async def _update_participants(self, source: u.User, participants: list[UserInfoUnion] | None = None) -> bool: + async def _update_participants( + self, + source: u.User, + participant_info: PortalChannelParticipantInfo | None = None, + ) -> bool: + # NOTE This handles only non-logged-in users, because logged-in users should be handled by the channel list listeners # TODO nick map? - if participants is None: - self.log.debug("Called _update_participants with no participants, fetching them now...") - participants = await source.client.get_participants(self.channel_props) - sync_tasks = [ - self._update_participant(source, pcp) for pcp in participants - ] - changed = any(await asyncio.gather(*sync_tasks)) + if participant_info is None: + self.log.debug("Called _update_participants with no participant info, fetching it now...") + participant_info = await source.client.get_portal_channel_participant_info(self.channel_props) + if self.mxid: + # NOTE KakaoTalk kick = Matrix ban + prev_banned_mxids = { + cast(UserID, event.state_key) + for event in await self.main_intent.get_members(self.mxid, membership=Membership.BAN) + } + results = await asyncio.gather(*[ + self.handle_kakaotalk_user_left(source, None, puppet) + for puppet in [ + await p.Puppet.get_by_ktid(ktid) + for ktid in participant_info.kickedUserIds + ] + if puppet and puppet.mxid not in prev_banned_mxids + ], return_exceptions=True) + for e in filter(lambda x: isinstance(x, Exception), results): + self.log.exception(e) + + joined_ktids = {pcp.userId for pcp in participant_info.participants} + results = await asyncio.gather(*[ + self.handle_kakaotalk_user_left(source, puppet, puppet) + for puppet in [ + await p.Puppet.get_by_mxid(mxid) + for mxid in await self.main_intent.get_room_members(self.mxid) + ] + if puppet and puppet.ktid not in joined_ktids + ], return_exceptions=True) + for e in filter(lambda x: isinstance(x, Exception), results): + self.log.exception(e) + + kicked_ktids = set(participant_info.kickedUserIds) + results = await asyncio.gather(*[ + self.handle_kakaotalk_user_unkick(source, None, puppet) + for puppet in [ + await p.Puppet.get_by_mxid(mxid) + for mxid in prev_banned_mxids + ] + if puppet and puppet.ktid not in kicked_ktids + ], return_exceptions=True) + for e in filter(lambda x: isinstance(x, Exception), results): + self.log.exception(e) + + changed = False + results = await asyncio.gather(*[ + self._update_participant(source, pcp) for pcp in participant_info.participants + ], return_exceptions=True) + for result in results: + if isinstance(result, Exception): + self.log.exception(result) + else: + changed = result or changed + + if self.mxid and self.is_open: + # TODO Find whether perms apply to any non-direct channel, or just open ones + user_power_levels = await self._get_mapped_participant_power_levels(participant_info.participants) + await self._set_user_power_levels(None, user_power_levels) + return changed # endregion @@ -625,8 +702,6 @@ class Portal(DBPortal, BasePortal): async def _update_matrix_room( self, source: u.User, info: PortalChannelInfo | None = None ) -> None: - info = await self.update_info(source, info) - puppet = await p.Puppet.get_by_custom_mxid(source.mxid) await self.main_intent.invite_user( self.mxid, @@ -639,6 +714,8 @@ class Portal(DBPortal, BasePortal): if did_join and self.is_direct: await source.update_direct_chats({self.main_intent.mxid: [self.mxid]}) + info = await self.update_info(source, info) + # TODO Sync read receipts? """ @@ -725,7 +802,7 @@ class Portal(DBPortal, BasePortal): self.log.debug(f"Creating Matrix room") if self.is_direct: # NOTE Must do this to find the other member of the DM, since the channel ID != the member's ID! - await self._update_participants(source, info.participants) + await self._update_participants(source, info.participantInfo) name: str | None = None initial_state = [ { @@ -752,8 +829,10 @@ 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) + # TODO Find whether perms apply to any non-direct channel, or just open ones + user_power_levels = await self._get_mapped_participant_power_levels(info.participantInfo.participants) + # NOTE Giving the bot a +1 power level if necessary so it can demote non-puppet admins + user_power_levels[self.main_intent.mxid] = max(100, 1 + FROM_PERM_MAP[OpenChannelUserPerm.OWNER]) initial_state.append( { "type": str(EventType.ROOM_POWER_LEVELS), @@ -827,7 +906,7 @@ class Portal(DBPortal, BasePortal): if not self.is_direct: # NOTE Calling this after room creation to invite participants - await self._update_participants(source, info.participants) + await self._update_participants(source, info.participantInfo) try: await self.backfill(source, is_initial=True, channel_info=info.channel_info) @@ -1132,21 +1211,55 @@ class Portal(DBPortal, BasePortal): 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): + ktid_perms: dict[Long, OpenChannelUserPerm] = {} + user_power_levels: dict[UserID, int] = {} + for user_id, level in content.users.items(): + if level == prev_content.get_user_level(user_id): continue - puppet = await p.Puppet.get_by_mxid(target_mxid) + ktid = None + mxid = None + puppet = await p.Puppet.get_by_mxid(user_id) + user = await u.User.get_by_mxid(user_id) if not puppet else None 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" - ) + ktid = puppet.ktid + user = await u.User.get_by_ktid(ktid) + if user: + mxid = user.mxid + elif user: + ktid = user.ktid + puppet = await p.Puppet.get_by_ktid(ktid) + if puppet: + mxid = puppet.mxid + if ktid is not None: + ktid_perms[ktid] = TO_PERM_MAP.get(level) + if mxid is not None: + user_power_levels[mxid] = level + if ktid_perms: + if sender and sender.is_connected: + if user_power_levels: + await self._set_user_power_levels(await sender.get_puppet(), user_power_levels) + ok = True + results = await asyncio.gather(*[ + sender.client.send_perm(self.channel_props, ktid, perm) + for ktid, perm in ktid_perms.items() + ], return_exceptions=True) + for e in filter(lambda x: isinstance(x, Exception), results): + ok = False + self.log.exception(e) + if not ok: + self.log.info("Failed to send all perms, so re-syncing all participant info") + await self._update_participants(sender) + 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) + managed_power_levels: dict[UserID, int] = {} + for user_id, level in prev_content.users.items(): + if await p.Puppet.get_by_mxid(user_id) or await u.User.get_by_mxid(user_id): + managed_power_levels[user_id] = level + await self._set_user_power_levels(None, managed_power_levels) async def _handle_matrix_room_name( self, @@ -1571,25 +1684,67 @@ class Portal(DBPortal, BasePortal): f"Handled KakaoTalk read receipt from {sender.ktid} up to {chat_id}/{msg.mxid}" ) + async def handle_kakaotalk_perm_changed( + self, source: u.User, sender: p.Puppet, user_id: Long, perm: OpenChannelUserPerm + ) -> None: + user_power_levels: dict[UserID, int] = {} + await self._update_mapped_ktid_power_levels(user_power_levels, user_id, perm) + await self._set_user_power_levels(sender, user_power_levels) + async def handle_kakaotalk_user_join( self, source: u.User, user: p.Puppet ) -> None: - await self.main_intent.ensure_joined(self.mxid) + # TODO Check if a KT user can join as an admin / room owner + await self.main_intent.invite_user(self.mxid, user.mxid) + await user.intent_for(self).join_room_by_id(self.mxid) if not user.name: self.schedule_resync(source, user) async def handle_kakaotalk_user_left( - self, source: u.User, sender: p.Puppet, removed: p.Puppet + self, source: u.User, sender: p.Puppet | None, removed: p.Puppet ) -> None: + sender_intent = sender.intent_for(self) if sender else self.main_intent if sender == removed: await removed.intent_for(self).leave_room(self.mxid) + if not removed.is_real_user: + user = await u.User.get_by_ktid(removed.ktid) + if user: + await sender_intent.kick_user(self.mxid, user.mxid, "Left channel from KakaoTalk") else: + for removed_mxid in (r.mxid for r in ( + removed, + await u.User.get_by_ktid(removed.ktid) if not removed.is_real_user else None + ) if r): + try: + await sender_intent.ban_user( + self.mxid, removed_mxid, None if sender else "Kicked by channel admin" + ) + except MForbidden: + if not sender: + raise + await self.main_intent.ban_user( + self.mxid, removed_mxid, reason=f"Kicked by {sender.name}" + ) + + # TODO Find when or if there is a listener for this + # TODO Confirm whether this can refer to any user that was kicked, or only to the current user + async def handle_kakaotalk_user_unkick( + self, source: u.User, sender: p.Puppet | None, unkicked: p.Puppet + ) -> None: + assert sender != unkicked, f"Puppet for {unkicked.mxid} tried to unkick itself" + sender_intent = sender.intent_for(self) if sender else self.main_intent + for unkicked_mxid in (r.mxid for r in ( + unkicked, + await u.User.get_by_ktid(unkicked.ktid) if not unkicked.is_real_user else None + ) if r): + # NOTE KakaoTalk kick = Matrix ban try: - await sender.intent_for(self).kick_user(self.mxid, removed.mxid) + await sender_intent.unban_user(self.mxid, unkicked_mxid) except MForbidden: - await self.main_intent.kick_user( - self.mxid, removed.mxid, reason=f"Kicked by {sender.name}" - ) + if not sender: + raise + await self.main_intent.unban_user(self.mxid, unkicked_mxid) + # endregion diff --git a/matrix_appservice_kakaotalk/user.py b/matrix_appservice_kakaotalk/user.py index 8862fbb..3d93d5a 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 PortalChannelInfo, SettingsStruct, FROM_PERM_MAP +from .kt.client.types import PortalChannelInfo, SettingsStruct 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 @@ -441,10 +441,10 @@ class User(DBUser, BaseUser): if not sync_count: self.log.debug("Skipping channel syncing") return + # TODO Sync left channels. It's not login_result.removedChannelIdList... if not login_result.channelList: self.log.debug("No channels to sync") return - # TODO What about removed channels? Don't early-return then num_channels = len(login_result.channelList) sync_count = num_channels if sync_count < 0 else min(sync_count, num_channels) @@ -776,6 +776,7 @@ class User(DBUser, BaseUser): self, user_id: Long, perm: OpenChannelUserPerm, + sender_id: Long, channel_id: Long, channel_type: ChannelType, ) -> None: @@ -787,9 +788,9 @@ class User(DBUser, BaseUser): 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) + sender = await pu.Puppet.get_by_ktid(sender_id) + await portal.backfill_lock.wait("perm changed") + await portal.handle_kakaotalk_perm_changed(self, sender, user_id, perm) @async_time(METRIC_CHANNEL_ADDED) def on_channel_added(self, channel_info: ChannelInfo) -> Awaitable[None]: diff --git a/node/src/client.js b/node/src/client.js index 426416c..079732c 100644 --- a/node/src/client.js +++ b/node/src/client.js @@ -27,7 +27,11 @@ import { /** @typedef {import("node-kakao").ChannelType} ChannelType */ /** @typedef {import("node-kakao").ReplyAttachment} ReplyAttachment */ /** @typedef {import("node-kakao").MentionStruct} MentionStruct */ +/** @typedef {import("node-kakao").TalkNormalChannel} TalkNormalChannel */ +/** @typedef {import("node-kakao").TalkOpenChannel} TalkOpenChannel */ /** @typedef {import("node-kakao/dist/talk").TalkChannelList} TalkChannelList */ +// TODO Remove once/if some helper type hints are upstreamed +/** @typedef {import("node-kakao").OpenChannelUserInfo} OpenChannelUserInfo */ import openlink from "node-kakao/openlink" const { OpenChannelUserPerm } = openlink @@ -61,9 +65,9 @@ ServiceApiClient.prototype.requestFriendList = async function() { } -class CustomError extends Error {} +class ProtocolError extends Error {} -class PermError extends CustomError { +class PermError extends ProtocolError { /** @type {Map */ static #PERM_NAMES = new Map([ [OpenChannelUserPerm.OWNER, "the channel owner"], @@ -175,13 +179,20 @@ 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.#talkClient.on("perm_changed", + /** + * TODO Upstream these type hints + * @param {TalkOpenChannel} channel + * @param {OpenChannelUserInfo} lastInfo + * @param {OpenChannelUserInfo} user + */ + (channel, lastInfo, user) => { 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, + senderId: getChannelOwner().userId, channelId: channel.channelId, channelType: channel.info.type, }) @@ -697,10 +708,48 @@ export default class PeerClient { 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()), + participantInfo: { + // TODO Get members from chatON? + participants: Array.from(talkChannel.getAllUserInfo()), + kickedUserIds: await this.#getKickedUserIds(talkChannel), + }, }) } + /** + * @param {Object} req + * @param {string} req.mxid + * @param {ChannelProps} req.channel_props + */ + getPortalChannelParticipantInfo = async (req) => { + const talkChannel = await this.#getUserChannel(req.mxid, req.channel_props) + + // TODO Get members from chatON? + const participantRes = await talkChannel.getAllLatestUserInfo() + if (!participantRes.success) return participantRes + + return { + participants: participantRes.result, + kickedUserIds: await this.#getKickedUserIds(talkChannel), + } + } + + /** + * @param {TalkNormalChannel | TalkOpenChannel} talkChannel + */ + async #getKickedUserIds(talkChannel) { + if (!isChannelTypeOpen(talkChannel.info.type)) { + return [] + } else { + const kickListRes = await talkChannel.getKickList() + if (!kickListRes.success) { + return [] + } else { + return kickListRes.result.map(kickUser => kickUser.userId) + } + } + } + /** * @param {Object} req * @param {string} req.mxid @@ -860,7 +909,14 @@ export default class PeerClient { [OpenChannelUserPerm.OWNER], "change user permissions" ) - return await talkChannel.setUserPerm({ userId: req.user_id }, req.perm) + const user = { userId: req.user_id } + if (!talkChannel.getUserInfo(user)) { + throw new ProtocolError("Cannot set permission level of a user that is not a channel participant") + } + if (req.user_id == talkChannel.clientUser.userId) { + throw new ProtocolError("Cannot change own permission level") + } + return await talkChannel.setUserPerm(user, req.perm) } /** @@ -986,6 +1042,7 @@ export default class PeerClient { get_own_profile: this.getOwnProfile, get_profile: this.getProfile, get_portal_channel_info: this.getPortalChannelInfo, + get_portal_channel_participant_info: this.getPortalChannelParticipantInfo, get_participants: this.getParticipants, get_chats: this.getChats, list_friends: this.listFriends, @@ -1015,7 +1072,7 @@ export default class PeerClient { } } else { resp.command = "error" - resp.error = err instanceof CustomError ? err.message : err.toString() + resp.error = err instanceof ProtocolError ? err.message : err.toString() this.log(`Error handling request ${resp.id} ${err.stack}`) // TODO Check if session is broken. If it is, close the PeerClient } @@ -1074,3 +1131,15 @@ function isChannelTypeOpen(channelType) { return false } } + +/** + * @param {TalkOpenChannel} channel + */ +function getChannelOwner(channel) { + for (const userInfo of channel.getAllUserInfo()) { + if (userInfo.perm == OpenChannelUserPerm.OWNER) { + return userInfo + } + } + return null +}