From a07f719495b442c9391b4469c2b15b276eb9ed85 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Mon, 16 May 2022 02:23:52 -0400 Subject: [PATCH] Outbound joins, and manage OpenLink URLs --- ROADMAP.md | 5 +- .../commands/kakaotalk.py | 37 ++++++++- matrix_appservice_kakaotalk/db/portal.py | 6 +- .../db/upgrade/__init__.py | 1 + .../db/upgrade/v05_open_link.py | 24 ++++++ .../kt/client/client.py | 21 ++++- .../kt/client/types.py | 3 + .../kt/types/channel/channel_type.py | 2 +- matrix_appservice_kakaotalk/matrix.py | 3 +- matrix_appservice_kakaotalk/portal.py | 39 +++++++++- matrix_appservice_kakaotalk/user.py | 4 + node/src/client.js | 77 ++++++++++++++++++- 12 files changed, 210 insertions(+), 12 deletions(-) create mode 100644 matrix_appservice_kakaotalk/db/upgrade/v05_open_link.py diff --git a/ROADMAP.md b/ROADMAP.md index d76963e..22fc180 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -20,7 +20,7 @@ * [x] Power level[1] * [ ] Membership actions * [ ] Invite - * [ ] Join + * [x] Join * [x] Leave[3] * [ ] Ban[4] * [ ] Unban[4] @@ -81,6 +81,9 @@ * [ ] Public search * [ ] Max number of participants * [ ] Chatroom code + * [x] Display Open Chat public URL + * [x] Join Open Chat via public URL + * [ ] Join passcode-protected Open Chat * [x] Option to use own Matrix account for messages sent from other KakaoTalk clients * [ ] KakaoTalk friends list management * [x] List friends diff --git a/matrix_appservice_kakaotalk/commands/kakaotalk.py b/matrix_appservice_kakaotalk/commands/kakaotalk.py index a2348b1..2637e77 100644 --- a/matrix_appservice_kakaotalk/commands/kakaotalk.py +++ b/matrix_appservice_kakaotalk/commands/kakaotalk.py @@ -301,11 +301,46 @@ async def _on_friend_edited(evt: CommandEvent, friend_struct: FriendStruct | Non await puppet.update_info_from_friend(evt.sender, friend_struct) +@command_handler( + management_only=False, + help_section=SECTION_CHANNELS, + help_text="If the current KakaoTalk channel is an Open Chat, display its URL", +) +async def get_url(evt: CommandEvent) -> None: + if not evt.is_portal: + await evt.reply("This command may only be used in a KakaoTalk channel portal room") + return + await evt.reply( + evt.portal.full_link_url or "This channel has no URL." + if evt.portal.is_open else "This channel is not an Open Chat." + ) + +@command_handler( + needs_auth=True, + management_only=True, + help_section=SECTION_CHANNELS, + help_text="Join a KakaoTalk Open Chat", + help_args="<_URL_>", +) +async def join(evt: CommandEvent) -> None: + if len(evt.args) != 1: + await evt.reply(f"**Usage:** `$cmdprefix+sp {evt.command} `") + return + if not evt.sender.is_connected: + await evt.reply("You are not connected to KakaoTalk chats") + return + await evt.mark_read() + try: + await evt.sender.join_channel(evt.args[0]) + except CommandException as e: + await evt.reply(f"Error from KakaoTalk: {e}") + + @command_handler( needs_auth=True, management_only=False, help_section=SECTION_CHANNELS, - help_text="Leave this KakaoTalk channel", + help_text="Leave the current KakaoTalk channel", ) async def leave(evt: CommandEvent) -> None: if not evt.sender.is_connected: diff --git a/matrix_appservice_kakaotalk/db/portal.py b/matrix_appservice_kakaotalk/db/portal.py index 221675e..9284dfc 100644 --- a/matrix_appservice_kakaotalk/db/portal.py +++ b/matrix_appservice_kakaotalk/db/portal.py @@ -45,6 +45,8 @@ class Portal: name_set: bool topic_set: bool avatar_set: bool + link_id: Long | None = field(converter=to_optional_long) + link_url: str | None fully_read_kt_chat: Long | None = field(converter=to_optional_long) relay_user_id: UserID | None @@ -58,7 +60,7 @@ class Portal: _columns = ( "ktid, kt_receiver, kt_type, mxid, name, description, photo_id, avatar_url, encrypted, " - "name_set, avatar_set, fully_read_kt_chat, relay_user_id" + "name_set, avatar_set, link_id, link_url, fully_read_kt_chat, relay_user_id" ) @classmethod @@ -99,6 +101,8 @@ class Portal: self.encrypted, self.name_set, self.avatar_set, + self.link_id, + self.link_url, self.fully_read_kt_chat, self.relay_user_id, ) diff --git a/matrix_appservice_kakaotalk/db/upgrade/__init__.py b/matrix_appservice_kakaotalk/db/upgrade/__init__.py index a79510b..9a43c1e 100644 --- a/matrix_appservice_kakaotalk/db/upgrade/__init__.py +++ b/matrix_appservice_kakaotalk/db/upgrade/__init__.py @@ -21,3 +21,4 @@ from . import v01_initial_revision from . import v02_channel_meta from . import v03_user_connection from . import v04_read_receipt_sync +from . import v05_open_link diff --git a/matrix_appservice_kakaotalk/db/upgrade/v05_open_link.py b/matrix_appservice_kakaotalk/db/upgrade/v05_open_link.py new file mode 100644 index 0000000..492684b --- /dev/null +++ b/matrix_appservice_kakaotalk/db/upgrade/v05_open_link.py @@ -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 . +from mautrix.util.async_db import Connection, Scheme + +from . import upgrade_table + + +@upgrade_table.register(description="Track OpenChannel public link IDs and URLs") +async def upgrade_v5(conn: Connection) -> None: + await conn.execute("ALTER TABLE portal ADD COLUMN link_id BIGINT") + await conn.execute("ALTER TABLE portal ADD COLUMN link_url TEXT") diff --git a/matrix_appservice_kakaotalk/kt/client/client.py b/matrix_appservice_kakaotalk/kt/client/client.py index ff4f90f..6da565e 100644 --- a/matrix_appservice_kakaotalk/kt/client/client.py +++ b/matrix_appservice_kakaotalk/kt/client/client.py @@ -19,7 +19,6 @@ Currently a wrapper around a Node backend, but the abstraction used here should be compatible with any other potential backend. """ - from __future__ import annotations from typing import TYPE_CHECKING, cast, Awaitable, Type, Optional, Union @@ -518,6 +517,26 @@ class Client: user_id=ktid.serialize(), ) + def join_channel_by_url(self, url: str) -> Awaitable[Long]: + return self._api_user_request_result( + Long, + "join_channel_by_url", + url=url, + ) + + async def join_channel( + self, + channel_id: Long, + link_id: Long, + ) -> None: + joined_id = await self._api_user_request_result( + Long, + "join_channel", + channel_id=channel_id.serialize(), + link_id=link_id.serialize(), + ) + assert channel_id == joined_id, f"Mismatch of joined channel ID: expected {channel_id}, got {joined_id}" + def leave_channel( self, channel_props: ChannelProps, diff --git a/matrix_appservice_kakaotalk/kt/client/types.py b/matrix_appservice_kakaotalk/kt/client/types.py index 1a4e193..22749e6 100644 --- a/matrix_appservice_kakaotalk/kt/client/types.py +++ b/matrix_appservice_kakaotalk/kt/client/types.py @@ -81,6 +81,8 @@ class PortalChannelInfo(SerializableAttrs): name: str description: Optional[str] = None photoURL: Optional[str] = None + linkId: Optional[Long] = None + linkURL: Optional[str] = None participantInfo: Optional[PortalChannelParticipantInfo] = None # May set to None to skip participant update channel_info: Optional[ChannelInfoUnion] = None # Should be set manually by caller @@ -89,6 +91,7 @@ class PortalChannelInfo(SerializableAttrs): class ChannelProps(SerializableAttrs): id: Long type: ChannelType + link_id: Optional[Long] # TODO Add non-media types, like polls & maps diff --git a/matrix_appservice_kakaotalk/kt/types/channel/channel_type.py b/matrix_appservice_kakaotalk/kt/types/channel/channel_type.py index 10b0424..ba09a30 100644 --- a/matrix_appservice_kakaotalk/kt/types/channel/channel_type.py +++ b/matrix_appservice_kakaotalk/kt/types/channel/channel_type.py @@ -28,7 +28,7 @@ class KnownChannelType(str, Enum): @classmethod def is_direct(cls, value: Union["KnownChannelType", str]) -> bool: - return value in [cls.DirectChat, cls.MemoChat] + return value in [cls.DirectChat, cls.MemoChat, cls.OD] @classmethod def is_open(cls, value: Union["KnownChannelType", str]) -> bool: diff --git a/matrix_appservice_kakaotalk/matrix.py b/matrix_appservice_kakaotalk/matrix.py index d2216de..bdd0743 100644 --- a/matrix_appservice_kakaotalk/matrix.py +++ b/matrix_appservice_kakaotalk/matrix.py @@ -105,8 +105,7 @@ class MatrixHandler(BaseMatrixHandler): ) return - self.log.debug(f"{user.mxid} joined {room_id}") - # await portal.join_matrix(user, event_id) + await portal.handle_matrix_join(user) async def handle_leave(self, room_id: RoomID, user_id: UserID, event_id: EventID) -> None: portal = await po.Portal.get_by_mxid(room_id) diff --git a/matrix_appservice_kakaotalk/portal.py b/matrix_appservice_kakaotalk/portal.py index e569152..64f6f32 100644 --- a/matrix_appservice_kakaotalk/portal.py +++ b/matrix_appservice_kakaotalk/portal.py @@ -166,6 +166,8 @@ class Portal(DBPortal, BasePortal): _CHAT_TYPE_HANDLER_MAP: dict[ChatType, Callable[..., ACallable[list[EventID]]]] _STATE_EVENT_HANDLER_MAP: dict[EventType, StateEventHandler] + OPEN_LINK_URL_PREFIX = "https://open.kakao.com/o/" + def __init__( self, ktid: Long, @@ -180,6 +182,8 @@ class Portal(DBPortal, BasePortal): name_set: bool = False, topic_set: bool = False, avatar_set: bool = False, + link_id: Long | None = None, + link_url: str | None = None, fully_read_kt_chat: Long | None = None, relay_user_id: UserID | None = None, ) -> None: @@ -196,6 +200,8 @@ class Portal(DBPortal, BasePortal): name_set, topic_set, avatar_set, + link_id, + link_url, fully_read_kt_chat, relay_user_id, ) @@ -315,9 +321,14 @@ class Portal(DBPortal, BasePortal): def channel_props(self) -> ChannelProps: return ChannelProps( id=self.ktid, - type=self.kt_type + type=self.kt_type, + link_id=self.link_id, ) + @property + def full_link_url(self) -> str: + return self.OPEN_LINK_URL_PREFIX + self.link_url if self.link_url else "" + @property def main_intent(self) -> IntentAPI: if not self._main_intent: @@ -378,6 +389,7 @@ class Portal(DBPortal, BasePortal): self._update_name(info.name), self._update_description(info.description), self._update_photo(source, info.photoURL), + self._update_open_link(info.linkId, info.linkURL), ) ) if info.participantInfo: @@ -550,6 +562,24 @@ class Portal(DBPortal, BasePortal): return True return False + async def _update_open_link(self, link_id: Long | None, link_url: str | None) -> bool: + changed = False + if self.link_id != link_id: + self.log.trace(f"Updating OpenLink ID {self.link_id} -> {link_id}") + self.link_id = link_id + changed = True + if self.link_url != link_url: + if link_url: + if not link_url.startswith(self.OPEN_LINK_URL_PREFIX): + self.log.error(f"Unexpected prefix for OpenLink URL {link_url}") + link_url = None + else: + link_url = link_url.removeprefix(self.OPEN_LINK_URL_PREFIX) + self.log.trace(f"Updating OpenLink URL {self.link_url} -> {link_url}") + self.link_url = link_url + changed = True + return changed + async def _update_photo(self, source: u.User, photo_id: str | None) -> bool: if self.is_direct and not self.encrypted: return False @@ -1375,6 +1405,13 @@ class Portal(DBPortal, BasePortal): 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_join(self, user: u.User) -> None: + if self.link_id: + try: + await user.client.join_channel(self.ktid, self.link_id) + except Exception as e: + await self.main_intent.kick_user(self.mxid, user.mxid, str(e)) + 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}") diff --git a/matrix_appservice_kakaotalk/user.py b/matrix_appservice_kakaotalk/user.py index b693246..e1c92ff 100644 --- a/matrix_appservice_kakaotalk/user.py +++ b/matrix_appservice_kakaotalk/user.py @@ -707,6 +707,10 @@ class User(DBUser, BaseUser): # region Matrix->KakaoTalk commands + async def join_channel(self, url: str) -> None: + await self.client.join_channel_by_url(url) + # TODO Get channel ID(s) and sync + async def leave_channel(self, portal: po.Portal) -> None: await self.client.leave_channel(portal.channel_props) await self.on_channel_left(portal.ktid, portal.kt_type) diff --git a/node/src/client.js b/node/src/client.js index 15b71f5..b9f4366 100644 --- a/node/src/client.js +++ b/node/src/client.js @@ -30,6 +30,7 @@ import { ReadStreamUtil } from "node-kakao/stream" /** @typedef {import("node-kakao").MentionStruct} MentionStruct */ /** @typedef {import("node-kakao").TalkNormalChannel} TalkNormalChannel */ /** @typedef {import("node-kakao").TalkOpenChannel} TalkOpenChannel */ +/** @typedef {import("node-kakao").OpenLink} OpenLink */ /** @typedef {import("node-kakao/dist/talk").TalkChannelList} TalkChannelList */ // TODO Remove once/if some helper type hints are upstreamed /** @typedef {import("node-kakao").OpenChannelUserInfo} OpenChannelUserInfo */ @@ -46,6 +47,7 @@ import { emitLines, promisify } from "./util.js" * @typedef {object} ChannelProps * @property {Long} id * @property {ChannelType} type + * @property {?Long} link_id */ @@ -297,8 +299,10 @@ class UserClient { this.write("channel_meta_change", { info: { name: data.ol?.ln, - description: data.ol?.desc || null, + description: data.ol?.desc, photoURL: data.ol?.liu || null, + linkId: data.ol?.linkId, + linkURL: data.ol?.linkURL, }, channelId: channel.channelId, channelType: channel.info.type, @@ -375,7 +379,11 @@ class UserClient { this.#talkClient.channelList, channelProps.type ) - const res = await channelList.addChannel({ channelId: channelProps.id }) + let res = await channelList.addChannel({ channelId: channelProps.id }) + if (!res.success && channelProps.link_id) { + res = await this.#talkClient.channelList.open.joinChannel({ linkId: channelProps.link_id }, {}) + } + if (!res.success) { this.error(`Unable to add ${channelProps.type} channel ${channelProps.id}`) throw res @@ -765,11 +773,16 @@ export default class PeerClient { const res = await talkChannel.updateAll() if (!res.success) return res + /** @type {?OpenLink} */ + const openLink = talkChannel.info.openLink + return makeCommandResult({ name: talkChannel.getDisplayName(), - description: talkChannel.info.openLink?.description, + description: openLink?.description, // TODO Find out why linkCoverURL is blank, despite having updated the channel! - photoURL: talkChannel.info.openLink?.linkCoverURL || null, + photoURL: openLink?.linkCoverURL || null, + linkId: openLink?.linkId, + linkURL: openLink?.linkURL, participantInfo: { // TODO Get members from chatON? participants: Array.from(talkChannel.getAllUserInfo()), @@ -1254,6 +1267,60 @@ export default class PeerClient { } } + /** + * @param {object} req + * @param {string} req.mxid + * @param {string} req.url + */ + joinChannelByURL = async (req) => { + const channelList = this.#getUser(req.mxid).talkClient.channelList.open + + const inviteRes = await channelList.getJoinInfo(req.url) + if (!inviteRes.success) throw inviteRes + + const channelIds = channelList.getLinkChannelList(inviteRes.result.openLink.linkId) + if (channelIds.length == 0) { + throw new ProtocolError(`No channel found for OpenLink URL ${req.url}`) + } + if (channelIds.length > 1) { + this.log(`Multiple channels found for OpenLink URL ${req.url}: ${channelIds.join(", ")}`) + } + for (const channelId of channelList.getLinkChannelList(inviteRes.result.openLink.linkId)) { + if (channelList.get(channelId)) { + this.log(`Already joined channel ${channelId}`) + continue + } + const joinRes = await channelList.joinChannel(inviteRes.result.openLink, {}) + if (!joinRes.success) { + this.error(`Failed to join channel ${channelId} via ${inviteRes.result.openLink.linkId}`) + } else { + this.log(`Joined channel ${channelId} via ${inviteRes.result.openLink.linkId}`) + } + } + // TODO Consider returning ID of each joined channel + } + + /** + * @param {object} req + * @param {string} req.mxid + * @param {Long} req.channel_id + * @param {Long} req.link_id + */ + joinChannel = async (req) => { + const channelList = this.#getUser(req.mxid).talkClient.channelList.open + let talkChannel = channelList.get(req.channel_id) + if (talkChannel) { + this.log(`Already joined channel ${channelId}`) + } else { + const joinRes = await channelList.joinChannel({ linkId: req.link_id }, {}) + if (!joinRes.success) return joinRes + + this.log(`Joined channel ${channelId} via ${req.link_id}`) + talkChannel = joinRes.result + } + return makeCommandResult(talkChannel.channelId) + } + /** * @param {object} req * @param {string} req.mxid @@ -1363,6 +1430,8 @@ export default class PeerClient { set_channel_description: this.setChannelDescription, //set_channel_photo: this.setChannelPhoto, create_direct_chat: this.createDirectChat, + join_channel_by_url: this.joinChannelByURL, + join_channel: this.joinChannel, leave_channel: this.leaveChannel, }[req.command] || this.handleUnknownCommand }