Improve inbound joins, leaves, kicks, and power levels
TODO Channel leaves on backfill
This commit is contained in:
parent
283ff43769
commit
c9961d5078
|
@ -38,8 +38,13 @@ from mautrix.util.logging import TraceLogger
|
||||||
from ...config import Config
|
from ...config import Config
|
||||||
from ...rpc import EventHandler, RPCClient
|
from ...rpc import EventHandler, RPCClient
|
||||||
|
|
||||||
from ..types.api.struct import FriendListStruct
|
from ..types.api.struct import (
|
||||||
from ..types.api.struct.profile import ProfileReqStruct, ProfileStruct
|
FriendListStruct,
|
||||||
|
FriendReqStruct,
|
||||||
|
FriendStruct,
|
||||||
|
ProfileReqStruct,
|
||||||
|
ProfileStruct,
|
||||||
|
)
|
||||||
from ..types.bson import Long
|
from ..types.bson import Long
|
||||||
from ..types.channel.channel_info import ChannelInfo
|
from ..types.channel.channel_info import ChannelInfo
|
||||||
from ..types.chat import Chatlog, KnownChatType
|
from ..types.chat import Chatlog, KnownChatType
|
||||||
|
@ -60,6 +65,7 @@ from ..types.request import (
|
||||||
from .types import (
|
from .types import (
|
||||||
ChannelProps,
|
ChannelProps,
|
||||||
PortalChannelInfo,
|
PortalChannelInfo,
|
||||||
|
PortalChannelParticipantInfo,
|
||||||
SettingsStruct,
|
SettingsStruct,
|
||||||
UserInfoUnion,
|
UserInfoUnion,
|
||||||
)
|
)
|
||||||
|
@ -276,6 +282,13 @@ class Client:
|
||||||
channel_props=channel_props.serialize(),
|
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]]:
|
def get_participants(self, channel_props: ChannelProps) -> Awaitable[list[UserInfoUnion]]:
|
||||||
return self._api_user_request_result(
|
return self._api_user_request_result(
|
||||||
ResultListType(UserInfoUnion),
|
ResultListType(UserInfoUnion),
|
||||||
|
@ -511,6 +524,7 @@ class Client:
|
||||||
return self.user.on_perm_changed(
|
return self.user.on_perm_changed(
|
||||||
Long.deserialize(data["userId"]),
|
Long.deserialize(data["userId"]),
|
||||||
OpenChannelUserPerm(data["perm"]),
|
OpenChannelUserPerm(data["perm"]),
|
||||||
|
Long.deserialize(data["senderId"]),
|
||||||
Long.deserialize(data["channelId"]),
|
Long.deserialize(data["channelId"]),
|
||||||
str(data["channelType"]),
|
str(data["channelType"]),
|
||||||
)
|
)
|
||||||
|
|
|
@ -66,12 +66,17 @@ def deserialize_user_info_union(data: JSON) -> UserInfoUnion:
|
||||||
setattr(UserInfoUnion, "deserialize", deserialize_user_info_union)
|
setattr(UserInfoUnion, "deserialize", deserialize_user_info_union)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PortalChannelParticipantInfo(SerializableAttrs):
|
||||||
|
participants: list[UserInfoUnion]
|
||||||
|
kickedUserIds: list[Long]
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PortalChannelInfo(SerializableAttrs):
|
class PortalChannelInfo(SerializableAttrs):
|
||||||
name: str
|
name: str
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
photoURL: 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
|
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
|
# TODO Consider allowing custom power/perm mappings
|
||||||
|
# But must update default user level & permissions to match!
|
||||||
|
|
||||||
FROM_PERM_MAP: dict[OpenChannelUserPerm, int] = {
|
FROM_PERM_MAP: dict[OpenChannelUserPerm, int] = {
|
||||||
OpenChannelUserPerm.OWNER: 100,
|
OpenChannelUserPerm.OWNER: 100,
|
||||||
OpenChannelUserPerm.MANAGER: 50,
|
OpenChannelUserPerm.MANAGER: 50,
|
||||||
# TODO Decide on an appropriate value for this
|
# TODO Decide on an appropriate value for this
|
||||||
#OpenChannelUserPerm.BOT: 101,
|
OpenChannelUserPerm.BOT: 25,
|
||||||
# NOTE Intentionally skipping OpenChannelUserPerm.NONE
|
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:
|
class TO_PERM_MAP:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get(key: int, default: Optional[OpenChannelUserPerm] = None) -> OpenChannelUserPerm:
|
def get(key: int) -> OpenChannelUserPerm:
|
||||||
if key >= 100:
|
if key >= 100:
|
||||||
return OpenChannelUserPerm.OWNER
|
return OpenChannelUserPerm.OWNER
|
||||||
elif key >= 50:
|
if key >= 50:
|
||||||
return OpenChannelUserPerm.MANAGER
|
return OpenChannelUserPerm.MANAGER
|
||||||
else:
|
return OpenChannelUserPerm.NONE
|
||||||
return default or OpenChannelUserPerm.NONE
|
|
||||||
|
|
|
@ -88,12 +88,13 @@ 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.types.openlink.open_link_type import OpenChannelUserPerm
|
||||||
|
from .kt.types.user.channel_user_info import OpenChannelUserInfo
|
||||||
|
|
||||||
from .kt.client.types import (
|
from .kt.client.types import (
|
||||||
UserInfoUnion,
|
UserInfoUnion,
|
||||||
PortalChannelInfo,
|
PortalChannelInfo,
|
||||||
|
PortalChannelParticipantInfo,
|
||||||
ChannelProps,
|
ChannelProps,
|
||||||
TO_MSGTYPE_MAP,
|
TO_MSGTYPE_MAP,
|
||||||
FROM_PERM_MAP,
|
FROM_PERM_MAP,
|
||||||
|
@ -356,7 +357,7 @@ class Portal(DBPortal, BasePortal):
|
||||||
force_save: bool = False,
|
force_save: bool = False,
|
||||||
) -> PortalChannelInfo:
|
) -> PortalChannelInfo:
|
||||||
if not info:
|
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)
|
info = await source.client.get_portal_channel_info(self.channel_props)
|
||||||
changed = False
|
changed = False
|
||||||
if not self.is_direct:
|
if not self.is_direct:
|
||||||
|
@ -367,31 +368,25 @@ class Portal(DBPortal, BasePortal):
|
||||||
self._update_photo(source, info.photoURL),
|
self._update_photo(source, info.photoURL),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if info.participants is not None:
|
if info.participantInfo:
|
||||||
changed = await self._update_participants(source, info.participants) or changed
|
changed = await self._update_participants(source, info.participantInfo) 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]:
|
async def _get_mapped_participant_power_levels(self, participants: list[UserInfoUnion]) -> dict[UserID, int]:
|
||||||
user_power_levels: dict[UserID, int] = {}
|
user_power_levels: dict[UserID, int] = {}
|
||||||
default_value = None if skip_default else 0
|
|
||||||
for participant in participants:
|
for participant in participants:
|
||||||
if not isinstance(participant, OpenChannelUserInfo):
|
if not isinstance(participant, OpenChannelUserInfo):
|
||||||
self.log.warning(f"Info object for participant {participant.userId} of open channel is not an OpenChannelUserInfo")
|
self.log.warning(f"Info object for participant {participant.userId} of open channel is not an OpenChannelUserInfo")
|
||||||
continue
|
continue
|
||||||
power_level = FROM_PERM_MAP.get(participant.perm, default_value)
|
await self._update_mapped_ktid_power_levels(user_power_levels, participant.userId, participant.perm)
|
||||||
if power_level is None:
|
|
||||||
continue
|
|
||||||
await self.update_mapped_ktid_power_levels(user_power_levels, participant.userId, power_level)
|
|
||||||
return user_power_levels
|
return user_power_levels
|
||||||
|
|
||||||
@staticmethod
|
@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)
|
user = await u.User.get_by_ktid(ktid)
|
||||||
if user:
|
if user:
|
||||||
user_power_levels[user.mxid] = power_level
|
user_power_levels[user.mxid] = power_level
|
||||||
|
@ -399,20 +394,46 @@ class Portal(DBPortal, BasePortal):
|
||||||
if puppet:
|
if puppet:
|
||||||
user_power_levels[puppet.mxid] = power_level
|
user_power_levels[puppet.mxid] = power_level
|
||||||
|
|
||||||
async def set_power_levels(self, user_power_levels: dict[UserID, int]) -> None:
|
async def _set_user_power_levels(self, sender: p.Puppet | None, user_power_levels: dict[UserID, int]) -> None:
|
||||||
if self.mxid and user_power_levels:
|
if not self.mxid:
|
||||||
changed = False
|
return
|
||||||
power_levels = await self.main_intent.get_power_levels(self.mxid)
|
orig_power_levels = await self.main_intent.get_power_levels(self.mxid)
|
||||||
for user, power_level in user_power_levels.items():
|
user_power_levels = {k: v for k, v in user_power_levels.items() if orig_power_levels.get_user_level(k) != v}
|
||||||
changed = power_levels.ensure_user_level(user, power_level) or changed
|
if not user_power_levels:
|
||||||
if changed:
|
return
|
||||||
await self.main_intent.set_power_levels(self.mxid, power_levels)
|
joined_puppets = {
|
||||||
|
puppet.mxid: puppet for puppet in [
|
||||||
@staticmethod
|
await p.Puppet.get_by_custom_mxid(mxid) or await p.Puppet.get_by_mxid(mxid)
|
||||||
async def get_mapped_ktid_power_levels(ktid: int, power_level: int) -> dict[UserID, int]:
|
for mxid in await self.main_intent.get_room_members(self.mxid)
|
||||||
user_power_levels: dict[UserID, int] = {}
|
] if puppet
|
||||||
await Portal.update_mapped_ktid_power_levels(user_power_levels, ktid, power_level)
|
}
|
||||||
return user_power_levels
|
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
|
@classmethod
|
||||||
async def _reupload_kakaotalk_file(
|
async def _reupload_kakaotalk_file(
|
||||||
|
@ -593,16 +614,72 @@ class Portal(DBPortal, BasePortal):
|
||||||
# await self.sync_per_room_nick(puppet, nick_map[puppet.ktid])
|
# await self.sync_per_room_nick(puppet, nick_map[puppet.ktid])
|
||||||
return changed
|
return changed
|
||||||
|
|
||||||
|
async def _update_participants(
|
||||||
async def _update_participants(self, source: u.User, participants: list[UserInfoUnion] | None = None) -> bool:
|
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?
|
# TODO nick map?
|
||||||
if participants is None:
|
if participant_info is None:
|
||||||
self.log.debug("Called _update_participants with no participants, fetching them now...")
|
self.log.debug("Called _update_participants with no participant info, fetching it now...")
|
||||||
participants = await source.client.get_participants(self.channel_props)
|
participant_info = await source.client.get_portal_channel_participant_info(self.channel_props)
|
||||||
sync_tasks = [
|
if self.mxid:
|
||||||
self._update_participant(source, pcp) for pcp in participants
|
# NOTE KakaoTalk kick = Matrix ban
|
||||||
]
|
prev_banned_mxids = {
|
||||||
changed = any(await asyncio.gather(*sync_tasks))
|
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
|
return changed
|
||||||
|
|
||||||
# endregion
|
# endregion
|
||||||
|
@ -625,8 +702,6 @@ class Portal(DBPortal, BasePortal):
|
||||||
async def _update_matrix_room(
|
async def _update_matrix_room(
|
||||||
self, source: u.User, info: PortalChannelInfo | None = None
|
self, source: u.User, info: PortalChannelInfo | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
info = await self.update_info(source, info)
|
|
||||||
|
|
||||||
puppet = await p.Puppet.get_by_custom_mxid(source.mxid)
|
puppet = await p.Puppet.get_by_custom_mxid(source.mxid)
|
||||||
await self.main_intent.invite_user(
|
await self.main_intent.invite_user(
|
||||||
self.mxid,
|
self.mxid,
|
||||||
|
@ -639,6 +714,8 @@ class Portal(DBPortal, BasePortal):
|
||||||
if did_join and self.is_direct:
|
if did_join and self.is_direct:
|
||||||
await source.update_direct_chats({self.main_intent.mxid: [self.mxid]})
|
await source.update_direct_chats({self.main_intent.mxid: [self.mxid]})
|
||||||
|
|
||||||
|
info = await self.update_info(source, info)
|
||||||
|
|
||||||
# TODO Sync read receipts?
|
# TODO Sync read receipts?
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
@ -725,7 +802,7 @@ class Portal(DBPortal, BasePortal):
|
||||||
self.log.debug(f"Creating Matrix room")
|
self.log.debug(f"Creating Matrix room")
|
||||||
if self.is_direct:
|
if self.is_direct:
|
||||||
# NOTE Must do this to find the other member of the DM, since the channel ID != the member's ID!
|
# 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
|
name: str | None = None
|
||||||
initial_state = [
|
initial_state = [
|
||||||
{
|
{
|
||||||
|
@ -752,8 +829,10 @@ 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)
|
# TODO Find whether perms apply to any non-direct channel, or just open ones
|
||||||
user_power_levels[self.main_intent.mxid] = 1 + FROM_PERM_MAP.get(OpenChannelUserPerm.OWNER)
|
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(
|
initial_state.append(
|
||||||
{
|
{
|
||||||
"type": str(EventType.ROOM_POWER_LEVELS),
|
"type": str(EventType.ROOM_POWER_LEVELS),
|
||||||
|
@ -827,7 +906,7 @@ class Portal(DBPortal, BasePortal):
|
||||||
|
|
||||||
if not self.is_direct:
|
if not self.is_direct:
|
||||||
# NOTE Calling this after room creation to invite participants
|
# NOTE Calling this after room creation to invite participants
|
||||||
await self._update_participants(source, info.participants)
|
await self._update_participants(source, info.participantInfo)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self.backfill(source, is_initial=True, channel_info=info.channel_info)
|
await self.backfill(source, is_initial=True, channel_info=info.channel_info)
|
||||||
|
@ -1132,21 +1211,55 @@ class Portal(DBPortal, BasePortal):
|
||||||
prev_content: PowerLevelStateEventContent,
|
prev_content: PowerLevelStateEventContent,
|
||||||
content: PowerLevelStateEventContent,
|
content: PowerLevelStateEventContent,
|
||||||
) -> None:
|
) -> None:
|
||||||
for target_mxid, power_level in content.users.items():
|
ktid_perms: dict[Long, OpenChannelUserPerm] = {}
|
||||||
if power_level == prev_content.get_user_level(target_mxid):
|
user_power_levels: dict[UserID, int] = {}
|
||||||
|
for user_id, level in content.users.items():
|
||||||
|
if level == prev_content.get_user_level(user_id):
|
||||||
continue
|
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 puppet:
|
||||||
if sender and sender.is_connected:
|
ktid = puppet.ktid
|
||||||
perm = TO_PERM_MAP.get(power_level)
|
user = await u.User.get_by_ktid(ktid)
|
||||||
await sender.client.send_perm(self.channel_props, puppet.ktid, perm)
|
if user:
|
||||||
else:
|
mxid = user.mxid
|
||||||
raise Exception(
|
elif user:
|
||||||
"Only users connected to KakaoTalk can set power levels of KakaoTalk users"
|
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:
|
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(
|
async def _handle_matrix_room_name(
|
||||||
self,
|
self,
|
||||||
|
@ -1571,25 +1684,67 @@ class Portal(DBPortal, BasePortal):
|
||||||
f"Handled KakaoTalk read receipt from {sender.ktid} up to {chat_id}/{msg.mxid}"
|
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(
|
async def handle_kakaotalk_user_join(
|
||||||
self, source: u.User, user: p.Puppet
|
self, source: u.User, user: p.Puppet
|
||||||
) -> None:
|
) -> 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:
|
if not user.name:
|
||||||
self.schedule_resync(source, user)
|
self.schedule_resync(source, user)
|
||||||
|
|
||||||
async def handle_kakaotalk_user_left(
|
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:
|
) -> None:
|
||||||
|
sender_intent = sender.intent_for(self) if sender else self.main_intent
|
||||||
if sender == removed:
|
if sender == removed:
|
||||||
await removed.intent_for(self).leave_room(self.mxid)
|
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:
|
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:
|
try:
|
||||||
await sender.intent_for(self).kick_user(self.mxid, removed.mxid)
|
await sender_intent.unban_user(self.mxid, unkicked_mxid)
|
||||||
except MForbidden:
|
except MForbidden:
|
||||||
await self.main_intent.kick_user(
|
if not sender:
|
||||||
self.mxid, removed.mxid, reason=f"Kicked by {sender.name}"
|
raise
|
||||||
)
|
await self.main_intent.unban_user(self.mxid, unkicked_mxid)
|
||||||
|
|
||||||
|
|
||||||
# endregion
|
# endregion
|
||||||
|
|
||||||
|
|
|
@ -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 PortalChannelInfo, SettingsStruct, FROM_PERM_MAP
|
from .kt.client.types import PortalChannelInfo, SettingsStruct
|
||||||
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
|
||||||
|
@ -441,10 +441,10 @@ class User(DBUser, BaseUser):
|
||||||
if not sync_count:
|
if not sync_count:
|
||||||
self.log.debug("Skipping channel syncing")
|
self.log.debug("Skipping channel syncing")
|
||||||
return
|
return
|
||||||
|
# TODO Sync left channels. It's not login_result.removedChannelIdList...
|
||||||
if not login_result.channelList:
|
if not login_result.channelList:
|
||||||
self.log.debug("No channels to sync")
|
self.log.debug("No channels to sync")
|
||||||
return
|
return
|
||||||
# TODO What about removed channels? Don't early-return then
|
|
||||||
|
|
||||||
num_channels = len(login_result.channelList)
|
num_channels = len(login_result.channelList)
|
||||||
sync_count = num_channels if sync_count < 0 else min(sync_count, num_channels)
|
sync_count = num_channels if sync_count < 0 else min(sync_count, num_channels)
|
||||||
|
@ -776,6 +776,7 @@ class User(DBUser, BaseUser):
|
||||||
self,
|
self,
|
||||||
user_id: Long,
|
user_id: Long,
|
||||||
perm: OpenChannelUserPerm,
|
perm: OpenChannelUserPerm,
|
||||||
|
sender_id: Long,
|
||||||
channel_id: Long,
|
channel_id: Long,
|
||||||
channel_type: ChannelType,
|
channel_type: ChannelType,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
@ -787,9 +788,9 @@ class User(DBUser, BaseUser):
|
||||||
create=False,
|
create=False,
|
||||||
)
|
)
|
||||||
if portal and portal.mxid:
|
if portal and portal.mxid:
|
||||||
power_level = FROM_PERM_MAP.get(perm, 0)
|
sender = await pu.Puppet.get_by_ktid(sender_id)
|
||||||
user_power_levels = await po.Portal.get_mapped_ktid_power_levels(user_id, power_level)
|
await portal.backfill_lock.wait("perm changed")
|
||||||
await portal.set_power_levels(user_power_levels)
|
await portal.handle_kakaotalk_perm_changed(self, sender, user_id, perm)
|
||||||
|
|
||||||
@async_time(METRIC_CHANNEL_ADDED)
|
@async_time(METRIC_CHANNEL_ADDED)
|
||||||
def on_channel_added(self, channel_info: ChannelInfo) -> Awaitable[None]:
|
def on_channel_added(self, channel_info: ChannelInfo) -> Awaitable[None]:
|
||||||
|
|
|
@ -27,7 +27,11 @@ import {
|
||||||
/** @typedef {import("node-kakao").ChannelType} ChannelType */
|
/** @typedef {import("node-kakao").ChannelType} ChannelType */
|
||||||
/** @typedef {import("node-kakao").ReplyAttachment} ReplyAttachment */
|
/** @typedef {import("node-kakao").ReplyAttachment} ReplyAttachment */
|
||||||
/** @typedef {import("node-kakao").MentionStruct} MentionStruct */
|
/** @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 */
|
/** @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"
|
import openlink from "node-kakao/openlink"
|
||||||
const { OpenChannelUserPerm } = 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<OpenChannelUserPerm, string> */
|
/** @type {Map<OpenChannelUserPerm, string> */
|
||||||
static #PERM_NAMES = new Map([
|
static #PERM_NAMES = new Map([
|
||||||
[OpenChannelUserPerm.OWNER, "the channel owner"],
|
[OpenChannelUserPerm.OWNER, "the channel owner"],
|
||||||
|
@ -175,13 +179,20 @@ class UserClient {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
this.#talkClient.on("perm_changed", (channel, lastInfo, user) => {
|
this.#talkClient.on("perm_changed",
|
||||||
// TODO Fix the type hint on lastInfo and user: they should each be a OpenChannelUserInfo, not just a ChannelUserInfo
|
/**
|
||||||
|
* 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.log(`Perms of user ${user.userId} in channel ${channel.channelId} changed from ${lastInfo.perm} to ${user.perm}`)
|
||||||
this.write("perm_changed", {
|
this.write("perm_changed", {
|
||||||
is_sequential: true,
|
is_sequential: true,
|
||||||
userId: user.userId,
|
userId: user.userId,
|
||||||
perm: user.perm,
|
perm: user.perm,
|
||||||
|
senderId: getChannelOwner().userId,
|
||||||
channelId: channel.channelId,
|
channelId: channel.channelId,
|
||||||
channelType: channel.info.type,
|
channelType: channel.info.type,
|
||||||
})
|
})
|
||||||
|
@ -697,10 +708,48 @@ export default class PeerClient {
|
||||||
description: talkChannel.info.openLink?.description,
|
description: talkChannel.info.openLink?.description,
|
||||||
// TODO Find out why linkCoverURL is blank, despite having updated the channel!
|
// TODO Find out why linkCoverURL is blank, despite having updated the channel!
|
||||||
photoURL: talkChannel.info.openLink?.linkCoverURL || null,
|
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 {Object} req
|
||||||
* @param {string} req.mxid
|
* @param {string} req.mxid
|
||||||
|
@ -860,7 +909,14 @@ export default class PeerClient {
|
||||||
[OpenChannelUserPerm.OWNER],
|
[OpenChannelUserPerm.OWNER],
|
||||||
"change user permissions"
|
"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_own_profile: this.getOwnProfile,
|
||||||
get_profile: this.getProfile,
|
get_profile: this.getProfile,
|
||||||
get_portal_channel_info: this.getPortalChannelInfo,
|
get_portal_channel_info: this.getPortalChannelInfo,
|
||||||
|
get_portal_channel_participant_info: this.getPortalChannelParticipantInfo,
|
||||||
get_participants: this.getParticipants,
|
get_participants: this.getParticipants,
|
||||||
get_chats: this.getChats,
|
get_chats: this.getChats,
|
||||||
list_friends: this.listFriends,
|
list_friends: this.listFriends,
|
||||||
|
@ -1015,7 +1072,7 @@ export default class PeerClient {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
resp.command = "error"
|
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}`)
|
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
|
||||||
}
|
}
|
||||||
|
@ -1074,3 +1131,15 @@ function isChannelTypeOpen(channelType) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {TalkOpenChannel} channel
|
||||||
|
*/
|
||||||
|
function getChannelOwner(channel) {
|
||||||
|
for (const userInfo of channel.getAllUserInfo()) {
|
||||||
|
if (userInfo.perm == OpenChannelUserPerm.OWNER) {
|
||||||
|
return userInfo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue