Compare commits
11 Commits
a9c7bfe046
...
91448c3005
Author | SHA1 | Date |
---|---|---|
Andrew Ferrazzutti | 91448c3005 | |
Andrew Ferrazzutti | b85301d59f | |
Andrew Ferrazzutti | 0a0812515c | |
Andrew Ferrazzutti | 770b0e447b | |
Andrew Ferrazzutti | abf3114203 | |
Andrew Ferrazzutti | 746756cc3f | |
Andrew Ferrazzutti | a12efc92c4 | |
Andrew Ferrazzutti | d843fcf5d2 | |
Andrew Ferrazzutti | b29453a871 | |
Andrew Ferrazzutti | 7a78d6ba25 | |
Andrew Ferrazzutti | ecb04fc2f5 |
31
ROADMAP.md
31
ROADMAP.md
|
@ -10,25 +10,20 @@
|
|||
* [x] Videos
|
||||
* [x] Images
|
||||
* [ ] Locations
|
||||
* [ ] Replies
|
||||
* [x] In DMs
|
||||
* [ ] In multi-user rooms
|
||||
* [ ] To relay users
|
||||
* [ ] Mentions
|
||||
* [x] In DMs
|
||||
* [ ] In multi-user rooms
|
||||
* [x] To relay users
|
||||
* [x] Replies<sup>[1]</sup>
|
||||
* [x] Mentions<sup>[1]</sup>
|
||||
* [ ] Polls
|
||||
* [x] Message redactions<sup>[1]</sup>
|
||||
* [x] Message redactions<sup>[2]</sup>
|
||||
* [ ] Message reactions
|
||||
* [x] Read receipts
|
||||
* [ ] Power level
|
||||
* [x] Power level
|
||||
* [ ] Membership actions
|
||||
* [ ] Invite
|
||||
* [ ] Kick
|
||||
* [ ] Leave
|
||||
* [ ] Room metadata changes
|
||||
* [ ] Name
|
||||
* [ ] Topic
|
||||
* [ ] Avatar
|
||||
* [ ] Per-room user nick
|
||||
* KakaoTalk → Matrix
|
||||
|
@ -52,17 +47,15 @@
|
|||
* [ ] Read receipts
|
||||
* [ ] On backfill
|
||||
* [x] On live event
|
||||
* [ ] Admin status
|
||||
* [x] Admin status
|
||||
* [x] Membership actions
|
||||
* [x] Add member
|
||||
* [x] Remove member
|
||||
* [x] Leave
|
||||
* [ ] Chat metadata changes
|
||||
* [x] Title
|
||||
* [ ] Avatar
|
||||
* [ ] Initial chat metadata
|
||||
* [x] Title
|
||||
* [ ] Avatar
|
||||
* [x] Channel metadata
|
||||
* [x] Name
|
||||
* [x] Description
|
||||
* [x] Cover photo<sup>[3]</sup>
|
||||
* [x] User metadata
|
||||
* [x] Name
|
||||
* [x] Avatar
|
||||
|
@ -79,4 +72,6 @@
|
|||
* [ ] For new KakaoTalk channels
|
||||
* [x] Option to use own Matrix account for messages sent from other KakaoTalk clients
|
||||
|
||||
<sup>[1]</sup> Only recently-sent messages can be deleted
|
||||
<sup>[1]</sup> Sometimes fails with "Invalid body" error
|
||||
<sup>[2]</sup> Only recently-sent messages can be deleted
|
||||
<sup>[3]</sup> Might only get synced on backfill, or on changing channel name/description
|
||||
|
|
|
@ -38,10 +38,12 @@ class Portal:
|
|||
kt_type: ChannelType
|
||||
mxid: RoomID | None
|
||||
name: str | None
|
||||
description: str | None
|
||||
photo_id: str | None
|
||||
avatar_url: ContentURI | None
|
||||
encrypted: bool
|
||||
name_set: bool
|
||||
topic_set: bool
|
||||
avatar_set: bool
|
||||
relay_user_id: UserID | None
|
||||
|
||||
|
@ -56,7 +58,7 @@ class Portal:
|
|||
@classmethod
|
||||
async def get_by_ktid(cls, ktid: int, kt_receiver: int) -> Portal | None:
|
||||
q = """
|
||||
SELECT ktid, kt_receiver, kt_type, mxid, name, photo_id, avatar_url, encrypted,
|
||||
SELECT ktid, kt_receiver, kt_type, mxid, name, description, photo_id, avatar_url, encrypted,
|
||||
name_set, avatar_set, relay_user_id
|
||||
FROM portal WHERE ktid=$1 AND kt_receiver=$2
|
||||
"""
|
||||
|
@ -66,7 +68,7 @@ class Portal:
|
|||
@classmethod
|
||||
async def get_by_mxid(cls, mxid: RoomID) -> Portal | None:
|
||||
q = """
|
||||
SELECT ktid, kt_receiver, kt_type, mxid, name, photo_id, avatar_url, encrypted,
|
||||
SELECT ktid, kt_receiver, kt_type, mxid, name, description, photo_id, avatar_url, encrypted,
|
||||
name_set, avatar_set, relay_user_id
|
||||
FROM portal WHERE mxid=$1
|
||||
"""
|
||||
|
@ -76,7 +78,7 @@ class Portal:
|
|||
@classmethod
|
||||
async def get_all_by_receiver(cls, kt_receiver: int) -> list[Portal]:
|
||||
q = """
|
||||
SELECT ktid, kt_receiver, kt_type, mxid, name, photo_id, avatar_url, encrypted,
|
||||
SELECT ktid, kt_receiver, kt_type, mxid, name, description, photo_id, avatar_url, encrypted,
|
||||
name_set, avatar_set, relay_user_id
|
||||
FROM portal WHERE kt_receiver=$1
|
||||
"""
|
||||
|
@ -86,7 +88,7 @@ class Portal:
|
|||
@classmethod
|
||||
async def all(cls) -> list[Portal]:
|
||||
q = """
|
||||
SELECT ktid, kt_receiver, kt_type, mxid, name, photo_id, avatar_url, encrypted,
|
||||
SELECT ktid, kt_receiver, kt_type, mxid, name, description, photo_id, avatar_url, encrypted,
|
||||
name_set, avatar_set, relay_user_id
|
||||
FROM portal
|
||||
"""
|
||||
|
@ -101,6 +103,7 @@ class Portal:
|
|||
self.kt_type,
|
||||
self.mxid,
|
||||
self.name,
|
||||
self.description,
|
||||
self.photo_id,
|
||||
self.avatar_url,
|
||||
self.encrypted,
|
||||
|
@ -111,9 +114,9 @@ class Portal:
|
|||
|
||||
async def insert(self) -> None:
|
||||
q = """
|
||||
INSERT INTO portal (ktid, kt_receiver, kt_type, mxid, name, photo_id, avatar_url,
|
||||
INSERT INTO portal (ktid, kt_receiver, kt_type, mxid, name, description, photo_id, avatar_url,
|
||||
encrypted, name_set, avatar_set, relay_user_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
"""
|
||||
await self.db.execute(q, *self._values)
|
||||
|
||||
|
@ -123,8 +126,8 @@ class Portal:
|
|||
|
||||
async def save(self) -> None:
|
||||
q = """
|
||||
UPDATE portal SET kt_type=$3, mxid=$4, name=$5, photo_id=$6, avatar_url=$7,
|
||||
encrypted=$8, name_set=$9, avatar_set=$10, relay_user_id=$11
|
||||
UPDATE portal SET kt_type=$3, mxid=$4, name=$5, description=$6, photo_id=$7, avatar_url=$8,
|
||||
encrypted=$9, name_set=$10, avatar_set=$11, relay_user_id=$12
|
||||
WHERE ktid=$1 AND kt_receiver=$2
|
||||
"""
|
||||
await self.db.execute(q, *self._values)
|
||||
|
|
|
@ -3,3 +3,4 @@ from mautrix.util.async_db import UpgradeTable
|
|||
upgrade_table = UpgradeTable()
|
||||
|
||||
from . import v01_initial_revision
|
||||
from . import v02_channel_meta
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge.
|
||||
# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from mautrix.util.async_db import Connection
|
||||
|
||||
from . import upgrade_table
|
||||
|
||||
|
||||
@upgrade_table.register(description="Support channel descriptions")
|
||||
async def upgrade_v2(conn: Connection) -> None:
|
||||
await conn.execute("ALTER TABLE portal ADD COLUMN description TEXT")
|
||||
await conn.execute("ALTER TABLE portal ADD COLUMN topic_set BOOLEAN NOT NULL DEFAULT false")
|
|
@ -35,7 +35,7 @@ from ..kt.types.chat.attachment import ReplyAttachment, MentionStruct
|
|||
|
||||
from ..kt.client.types import TO_MSGTYPE_MAP
|
||||
|
||||
from .. import puppet as pu, user as u
|
||||
from .. import portal as po, puppet as pu, user as u
|
||||
from ..db import Message as DBMessage
|
||||
|
||||
|
||||
|
@ -90,20 +90,23 @@ class ToKakaoTalkParser(MatrixParser[KakaoTalkFormatString]):
|
|||
fs = KakaoTalkFormatString
|
||||
|
||||
|
||||
async def _get_id_from_mxid(mxid: UserID) -> Long | None:
|
||||
user = await u.User.get_by_mxid(mxid, create=False)
|
||||
if user and user.ktid:
|
||||
return user.ktid
|
||||
else:
|
||||
puppet = await pu.Puppet.get_by_mxid(mxid, create=False)
|
||||
return puppet.ktid if puppet else None
|
||||
async def _get_id_from_mxid(mxid: UserID, portal: po.Portal) -> Long | None:
|
||||
orig_sender = await u.User.get_by_mxid(mxid, create=False)
|
||||
if orig_sender and orig_sender.ktid:
|
||||
return orig_sender.ktid
|
||||
elif orig_sender:
|
||||
sender, _ = await portal.get_relay_sender(orig_sender, "relation")
|
||||
if sender and sender.ktid:
|
||||
return sender.ktid
|
||||
puppet = await pu.Puppet.get_by_mxid(mxid, create=False)
|
||||
return puppet.ktid if puppet else None
|
||||
|
||||
|
||||
async def matrix_to_kakaotalk(
|
||||
content: MessageEventContent,
|
||||
room_id: RoomID,
|
||||
log: TraceLogger,
|
||||
intent: IntentAPI,
|
||||
portal: po.Portal,
|
||||
skip_reply: bool = False
|
||||
) -> SendParams:
|
||||
# NOTE By design, this *throws* if user intent can't be matched (i.e. if a reply can't be created)
|
||||
|
@ -115,17 +118,17 @@ async def matrix_to_kakaotalk(
|
|||
" to bridge text message reply metadata to KakaoTalk"
|
||||
)
|
||||
try:
|
||||
src_event = await intent.get_event(room_id, message.mxid)
|
||||
src_event = await portal.main_intent.get_event(room_id, message.mxid)
|
||||
except:
|
||||
log.exception(f"Failed to find Matrix event for reply target {message.mxid}")
|
||||
raise
|
||||
src_kt_sender = await _get_id_from_mxid(src_event.sender)
|
||||
src_kt_sender = await _get_id_from_mxid(src_event.sender, portal)
|
||||
if src_kt_sender is None:
|
||||
raise ValueError(
|
||||
f"Found no KakaoTalk user ID for reply target sender {src_event.sender}"
|
||||
)
|
||||
content.trim_reply_fallback()
|
||||
src_converted = await matrix_to_kakaotalk(src_event.content, room_id, log, intent, skip_reply=True)
|
||||
src_converted = await matrix_to_kakaotalk(src_event.content, room_id, log, portal, skip_reply=True)
|
||||
if src_event.content.relates_to.rel_type == RelationType.REPLY:
|
||||
src_type = KnownChatType.REPLY
|
||||
src_message = src_converted.text
|
||||
|
@ -158,7 +161,7 @@ async def matrix_to_kakaotalk(
|
|||
mentions_by_user: dict[Long, MentionStruct] = {}
|
||||
# Make sure to not create remote mentions for any remote user not in the room
|
||||
if parsed.entities:
|
||||
joined_members = set(await intent.get_room_members(room_id))
|
||||
joined_members = set(await portal.main_intent.get_room_members(room_id))
|
||||
last_offset = 0
|
||||
at = 0
|
||||
for mention in sorted(parsed.entities, key=lambda entity: entity.offset):
|
||||
|
|
|
@ -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 (
|
||||
|
@ -126,7 +127,7 @@ class Client:
|
|||
@classmethod
|
||||
async def register_device(cls, passcode: str, **req: JSON) -> None:
|
||||
"""Register a (fake) device that will be associated with the provided login credentials."""
|
||||
await cls._api_request_void("register_device", passcode=passcode, is_secret=True, **req)
|
||||
await cls._api_request_void("register_device", passcode=passcode, **req)
|
||||
|
||||
@classmethod
|
||||
async def login(cls, **req: JSON) -> OAuthCredential:
|
||||
|
@ -135,7 +136,7 @@ class Client:
|
|||
Must have first called register_device with these credentials.
|
||||
"""
|
||||
# NOTE Actually returns an auth LoginData, but this only needs an OAuthCredential
|
||||
return await cls._api_request_result(OAuthCredential, "login", is_secret=True, **req)
|
||||
return await cls._api_request_result(OAuthCredential, "login", **req)
|
||||
|
||||
# endregion
|
||||
|
||||
|
@ -357,9 +358,6 @@ class Client:
|
|||
width=width,
|
||||
height=height,
|
||||
ext=ext,
|
||||
# Don't log the bytes
|
||||
# TODO Disable logging per-argument, not per-command
|
||||
is_secret=True
|
||||
)
|
||||
|
||||
async def delete_chat(
|
||||
|
@ -384,6 +382,52 @@ 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,
|
||||
)
|
||||
|
||||
async def set_channel_name(
|
||||
self,
|
||||
channel_props: ChannelProps,
|
||||
name: str,
|
||||
) -> None:
|
||||
return await self._api_user_request_void(
|
||||
"set_channel_name",
|
||||
channel_props=channel_props.serialize(),
|
||||
name=name,
|
||||
)
|
||||
|
||||
async def set_channel_description(
|
||||
self,
|
||||
channel_props: ChannelProps,
|
||||
description: str,
|
||||
) -> None:
|
||||
return await self._api_user_request_void(
|
||||
"set_channel_description",
|
||||
channel_props=channel_props.serialize(),
|
||||
description=description,
|
||||
)
|
||||
|
||||
async def set_channel_photo(
|
||||
self,
|
||||
channel_props: ChannelProps,
|
||||
photo_url: str,
|
||||
) -> None:
|
||||
return await self._api_user_request_void(
|
||||
"set_channel_photo",
|
||||
channel_props=channel_props.serialize(),
|
||||
photo_url=photo_url,
|
||||
)
|
||||
|
||||
|
||||
# TODO Combine these into one
|
||||
|
||||
|
@ -416,15 +460,15 @@ class Client:
|
|||
|
||||
# region listeners
|
||||
|
||||
async def _on_chat(self, data: dict[str, JSON]) -> None:
|
||||
await self.user.on_chat(
|
||||
def _on_chat(self, data: dict[str, JSON]) -> Awaitable[None]:
|
||||
return self.user.on_chat(
|
||||
Chatlog.deserialize(data["chatlog"]),
|
||||
Long.deserialize(data["channelId"]),
|
||||
str(data["channelType"]),
|
||||
)
|
||||
|
||||
async def _on_chat_deleted(self, data: dict[str, JSON]) -> None:
|
||||
await self.user.on_chat_deleted(
|
||||
def _on_chat_deleted(self, data: dict[str, JSON]) -> Awaitable[None]:
|
||||
return self.user.on_chat_deleted(
|
||||
Long.deserialize(data["chatId"]),
|
||||
Long.deserialize(data["senderId"]),
|
||||
int(data["timestamp"]),
|
||||
|
@ -432,68 +476,83 @@ class Client:
|
|||
str(data["channelType"]),
|
||||
)
|
||||
|
||||
async def _on_chat_read(self, data: dict[str, JSON]) -> None:
|
||||
await self.user.on_chat_read(
|
||||
def _on_chat_read(self, data: dict[str, JSON]) -> Awaitable[None]:
|
||||
return self.user.on_chat_read(
|
||||
Long.deserialize(data["chatId"]),
|
||||
Long.deserialize(data["senderId"]),
|
||||
Long.deserialize(data["channelId"]),
|
||||
str(data["channelType"]),
|
||||
)
|
||||
|
||||
async def _on_profile_changed(self, data: dict[str, JSON]) -> None:
|
||||
await self.user.on_profile_changed(
|
||||
def _on_profile_changed(self, data: dict[str, JSON]) -> Awaitable[None]:
|
||||
return self.user.on_profile_changed(
|
||||
OpenLinkChannelUserInfo.deserialize(data["info"]),
|
||||
)
|
||||
|
||||
async def _on_channel_join(self, data: dict[str, JSON]) -> None:
|
||||
await self.user.on_channel_join(
|
||||
ChannelInfo.deserialize(data["channelInfo"]),
|
||||
)
|
||||
|
||||
async def _on_channel_left(self, data: dict[str, JSON]) -> None:
|
||||
await self.user.on_channel_left(
|
||||
def _on_perm_changed(self, data: dict[str, JSON]) -> Awaitable[None]:
|
||||
return self.user.on_perm_changed(
|
||||
Long.deserialize(data["userId"]),
|
||||
OpenChannelUserPerm(data["perm"]),
|
||||
Long.deserialize(data["channelId"]),
|
||||
str(data["channelType"]),
|
||||
)
|
||||
|
||||
async def _on_channel_kicked(self, data: dict[str, JSON]) -> None:
|
||||
await self.user.on_channel_kicked(
|
||||
def _on_channel_join(self, data: dict[str, JSON]) -> Awaitable[None]:
|
||||
return self.user.on_channel_join(
|
||||
ChannelInfo.deserialize(data["channelInfo"]),
|
||||
)
|
||||
|
||||
def _on_channel_left(self, data: dict[str, JSON]) -> Awaitable[None]:
|
||||
return self.user.on_channel_left(
|
||||
Long.deserialize(data["channelId"]),
|
||||
str(data["channelType"]),
|
||||
)
|
||||
|
||||
def _on_channel_kicked(self, data: dict[str, JSON]) -> Awaitable[None]:
|
||||
return self.user.on_channel_kicked(
|
||||
Long.deserialize(data["userId"]),
|
||||
Long.deserialize(data["senderId"]),
|
||||
Long.deserialize(data["channelId"]),
|
||||
str(data["channelType"]),
|
||||
)
|
||||
|
||||
async def _on_user_join(self, data: dict[str, JSON]) -> None:
|
||||
await self.user.on_user_join(
|
||||
def _on_user_join(self, data: dict[str, JSON]) -> Awaitable[None]:
|
||||
return self.user.on_user_join(
|
||||
Long.deserialize(data["userId"]),
|
||||
Long.deserialize(data["channelId"]),
|
||||
str(data["channelType"]),
|
||||
)
|
||||
|
||||
async def _on_user_left(self, data: dict[str, JSON]) -> None:
|
||||
await self.user.on_user_left(
|
||||
def _on_user_left(self, data: dict[str, JSON]) -> Awaitable[None]:
|
||||
return self.user.on_user_left(
|
||||
Long.deserialize(data["userId"]),
|
||||
Long.deserialize(data["channelId"]),
|
||||
str(data["channelType"]),
|
||||
)
|
||||
|
||||
def _on_channel_meta_change(self, data: dict[str, JSON]) -> Awaitable[None]:
|
||||
return self.user.on_channel_meta_change(
|
||||
PortalChannelInfo.deserialize(data["info"]),
|
||||
Long.deserialize(data["channelId"]),
|
||||
str(data["channelType"]),
|
||||
)
|
||||
|
||||
async def _on_listen_disconnect(self, data: dict[str, JSON]) -> None:
|
||||
|
||||
def _on_listen_disconnect(self, data: dict[str, JSON]) -> Awaitable[None]:
|
||||
try:
|
||||
res = KickoutRes.deserialize(data)
|
||||
except Exception:
|
||||
self.log.exception("Invalid kickout reason, defaulting to None")
|
||||
res = None
|
||||
await self._on_disconnect(res)
|
||||
return self._on_disconnect(res)
|
||||
|
||||
async def _on_switch_server(self) -> None:
|
||||
def _on_switch_server(self) -> Awaitable[None]:
|
||||
# TODO Reconnect automatically instead
|
||||
await self._on_disconnect(KickoutRes(KnownKickoutType.CHANGE_SERVER))
|
||||
return self._on_disconnect(KickoutRes(KnownKickoutType.CHANGE_SERVER))
|
||||
|
||||
async def _on_disconnect(self, res: KickoutRes | None) -> None:
|
||||
def _on_disconnect(self, res: KickoutRes | None) -> Awaitable[None]:
|
||||
self._stop_listen()
|
||||
await self.user.on_disconnect(res)
|
||||
return self.user.on_disconnect(res)
|
||||
|
||||
def _on_error(self, data: dict[str, JSON]) -> Awaitable[None]:
|
||||
return self.user.on_error(data)
|
||||
|
@ -504,11 +563,13 @@ 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)
|
||||
self._add_event_handler("user_join", self._on_user_join)
|
||||
self._add_event_handler("user_left", self._on_user_left)
|
||||
self._add_event_handler("channel_meta_change", self._on_channel_meta_change)
|
||||
self._add_event_handler("disconnected", self._on_listen_disconnect)
|
||||
self._add_event_handler("switch_server", self._on_switch_server)
|
||||
self._add_event_handler("error", self._on_error)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
@ -68,8 +69,9 @@ setattr(UserInfoUnion, "deserialize", deserialize_user_info_union)
|
|||
@dataclass
|
||||
class PortalChannelInfo(SerializableAttrs):
|
||||
name: str
|
||||
participants: list[UserInfoUnion]
|
||||
# TODO Image
|
||||
description: Optional[str] = None
|
||||
photoURL: Optional[str] = None
|
||||
participants: Optional[list[UserInfoUnion]] = None # May set to None to skip participant update
|
||||
channel_info: Optional[ChannelInfoUnion] = None # Should be set manually by caller
|
||||
|
||||
|
||||
|
@ -91,3 +93,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
|
||||
|
|
|
@ -26,6 +26,7 @@ from mautrix.types import (
|
|||
RedactionEvent,
|
||||
RoomID,
|
||||
SingleReceiptEventContent,
|
||||
StateEvent,
|
||||
UserID,
|
||||
)
|
||||
|
||||
|
@ -135,7 +136,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)
|
||||
|
@ -175,3 +176,15 @@ 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 po.Portal.supports_state_event(evt.type):
|
||||
user = await u.User.get_by_mxid(evt.sender)
|
||||
if not user:
|
||||
return
|
||||
|
||||
portal = await po.Portal.get_by_mxid(evt.room_id)
|
||||
if not portal:
|
||||
return
|
||||
|
||||
await portal.handle_matrix_state_event(user, evt)
|
||||
|
|
|
@ -20,6 +20,8 @@ from typing import (
|
|||
Any,
|
||||
AsyncGenerator,
|
||||
Awaitable,
|
||||
Callable,
|
||||
NamedTuple,
|
||||
Pattern,
|
||||
cast,
|
||||
)
|
||||
|
@ -48,8 +50,14 @@ from mautrix.types import (
|
|||
Membership,
|
||||
MessageEventContent,
|
||||
MessageType,
|
||||
PowerLevelStateEventContent,
|
||||
RoomAvatarStateEventContent,
|
||||
RoomNameStateEventContent,
|
||||
RoomTopicStateEventContent,
|
||||
RelationType,
|
||||
RoomID,
|
||||
StateEvent,
|
||||
StateEventContent,
|
||||
TextMessageEventContent,
|
||||
UserID,
|
||||
VideoInfo,
|
||||
|
@ -80,12 +88,16 @@ 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,
|
||||
TO_PERM_MAP,
|
||||
)
|
||||
from .kt.client.errors import CommandException
|
||||
|
||||
|
@ -113,6 +125,14 @@ class FakeLock:
|
|||
pass
|
||||
|
||||
|
||||
class StateEventHandler(NamedTuple):
|
||||
# TODO Can this use Generic to force the two StateEventContent parameters to be of the same type?
|
||||
# Or, just have a single StateEvent parameter
|
||||
apply: Callable[[u.User, StateEventContent, StateEventContent], Awaitable[None]]
|
||||
revert: Callable[[StateEventContent], Awaitable[None]]
|
||||
action_name: str
|
||||
|
||||
|
||||
StateBridge = EventType.find("m.bridge", EventType.Class.STATE)
|
||||
StateHalfShotBridge = EventType.find("uk.half-shot.bridge", EventType.Class.STATE)
|
||||
|
||||
|
@ -142,10 +162,12 @@ class Portal(DBPortal, BasePortal):
|
|||
kt_type: ChannelType,
|
||||
mxid: RoomID | None = None,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
photo_id: str | None = None,
|
||||
avatar_url: ContentURI | None = None,
|
||||
encrypted: bool = False,
|
||||
name_set: bool = False,
|
||||
topic_set: bool = False,
|
||||
avatar_set: bool = False,
|
||||
relay_user_id: UserID | None = None,
|
||||
) -> None:
|
||||
|
@ -155,10 +177,12 @@ class Portal(DBPortal, BasePortal):
|
|||
kt_type,
|
||||
mxid,
|
||||
name,
|
||||
description,
|
||||
photo_id,
|
||||
avatar_url,
|
||||
encrypted,
|
||||
name_set,
|
||||
topic_set,
|
||||
avatar_set,
|
||||
relay_user_id,
|
||||
)
|
||||
|
@ -190,8 +214,7 @@ class Portal(DBPortal, BasePortal):
|
|||
NotificationDisabler.puppet_cls = p.Puppet
|
||||
NotificationDisabler.config_enabled = cls.config["bridge.backfill.disable_notifications"]
|
||||
|
||||
# TODO More
|
||||
cls._chat_type_handler_map = {
|
||||
cls._CHAT_TYPE_HANDLER_MAP = {
|
||||
KnownChatType.FEED: cls._handle_kakaotalk_feed,
|
||||
KnownChatType.TEXT: cls._handle_kakaotalk_text,
|
||||
KnownChatType.REPLY: cls._handle_kakaotalk_reply,
|
||||
|
@ -203,6 +226,33 @@ class Portal(DBPortal, BasePortal):
|
|||
16385: cls._handle_kakaotalk_deleted,
|
||||
}
|
||||
|
||||
cls._STATE_EVENT_HANDLER_MAP: dict[EventType, StateEventHandler] = {
|
||||
EventType.ROOM_POWER_LEVELS: StateEventHandler(
|
||||
cls._handle_matrix_power_levels,
|
||||
cls._revert_matrix_power_levels,
|
||||
"power level"
|
||||
),
|
||||
EventType.ROOM_NAME: StateEventHandler(
|
||||
cls._handle_matrix_room_name,
|
||||
cls._revert_matrix_room_name,
|
||||
"room name"
|
||||
),
|
||||
EventType.ROOM_TOPIC: StateEventHandler(
|
||||
cls._handle_matrix_room_topic,
|
||||
cls._revert_matrix_room_topic,
|
||||
"room topic"
|
||||
),
|
||||
EventType.ROOM_AVATAR: StateEventHandler(
|
||||
cls._handle_matrix_room_avatar,
|
||||
cls._revert_matrix_room_avatar,
|
||||
"room avatar"
|
||||
),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def supports_state_event(cls, evt_type: EventType) -> bool:
|
||||
return evt_type in cls._STATE_EVENT_HANDLER_MAP
|
||||
|
||||
# region DB conversion
|
||||
|
||||
async def delete(self) -> None:
|
||||
|
@ -234,6 +284,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:
|
||||
|
@ -309,16 +363,57 @@ class Portal(DBPortal, BasePortal):
|
|||
changed = any(
|
||||
await asyncio.gather(
|
||||
self._update_name(info.name),
|
||||
# TODO
|
||||
#self._update_photo(source, info.image),
|
||||
self._update_description(info.description),
|
||||
self._update_photo(source, info.photoURL),
|
||||
)
|
||||
)
|
||||
changed = await self._update_participants(source, info.participants) or changed
|
||||
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 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]:
|
||||
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,
|
||||
|
@ -386,23 +481,37 @@ class Portal(DBPortal, BasePortal):
|
|||
return True
|
||||
return False
|
||||
|
||||
"""
|
||||
async def _update_photo(self, source: u.User, photo: graphql.Picture) -> bool:
|
||||
async def _update_description(self, description: str | None) -> bool:
|
||||
if self.description != description or not self.topic_set:
|
||||
self.log.trace("Updating description %s -> %s", self.description, description)
|
||||
self.description = description
|
||||
if self.mxid and (self.encrypted or not self.is_direct):
|
||||
try:
|
||||
await self.main_intent.set_room_topic(self.mxid, self.description)
|
||||
self.topic_set = True
|
||||
except Exception:
|
||||
self.log.exception("Failed to set room description")
|
||||
self.topic_set = False
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _update_photo(self, source: u.User, photo_id: str | None) -> bool:
|
||||
if self.is_direct and not self.encrypted:
|
||||
return False
|
||||
photo_id = self.get_photo_id(photo)
|
||||
if self.photo_id is not None and photo_id is None:
|
||||
self.log.warning("Portal previously had a photo_id, but new photo_id is None. Leaving it as it is")
|
||||
return False
|
||||
if self.photo_id != photo_id or not self.avatar_set:
|
||||
self.photo_id = photo_id
|
||||
if photo:
|
||||
if photo_id:
|
||||
if self.photo_id != photo_id or not self.avatar_url:
|
||||
# Reset avatar_url first in case the upload fails
|
||||
self.avatar_url = None
|
||||
self.avatar_url = await p.Puppet.reupload_avatar(
|
||||
source,
|
||||
self.main_intent,
|
||||
photo.uri,
|
||||
photo_id,
|
||||
self.ktid,
|
||||
use_graph=self.is_direct and (photo.height or 0) < 500,
|
||||
)
|
||||
else:
|
||||
self.avatar_url = ContentURI("")
|
||||
|
@ -415,7 +524,6 @@ class Portal(DBPortal, BasePortal):
|
|||
self.avatar_set = False
|
||||
return True
|
||||
return False
|
||||
"""
|
||||
|
||||
async def _update_photo_from_puppet(self, puppet: p.Puppet) -> bool:
|
||||
if self.photo_id == puppet.photo_id and self.avatar_set:
|
||||
|
@ -632,7 +740,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 +752,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
|
||||
|
@ -809,7 +927,7 @@ class Portal(DBPortal, BasePortal):
|
|||
async def _send_chat(
|
||||
self, sender: u.User, message: TextMessageEventContent, event_id: EventID | None = None
|
||||
) -> Long:
|
||||
converted = await matrix_to_kakaotalk(message, self.mxid, self.log, self.main_intent)
|
||||
converted = await matrix_to_kakaotalk(message, self.mxid, self.log, self)
|
||||
try:
|
||||
return await sender.client.send_chat(
|
||||
self.channel_props,
|
||||
|
@ -930,6 +1048,8 @@ class Portal(DBPortal, BasePortal):
|
|||
sender, _ = await self.get_relay_sender(sender, f"redaction {event_id}")
|
||||
if not sender:
|
||||
raise Exception("not logged in")
|
||||
elif not sender.is_connected:
|
||||
raise Exception("not connected to KakaoTalk chats")
|
||||
message = await DBMessage.get_by_mxid(event_id, self.mxid)
|
||||
if message:
|
||||
if not message.ktid:
|
||||
|
@ -965,6 +1085,131 @@ class Portal(DBPortal, BasePortal):
|
|||
pass
|
||||
"""
|
||||
|
||||
async def handle_matrix_state_event(self, sender: u.User, evt: StateEvent) -> None:
|
||||
try:
|
||||
handler: StateEventHandler = self._STATE_EVENT_HANDLER_MAP[evt.type]
|
||||
except KeyError:
|
||||
# Misses should be guarded by supports_state_event, but handle this just in case
|
||||
self.log.error(f"Skipping Matrix state event {evt.event_id} of unsupported type {evt.type}")
|
||||
return
|
||||
try:
|
||||
effective_sender, _ = await self.get_relay_sender(sender, f"{handler.action_name} {evt.event_id}")
|
||||
await handler.apply(self, effective_sender, evt.prev_content, evt.content)
|
||||
except Exception as e:
|
||||
self.log.error(
|
||||
f"Failed to handle Matrix {handler.action_name} {evt.event_id}: {e}",
|
||||
exc_info=not isinstance(e, NotImplementedError),
|
||||
)
|
||||
sender.send_remote_checkpoint(
|
||||
self._status_from_exception(e),
|
||||
evt.event_id,
|
||||
self.mxid,
|
||||
evt.type,
|
||||
error=e,
|
||||
)
|
||||
change = f"{handler.action_name} change"
|
||||
await self._send_bridge_error(
|
||||
f"{e}. Reverting the {change}...",
|
||||
thing=change
|
||||
)
|
||||
# NOTE Redacting instead doesn't work
|
||||
await handler.revert(self, evt.prev_content)
|
||||
else:
|
||||
await self._send_delivery_receipt(evt.event_id)
|
||||
sender.send_remote_checkpoint(
|
||||
MessageSendCheckpointStatus.SUCCESS,
|
||||
evt.event_id,
|
||||
self.mxid,
|
||||
evt.type,
|
||||
)
|
||||
|
||||
async def _handle_matrix_power_levels(
|
||||
self,
|
||||
sender: u.User,
|
||||
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):
|
||||
continue
|
||||
puppet = await p.Puppet.get_by_mxid(target_mxid)
|
||||
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"
|
||||
)
|
||||
|
||||
async def _revert_matrix_power_levels(self, prev_content: PowerLevelStateEventContent) -> None:
|
||||
await self.main_intent.set_power_levels(self.mxid, prev_content)
|
||||
|
||||
async def _handle_matrix_room_name(
|
||||
self,
|
||||
sender: u.User,
|
||||
prev_content: RoomNameStateEventContent,
|
||||
content: RoomNameStateEventContent,
|
||||
) -> None:
|
||||
if content.name == prev_content.name:
|
||||
return
|
||||
if not (sender and sender.is_connected):
|
||||
raise Exception(
|
||||
"Only users connected to KakaoTalk can set the name of a KakaoTalk channel"
|
||||
)
|
||||
await sender.client.set_channel_name(self.channel_props, content.name)
|
||||
self.name = content.name
|
||||
self.name_set = True
|
||||
await self.save()
|
||||
|
||||
async def _revert_matrix_room_name(self, prev_content: RoomNameStateEventContent) -> None:
|
||||
await self.main_intent.set_room_name(self.mxid, prev_content.name)
|
||||
|
||||
async def _handle_matrix_room_topic(
|
||||
self,
|
||||
sender: u.User,
|
||||
prev_content: RoomTopicStateEventContent,
|
||||
content: RoomTopicStateEventContent,
|
||||
) -> None:
|
||||
if content.topic == prev_content.topic:
|
||||
return
|
||||
if not (sender and sender.is_connected):
|
||||
raise Exception(
|
||||
"Only users connected to KakaoTalk can set the description of a KakaoTalk channel"
|
||||
)
|
||||
await sender.client.set_channel_description(self.channel_props, content.topic)
|
||||
self.description = content.topic
|
||||
self.topic_set = True
|
||||
await self.save()
|
||||
|
||||
async def _revert_matrix_room_topic(self, prev_content: RoomTopicStateEventContent) -> None:
|
||||
await self.main_intent.set_room_topic(self.mxid, prev_content.topic)
|
||||
|
||||
async def _handle_matrix_room_avatar(
|
||||
self,
|
||||
sender: u.User,
|
||||
prev_content: RoomAvatarStateEventContent,
|
||||
content: RoomAvatarStateEventContent,
|
||||
) -> None:
|
||||
if content.url == prev_content.url:
|
||||
return
|
||||
if not (sender and sender.is_connected):
|
||||
raise Exception(
|
||||
"Only users connected to KakaoTalk can set the photo of a KakaoTalk channel"
|
||||
)
|
||||
raise NotImplementedError("Changing the room avatar is not supported by the KakaoTalk bridge.")
|
||||
""" TODO
|
||||
photo_url = str(self.main_intent.api.get_download_url(content.url))
|
||||
await sender.client.set_channel_photo(self.channel_props, photo_url)
|
||||
self.photo_id = photo_url
|
||||
self.avatar_url = content.url
|
||||
self.avatar_set = True
|
||||
await self.save()
|
||||
"""
|
||||
|
||||
async def _revert_matrix_room_avatar(self, prev_content: RoomAvatarStateEventContent) -> None:
|
||||
await self.main_intent.set_room_avatar(self.mxid, prev_content.url)
|
||||
|
||||
async def handle_matrix_leave(self, user: u.User) -> None:
|
||||
if self.is_direct:
|
||||
self.log.info(f"{user.mxid} left private chat portal with {self.ktid}")
|
||||
|
@ -1067,7 +1312,7 @@ class Portal(DBPortal, BasePortal):
|
|||
await intent.ensure_joined(self.mxid)
|
||||
self._backfill_leave.add(intent)
|
||||
|
||||
handler = self._chat_type_handler_map.get(chat.type, Portal._handle_kakaotalk_unsupported)
|
||||
handler = self._CHAT_TYPE_HANDLER_MAP.get(chat.type, Portal._handle_kakaotalk_unsupported)
|
||||
event_ids = [
|
||||
event_id for event_id in
|
||||
await handler(
|
||||
|
|
|
@ -166,6 +166,8 @@ class RPCClient:
|
|||
self._on_disconnect()
|
||||
|
||||
def _on_disconnect(self) -> None:
|
||||
self._req_id = 0
|
||||
self._min_broadcast_id = 0
|
||||
self._reader = None
|
||||
self._writer = None
|
||||
self._is_connected.clear()
|
||||
|
@ -293,11 +295,11 @@ class RPCClient:
|
|||
except:
|
||||
self.log.exception("Failed to handle incoming request %s", line_str)
|
||||
|
||||
async def _raw_request(self, command: str, is_secret: bool = False, **data: JSON) -> asyncio.Future[JSON]:
|
||||
async def _raw_request(self, command: str, **data: JSON) -> asyncio.Future[JSON]:
|
||||
req_id = self._next_req_id
|
||||
future = self._response_waiters[req_id] = self.loop.create_future()
|
||||
req = {"id": req_id, "command": command, **data}
|
||||
self.log.debug("Request %d: %s %s", req_id, command, data if not is_secret else "<REDACTED>")
|
||||
self.log.debug("Request %d: %s", req_id, command)
|
||||
assert self._writer is not None
|
||||
self._writer.write(json.dumps(req).encode("utf-8"))
|
||||
self._writer.write(b"\n")
|
||||
|
|
|
@ -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 PortalChannelInfo, 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
|
||||
|
||||
|
@ -54,7 +55,9 @@ METRIC_CONNECT_AND_SYNC = Summary("bridge_connect_and_sync", "calls to connect_a
|
|||
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_CHANNEL_META_CHANGE = Summary("bridge_on_channel_meta_change", "calls to on_channel_meta_change")
|
||||
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")
|
||||
|
@ -739,12 +742,46 @@ class User(DBUser, BaseUser):
|
|||
await portal.backfill_lock.wait(f"read receipt from {sender_id}")
|
||||
await portal.handle_kakaotalk_chat_read(self, puppet, chat_id)
|
||||
|
||||
@async_time(METRIC_CHANNEL_META_CHANGE)
|
||||
async def on_channel_meta_change(
|
||||
self,
|
||||
info: PortalChannelInfo,
|
||||
channel_id: Long,
|
||||
channel_type: ChannelType,
|
||||
) -> None:
|
||||
portal = await po.Portal.get_by_ktid(
|
||||
channel_id,
|
||||
kt_receiver=self.ktid,
|
||||
kt_type=channel_type,
|
||||
)
|
||||
if portal:
|
||||
await portal.update_info(self, info)
|
||||
|
||||
@async_time(METRIC_PROFILE_CHANGE)
|
||||
async def on_profile_changed(self, info: OpenLinkChannelUserInfo) -> None:
|
||||
puppet = await pu.Puppet.get_by_ktid(info.userId)
|
||||
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)
|
||||
|
|
|
@ -29,6 +29,9 @@ import {
|
|||
/** @typedef {import("node-kakao").MentionStruct} MentionStruct */
|
||||
/** @typedef {import("node-kakao/dist/talk").TalkChannelList} TalkChannelList */
|
||||
|
||||
import pkg from "node-kakao"
|
||||
const { OpenChannelUserPerm } = pkg
|
||||
|
||||
import chat from "node-kakao/chat"
|
||||
const { KnownChatType } = chat
|
||||
|
||||
|
@ -58,6 +61,34 @@ ServiceApiClient.prototype.requestFriendList = async function() {
|
|||
}
|
||||
|
||||
|
||||
class CustomError extends Error {}
|
||||
|
||||
class PermError extends CustomError {
|
||||
/** @type {Map<OpenChannelUserPerm, string> */
|
||||
static #PERM_NAMES = new Map([
|
||||
[OpenChannelUserPerm.OWNER, "the channel owner"],
|
||||
[OpenChannelUserPerm.MANAGER, "channel admininstrators"],
|
||||
[OpenChannelUserPerm.BOT, "bots"],
|
||||
[OpenChannelUserPerm.NONE, "registered KakaoTalk users"],
|
||||
])
|
||||
|
||||
/**
|
||||
* @param {?OpenChannelUserPerm[]} permNeeded
|
||||
* @param {?OpenChannelUserPerm} permActual
|
||||
*/
|
||||
constructor(permNeeded, permActual, action) {
|
||||
const who =
|
||||
!permActual
|
||||
? "In this channel, no one"
|
||||
: "Only " + permNeeded
|
||||
.map(v => PermError.#PERM_NAMES.get(v))
|
||||
.reduce((prev, curr) => prev += ` and ${curr}`)
|
||||
super(`${who} can ${action}`)
|
||||
this.name = this.constructor.name
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class UserClient {
|
||||
static #initializing = false
|
||||
|
||||
|
@ -70,21 +101,23 @@ class UserClient {
|
|||
|
||||
/**
|
||||
* DO NOT CONSTRUCT DIRECTLY. Callers should use {@link UserClient#create} instead.
|
||||
* @param {Long} userId
|
||||
* @param {string} mxid
|
||||
* @param {PeerClient} peerClient TODO Make RPC user-specific instead of needing this
|
||||
*/
|
||||
constructor(mxid, peerClient) {
|
||||
constructor(userId, mxid, peerClient) {
|
||||
if (!UserClient.#initializing) {
|
||||
throw new Error("Private constructor")
|
||||
}
|
||||
UserClient.#initializing = false
|
||||
|
||||
this.userId = userId
|
||||
this.mxid = mxid
|
||||
this.peerClient = peerClient
|
||||
|
||||
this.#talkClient.on("chat", (data, channel) => {
|
||||
this.log(`${data.chat.logId} received in channel ${channel.channelId}`)
|
||||
return this.write("chat", {
|
||||
this.write("chat", {
|
||||
//is_sequential: true, // TODO Make sequential per user & channel (if it isn't already)
|
||||
chatlog: data.chat,
|
||||
channelId: channel.channelId,
|
||||
|
@ -94,7 +127,7 @@ class UserClient {
|
|||
|
||||
this.#talkClient.on("chat_deleted", (feedChatlog, channel, feed) => {
|
||||
this.log(`${feed.logId} deleted in channel ${channel.channelId} by user ${feedChatlog.sender.userId}`)
|
||||
return this.write("chat_deleted", {
|
||||
this.write("chat_deleted", {
|
||||
chatId: feed.logId,
|
||||
senderId: feedChatlog.sender.userId,
|
||||
timestamp: feedChatlog.sendAt,
|
||||
|
@ -105,7 +138,7 @@ class UserClient {
|
|||
|
||||
this.#talkClient.on("message_hidden", (hideLog, channel, feed) => {
|
||||
this.log(`Message ${feed.logId} hid from channel ${channel.channelId} by user ${hideLog.sender.userId}`)
|
||||
return this.write("chat_deleted", {
|
||||
this.write("chat_deleted", {
|
||||
chatId: feed.logId,
|
||||
senderId: hideLog.sender.userId,
|
||||
timestamp: hideLog.sendAt,
|
||||
|
@ -116,7 +149,7 @@ class UserClient {
|
|||
|
||||
this.#talkClient.on("chat_read", (chat, channel, reader) => {
|
||||
this.log(`${chat.logId} read in channel ${channel.channelId} by ${reader.userId}`)
|
||||
return this.write("chat_read", {
|
||||
this.write("chat_read", {
|
||||
chatId: chat.logId,
|
||||
senderId: reader.userId,
|
||||
channelId: channel.channelId,
|
||||
|
@ -126,7 +159,7 @@ class UserClient {
|
|||
|
||||
this.#talkClient.on("profile_changed", (channel, lastInfo, user) => {
|
||||
this.log(`Profile of ${user.userId} changed (channel: ${channel ? channel.channelId : "None"})`)
|
||||
return this.write("profile_changed", {
|
||||
this.write("profile_changed", {
|
||||
info: user,
|
||||
/* TODO Is this ever a per-channel profile change?
|
||||
channelId: channel.channelId,
|
||||
|
@ -135,16 +168,28 @@ 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}`)
|
||||
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", {
|
||||
this.write("channel_join", {
|
||||
channelInfo: channel.info,
|
||||
})
|
||||
})
|
||||
|
||||
this.#talkClient.on("channel_left", channel => {
|
||||
this.log(`Left channel ${channel.channelId}`)
|
||||
return this.write("channel_left", {
|
||||
this.write("channel_left", {
|
||||
channelId: channel.channelId,
|
||||
channelType: channel.info.type,
|
||||
})
|
||||
|
@ -152,7 +197,7 @@ class UserClient {
|
|||
|
||||
this.#talkClient.on("channel_kicked", (kickedLog, channel, feed) => {
|
||||
this.log(`User ${feed.member.userId} kicked from channel ${channel.channelId} by user ${kickedLog.sender.userId}`)
|
||||
return this.write("channel_kicked", {
|
||||
this.write("channel_kicked", {
|
||||
userId: feed.member.userId,
|
||||
senderId: kickedLog.sender.userId,
|
||||
channelId: channel.channelId,
|
||||
|
@ -162,7 +207,7 @@ class UserClient {
|
|||
|
||||
this.#talkClient.on("user_join", (joinLog, channel, user, feed) => {
|
||||
this.log(`User ${user.userId} joined channel ${channel.channelId}`)
|
||||
return this.write("user_join", {
|
||||
this.write("user_join", {
|
||||
userId: user.userId,
|
||||
channelId: channel.channelId,
|
||||
channelType: channel.info.type,
|
||||
|
@ -171,31 +216,58 @@ class UserClient {
|
|||
|
||||
this.#talkClient.on("user_left", (leftLog, channel, user, feed) => {
|
||||
this.log(`User ${user.userId} left channel ${channel.channelId}`)
|
||||
return this.write("user_left", {
|
||||
this.write("user_left", {
|
||||
userId: user.userId,
|
||||
channelId: channel.channelId,
|
||||
channelType: channel.info.type,
|
||||
})
|
||||
})
|
||||
|
||||
this.#talkClient.on("meta_change", (channel, type, newMeta) => {
|
||||
this.log(`Channel ${channel.channelId} metadata changed`)
|
||||
})
|
||||
|
||||
this.#talkClient.on("push_packet", (method, data) => {
|
||||
// TODO Find a better way to do this...but data doesn't have much.
|
||||
if (method == "SYNCLINKUP") {
|
||||
if (!data?.ol) return
|
||||
const linkURL = data.ol?.lu
|
||||
if (!linkURL) return
|
||||
for (const channel of this.#talkClient.channelList.open.all()) {
|
||||
if (channel.info.openLink?.linkURL == linkURL) {
|
||||
this.write("channel_meta_change", {
|
||||
info: {
|
||||
name: data.ol?.ln,
|
||||
description: data.ol?.desc || null,
|
||||
photoURL: data.ol?.liu || null,
|
||||
},
|
||||
channelId: channel.channelId,
|
||||
channelType: channel.info.type,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
this.#talkClient.on("disconnected", (reason) => {
|
||||
this.log(`Disconnected (reason=${reason})`)
|
||||
this.disconnect()
|
||||
return this.write("disconnected", {
|
||||
this.write("disconnected", {
|
||||
reason: reason,
|
||||
})
|
||||
})
|
||||
|
||||
this.#talkClient.on("switch_server", () => {
|
||||
this.log(`Server switch requested`)
|
||||
return this.write("switch_server", {
|
||||
this.write("switch_server", {
|
||||
is_sequential: true,
|
||||
})
|
||||
})
|
||||
|
||||
this.#talkClient.on("error", (err) => {
|
||||
this.log(`Client error: ${err}`)
|
||||
return this.write("error", {
|
||||
this.write("error", {
|
||||
error: err,
|
||||
})
|
||||
})
|
||||
|
@ -208,7 +280,7 @@ class UserClient {
|
|||
*/
|
||||
static async create(mxid, credential, peerClient) {
|
||||
this.#initializing = true
|
||||
const userClient = new UserClient(mxid, peerClient)
|
||||
const userClient = new UserClient(credential.userId, mxid, peerClient)
|
||||
|
||||
userClient.#serviceClient = await ServiceApiClient.create(credential)
|
||||
return userClient
|
||||
|
@ -438,9 +510,20 @@ export default class PeerClient {
|
|||
/**
|
||||
* @param {string} mxid
|
||||
* @param {ChannelProps} channelProps
|
||||
* @param {?OpenChannelUserPerm[]} permNeeded If set, throw if the user's permission level matches none of the values in this list.
|
||||
* @param {?string} action The action requiring permission, to be used in an error message if throwing..
|
||||
* @throws {PermError} if the user does not have the specified permission level.
|
||||
*/
|
||||
async #getUserChannel(mxid, channelProps) {
|
||||
return await this.#getUser(mxid).getChannel(channelProps)
|
||||
async #getUserChannel(mxid, channelProps, permNeeded, action) {
|
||||
const userClient = this.#getUser(mxid)
|
||||
const talkChannel = await userClient.getChannel(channelProps)
|
||||
if (permNeeded) {
|
||||
const permActual = talkChannel.getUserInfo({ userId: userClient.userId }).perm
|
||||
if (permNeeded.indexOf(permActual) == -1) {
|
||||
throw new PermError(permNeeded, permActual, action)
|
||||
}
|
||||
}
|
||||
return talkChannel
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -555,8 +638,10 @@ export default class PeerClient {
|
|||
|
||||
return makeCommandResult({
|
||||
name: talkChannel.getDisplayName(),
|
||||
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()),
|
||||
// TODO Image
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -575,13 +660,23 @@ export default class PeerClient {
|
|||
* @param {string} req.mxid
|
||||
* @param {ChannelProps} req.channel_props
|
||||
* @param {?Long} req.sync_from
|
||||
* @param {?Number} req.limit
|
||||
* @param {?number} req.limit
|
||||
*/
|
||||
getChats = async (req) => {
|
||||
const talkChannel = await this.#getUserChannel(req.mxid, req.channel_props)
|
||||
|
||||
const res = await talkChannel.getChatListFrom(req.sync_from)
|
||||
if (res.success && 0 < req.limit && req.limit < res.result.length) {
|
||||
if (!res.success) return res
|
||||
|
||||
while (true) {
|
||||
const nextRes = await talkChannel.getChatListFrom(
|
||||
res.result[res.result.length - 1].logId
|
||||
)
|
||||
if (!nextRes.success) return nextRes
|
||||
if (!nextRes.result.length) break
|
||||
res.result.push(...nextRes.result)
|
||||
}
|
||||
if (0 < req.limit && req.limit < res.result.length) {
|
||||
res.result.splice(0, res.result.length - req.limit)
|
||||
}
|
||||
return res
|
||||
|
@ -695,6 +790,74 @@ 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) => {
|
||||
const talkChannel = await this.#getUserChannel(
|
||||
req.mxid,
|
||||
req.channel_props,
|
||||
[OpenChannelUserPerm.OWNER],
|
||||
"change user permissions"
|
||||
)
|
||||
return await talkChannel.setUserPerm({ userId: req.user_id }, req.perm)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {string} req.mxid
|
||||
* @param {ChannelProps} req.channel_props
|
||||
* @param {string} req.name
|
||||
*/
|
||||
setChannelName = async (req) => {
|
||||
const talkChannel = await this.#getUserChannel(
|
||||
req.mxid,
|
||||
req.channel_props,
|
||||
[OpenChannelUserPerm.OWNER],
|
||||
"change channel name"
|
||||
)
|
||||
return await talkChannel.setTitleMeta(req.name)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {string} req.mxid
|
||||
* @param {ChannelProps} req.channel_props
|
||||
* @param {string} req.description
|
||||
*/
|
||||
setChannelDescription = async (req) => {
|
||||
const talkChannel = await this.#getUserChannel(
|
||||
req.mxid,
|
||||
req.channel_props,
|
||||
[OpenChannelUserPerm.OWNER],
|
||||
"change channel description"
|
||||
)
|
||||
return await talkChannel.setNoticeMeta(req.description)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {string} req.mxid
|
||||
* @param {ChannelProps} req.channel_props
|
||||
* @param {string} req.photo_url
|
||||
*/
|
||||
setChannelPhoto = async (req) => {
|
||||
const talkChannel = await this.#getUserChannel(
|
||||
req.mxid,
|
||||
req.channel_props,
|
||||
[OpenChannelUserPerm.OWNER],
|
||||
"change channel photo"
|
||||
)
|
||||
return await talkChannel.setProfileMeta({
|
||||
imageUrl: req.photo_url,
|
||||
fullImageUrl: req.photo_url,
|
||||
})
|
||||
}
|
||||
|
||||
handleUnknownCommand = () => {
|
||||
throw new Error("Unknown command")
|
||||
}
|
||||
|
@ -773,6 +936,10 @@ export default class PeerClient {
|
|||
send_media: this.sendMedia,
|
||||
delete_chat: this.deleteChat,
|
||||
mark_read: this.markRead,
|
||||
send_perm: this.sendPerm,
|
||||
set_channel_name: this.setChannelName,
|
||||
set_channel_description: this.setChannelDescription,
|
||||
set_channel_photo: this.setChannelPhoto,
|
||||
}[req.command] || this.handleUnknownCommand
|
||||
}
|
||||
const resp = { id: req.id }
|
||||
|
@ -789,7 +956,7 @@ export default class PeerClient {
|
|||
}
|
||||
} else {
|
||||
resp.command = "error"
|
||||
resp.error = err.toString()
|
||||
resp.error = err instanceof CustomError ? 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
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue