Inbound permissions & outbound power levels

Note that these only apply to OpenChannels
This commit is contained in:
Andrew Ferrazzutti 2022-04-13 01:12:56 -04:00
parent a9c7bfe046
commit ecb04fc2f5
7 changed files with 202 additions and 6 deletions

View File

@ -22,7 +22,7 @@
* [x] Message redactions<sup>[1]</sup> * [x] Message redactions<sup>[1]</sup>
* [ ] Message reactions * [ ] Message reactions
* [x] Read receipts * [x] Read receipts
* [ ] Power level * [x] Power level
* [ ] Membership actions * [ ] Membership actions
* [ ] Invite * [ ] Invite
* [ ] Kick * [ ] Kick
@ -52,7 +52,7 @@
* [ ] Read receipts * [ ] Read receipts
* [ ] On backfill * [ ] On backfill
* [x] On live event * [x] On live event
* [ ] Admin status * [x] Admin status
* [x] Membership actions * [x] Membership actions
* [x] Add member * [x] Add member
* [x] Remove member * [x] Remove member

View File

@ -46,6 +46,7 @@ from ..types.chat import Chatlog, KnownChatType
from ..types.chat.attachment import MentionStruct, ReplyAttachment from ..types.chat.attachment import MentionStruct, ReplyAttachment
from ..types.client.client_session import LoginResult from ..types.client.client_session import LoginResult
from ..types.oauth import OAuthCredential, OAuthInfo from ..types.oauth import OAuthCredential, OAuthInfo
from ..types.openlink.open_link_type import OpenChannelUserPerm
from ..types.openlink.open_link_user_info import OpenLinkChannelUserInfo from ..types.openlink.open_link_user_info import OpenLinkChannelUserInfo
from ..types.packet.chat.kickout import KnownKickoutType, KickoutRes from ..types.packet.chat.kickout import KnownKickoutType, KickoutRes
from ..types.request import ( from ..types.request import (
@ -384,6 +385,19 @@ class Client:
read_until_chat_id=read_until_chat_id.serialize(), read_until_chat_id=read_until_chat_id.serialize(),
) )
async def send_perm(
self,
channel_props: ChannelProps,
user_id: Long,
perm: OpenChannelUserPerm,
) -> None:
return await self._api_user_request_void(
"send_perm",
channel_props=channel_props.serialize(),
user_id=user_id.serialize(),
perm=perm,
)
# TODO Combine these into one # TODO Combine these into one
@ -445,6 +459,14 @@ class Client:
OpenLinkChannelUserInfo.deserialize(data["info"]), 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: async def _on_channel_join(self, data: dict[str, JSON]) -> None:
await self.user.on_channel_join( await self.user.on_channel_join(
ChannelInfo.deserialize(data["channelInfo"]), 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_deleted", self._on_chat_deleted)
self._add_event_handler("chat_read", self._on_chat_read) self._add_event_handler("chat_read", self._on_chat_read)
self._add_event_handler("profile_changed", self._on_profile_changed) self._add_event_handler("profile_changed", self._on_profile_changed)
self._add_event_handler("perm_changed", self._on_perm_changed)
self._add_event_handler("channel_join", self._on_channel_join) self._add_event_handler("channel_join", self._on_channel_join)
self._add_event_handler("channel_left", self._on_channel_left) self._add_event_handler("channel_left", self._on_channel_left)
self._add_event_handler("channel_kicked", self._on_channel_kicked) self._add_event_handler("channel_kicked", self._on_channel_kicked)

View File

@ -32,6 +32,7 @@ from ..types.channel.channel_info import NormalChannelInfo
from ..types.channel.channel_type import ChannelType from ..types.channel.channel_type import ChannelType
from ..types.chat import KnownChatType from ..types.chat import KnownChatType
from ..types.openlink.open_channel_info import OpenChannelInfo from ..types.openlink.open_channel_info import OpenChannelInfo
from ..types.openlink.open_link_type import OpenChannelUserPerm
from ..types.user.channel_user_info import NormalChannelUserInfo, OpenChannelUserInfo from ..types.user.channel_user_info import NormalChannelUserInfo, OpenChannelUserInfo
@ -91,3 +92,25 @@ TO_MSGTYPE_MAP: dict[MessageType, KnownChatType] = {
# https://stackoverflow.com/a/483833 # https://stackoverflow.com/a/483833
FROM_MSGTYPE_MAP: dict[KnownChatType, MessageType] = {v: k for k, v in TO_MSGTYPE_MAP.items()} FROM_MSGTYPE_MAP: dict[KnownChatType, MessageType] = {v: k for k, v in TO_MSGTYPE_MAP.items()}
# TODO Consider allowing custom power/perm mappings
FROM_PERM_MAP: dict[OpenChannelUserPerm, int] = {
OpenChannelUserPerm.OWNER: 100,
OpenChannelUserPerm.MANAGER: 50,
# TODO Decide on an appropriate value for this
#OpenChannelUserPerm.BOT: 101,
# NOTE Intentionally skipping OpenChannelUserPerm.NONE
}
# NOTE Using a class to make it look like a dict
class TO_PERM_MAP:
@staticmethod
def get(key: int, default: Optional[OpenChannelUserPerm] = None) -> OpenChannelUserPerm:
if key >= 100:
return OpenChannelUserPerm.OWNER
elif key >= 50:
return OpenChannelUserPerm.MANAGER
else:
return default or OpenChannelUserPerm.NONE

View File

@ -26,10 +26,15 @@ from mautrix.types import (
RedactionEvent, RedactionEvent,
RoomID, RoomID,
SingleReceiptEventContent, SingleReceiptEventContent,
PowerLevelStateEventContent,
StateEvent,
UserID, 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 from .db import Message as DBMessage
if TYPE_CHECKING: if TYPE_CHECKING:
@ -135,7 +140,7 @@ class MatrixHandler(BaseMatrixHandler):
) )
return return
user = await u.User.get_by_mxid(user_id) user = await u.User.get_by_mxid(user_id)
if not user: if not user or not user.is_connected:
return return
portal = await po.Portal.get_by_mxid(room_id) portal = await po.Portal.get_by_mxid(room_id)
@ -160,6 +165,39 @@ class MatrixHandler(BaseMatrixHandler):
if message: if message:
await user.client.mark_read(portal.channel_props, message.ktid) 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( async def handle_ephemeral_event(
self, evt: ReceiptEvent | Event self, evt: ReceiptEvent | Event
) -> None: ) -> None:
@ -175,3 +213,7 @@ class MatrixHandler(BaseMatrixHandler):
evt: ReactionEvent evt: ReactionEvent
await self.handle_reaction(evt.room_id, evt.sender, evt.event_id, evt.content) await self.handle_reaction(evt.room_id, evt.sender, evt.event_id, evt.content)
""" """
async def handle_state_event(self, evt: StateEvent) -> None:
if evt.type == EventType.ROOM_POWER_LEVELS:
await self.handle_power_level(evt.room_id, evt.sender, evt.prev_content, evt.content)

View File

@ -48,6 +48,7 @@ from mautrix.types import (
Membership, Membership,
MessageEventContent, MessageEventContent,
MessageType, MessageType,
PowerLevelStateEventContent,
RelationType, RelationType,
RoomID, RoomID,
TextMessageEventContent, TextMessageEventContent,
@ -80,12 +81,15 @@ from .kt.types.chat.attachment import (
ReplyAttachment, ReplyAttachment,
VideoAttachment, VideoAttachment,
) )
from .kt.types.user.channel_user_info import OpenChannelUserInfo
from .kt.types.openlink.open_link_type import OpenChannelUserPerm
from .kt.client.types import ( from .kt.client.types import (
UserInfoUnion, UserInfoUnion,
PortalChannelInfo, PortalChannelInfo,
ChannelProps, ChannelProps,
TO_MSGTYPE_MAP, TO_MSGTYPE_MAP,
FROM_PERM_MAP,
) )
from .kt.client.errors import CommandException from .kt.client.errors import CommandException
@ -234,6 +238,10 @@ class Portal(DBPortal, BasePortal):
def is_direct(self) -> bool: def is_direct(self) -> bool:
return KnownChannelType.is_direct(self.kt_type) return KnownChannelType.is_direct(self.kt_type)
@property
def is_open(self) -> bool:
return KnownChannelType.is_open(self.kt_type)
@property @property
def kt_sender(self) -> int | None: def kt_sender(self) -> int | None:
if self.is_direct: if self.is_direct:
@ -317,8 +325,48 @@ class Portal(DBPortal, BasePortal):
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()
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 return info
async def _get_mapped_participant_power_levels(self, participants: list[UserInfoUnion], skip_default: bool) -> dict[UserID, int]:
user_power_levels: dict[UserID, int] = {}
default_value = None if skip_default else 0
for participant in participants:
if not isinstance(participant, OpenChannelUserInfo):
self.log.warning(f"Info object for participant {participant.userId} of open channel is not an OpenChannelUserInfo")
continue
power_level = FROM_PERM_MAP.get(participant.perm, default_value)
if power_level is None:
continue
await self.update_mapped_ktid_power_levels(user_power_levels, participant.userId, power_level)
return user_power_levels
@staticmethod
async def update_mapped_ktid_power_levels(user_power_levels: dict[UserID, int], ktid: int, power_level: int) -> None:
user = await u.User.get_by_ktid(ktid)
if user:
user_power_levels[user.mxid] = power_level
puppet = await p.Puppet.get_by_ktid(ktid)
if puppet:
user_power_levels[puppet.mxid] = power_level
async def set_power_levels(self, user_power_levels: dict[UserID, int]) -> None:
if self.mxid and user_power_levels:
changed = False
power_levels = await self.main_intent.get_power_levels(self.mxid)
for user, power_level in user_power_levels.items():
changed = power_levels.ensure_user_level(user, power_level) or changed
if changed:
await self.main_intent.set_power_levels(self.mxid, power_levels)
@staticmethod
async def get_mapped_ktid_power_levels(ktid: int, power_level: int) -> dict[UserID, int]:
user_power_levels: dict[UserID, int] = {}
await Portal.update_mapped_ktid_power_levels(user_power_levels, ktid, power_level)
return user_power_levels
@classmethod @classmethod
async def _reupload_kakaotalk_file( async def _reupload_kakaotalk_file(
cls, cls,
@ -632,7 +680,8 @@ class Portal(DBPortal, BasePortal):
"content": self.bridge_info, "content": self.bridge_info,
}, },
] ]
if KnownChannelType.is_open(info.channel_info.type):
if self.is_open:
initial_state.extend(( initial_state.extend((
{ {
"type": str(EventType.ROOM_JOIN_RULES), "type": str(EventType.ROOM_JOIN_RULES),
@ -643,6 +692,15 @@ class Portal(DBPortal, BasePortal):
"content": {"guest_access": "forbidden"}, "content": {"guest_access": "forbidden"},
}, },
)) ))
user_power_levels = await self._get_mapped_participant_power_levels(info.participants, skip_default=True)
user_power_levels[self.main_intent.mxid] = 1 + FROM_PERM_MAP.get(OpenChannelUserPerm.OWNER)
initial_state.append(
{
"type": str(EventType.ROOM_POWER_LEVELS),
"content": PowerLevelStateEventContent(users=user_power_levels).serialize()
}
)
invites = [] invites = []
if self.config["bridge.encryption.default"] and self.matrix.e2ee: if self.config["bridge.encryption.default"] and self.matrix.e2ee:
self.encrypted = True self.encrypted = True

View File

@ -39,7 +39,7 @@ from .db import User as DBUser
from .kt.client import Client from .kt.client import Client
from .kt.client.errors import AuthenticationRequired, ResponseError from .kt.client.errors import AuthenticationRequired, ResponseError
from .kt.client.types import SettingsStruct from .kt.client.types import SettingsStruct, FROM_PERM_MAP
from .kt.types.bson import Long from .kt.types.bson import Long
from .kt.types.channel.channel_info import ChannelInfo, NormalChannelInfo, NormalChannelData from .kt.types.channel.channel_info import ChannelInfo, NormalChannelInfo, NormalChannelData
from .kt.types.channel.channel_type import ChannelType, KnownChannelType from .kt.types.channel.channel_type import ChannelType, KnownChannelType
@ -47,6 +47,7 @@ from .kt.types.chat.chat import Chatlog
from .kt.types.client.client_session import LoginDataItem, LoginResult from .kt.types.client.client_session import LoginDataItem, LoginResult
from .kt.types.oauth import OAuthCredential from .kt.types.oauth import OAuthCredential
from .kt.types.openlink.open_channel_info import OpenChannelData, OpenChannelInfo from .kt.types.openlink.open_channel_info import OpenChannelData, OpenChannelInfo
from .kt.types.openlink.open_link_type import OpenChannelUserPerm
from .kt.types.openlink.open_link_user_info import OpenLinkChannelUserInfo from .kt.types.openlink.open_link_user_info import OpenLinkChannelUserInfo
from .kt.types.packet.chat.kickout import KnownKickoutType, KickoutRes from .kt.types.packet.chat.kickout import KnownKickoutType, KickoutRes
@ -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_DELETED = Summary("bridge_on_chat_deleted", "calls to on_chat_deleted")
METRIC_CHAT_READ = Summary("bridge_on_chat_read", "calls to on_chat_read") METRIC_CHAT_READ = Summary("bridge_on_chat_read", "calls to on_chat_read")
METRIC_PROFILE_CHANGE = Summary("bridge_on_profile_changed", "calls to on_profile_changed") METRIC_PROFILE_CHANGE = Summary("bridge_on_profile_changed", "calls to on_profile_changed")
METRIC_PERM_CHANGE = Summary("bridge_on_perm_changed", "calls to on_perm_changed")
METRIC_CHANNEL_JOIN = Summary("bridge_on_channel_join", "calls to on_channel_join") METRIC_CHANNEL_JOIN = Summary("bridge_on_channel_join", "calls to on_channel_join")
METRIC_CHANNEL_LEFT = Summary("bridge_on_channel_left", "calls to on_channel_left") METRIC_CHANNEL_LEFT = Summary("bridge_on_channel_left", "calls to on_channel_left")
METRIC_CHANNEL_KICKED = Summary("bridge_on_channel_kicked", "calls to on_channel_kicked") METRIC_CHANNEL_KICKED = Summary("bridge_on_channel_kicked", "calls to on_channel_kicked")
@ -745,6 +747,25 @@ class User(DBUser, BaseUser):
if puppet: if puppet:
await puppet.update_info_from_participant(self, info) await puppet.update_info_from_participant(self, info)
@async_time(METRIC_PERM_CHANGE)
async def on_perm_changed(
self,
user_id: Long,
perm: OpenChannelUserPerm,
channel_id: Long,
channel_type: ChannelType,
) -> None:
portal = await po.Portal.get_by_ktid(
channel_id,
kt_receiver=self.ktid,
kt_type=channel_type,
create=False,
)
if portal and portal.mxid:
power_level = FROM_PERM_MAP.get(perm, 0)
user_power_levels = await po.Portal.get_mapped_ktid_power_levels(user_id, power_level)
await portal.set_power_levels(user_power_levels)
@async_time(METRIC_CHANNEL_JOIN) @async_time(METRIC_CHANNEL_JOIN)
def on_channel_join(self, channel_info: ChannelInfo) -> Awaitable[None]: def on_channel_join(self, channel_info: ChannelInfo) -> Awaitable[None]:
return self._sync_channel(channel_info) return self._sync_channel(channel_info)

View File

@ -27,6 +27,7 @@ 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").OpenChannelUserPerm} OpenChannelUserPerm */
/** @typedef {import("node-kakao/dist/talk").TalkChannelList} TalkChannelList */ /** @typedef {import("node-kakao/dist/talk").TalkChannelList} TalkChannelList */
import chat from "node-kakao/chat" 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.#talkClient.on("channel_join", channel => {
this.log(`Joined channel ${channel.channelId}`) this.log(`Joined channel ${channel.channelId}`)
return this.write("channel_join", { 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 = () => { handleUnknownCommand = () => {
throw new Error("Unknown command") throw new Error("Unknown command")
} }
@ -773,6 +801,7 @@ export default class PeerClient {
send_media: this.sendMedia, send_media: this.sendMedia,
delete_chat: this.deleteChat, delete_chat: this.deleteChat,
mark_read: this.markRead, mark_read: this.markRead,
send_perm: this.sendPerm,
}[req.command] || this.handleUnknownCommand }[req.command] || this.handleUnknownCommand
} }
const resp = { id: req.id } const resp = { id: req.id }