Compare commits

...

1 Commits

Author SHA1 Message Date
8228293202 Outbound joins, and manage OpenLink URLs 2022-05-16 03:10:33 -04:00
12 changed files with 210 additions and 12 deletions

View File

@ -20,7 +20,7 @@
* [x] Power level<sup>[1]</sup> * [x] Power level<sup>[1]</sup>
* [ ] Membership actions * [ ] Membership actions
* [ ] Invite * [ ] Invite
* [ ] Join * [x] Join
* [x] Leave<sup>[3]</sup> * [x] Leave<sup>[3]</sup>
* [ ] Ban<sup>[4]</sup> * [ ] Ban<sup>[4]</sup>
* [ ] Unban<sup>[4]</sup> * [ ] Unban<sup>[4]</sup>
@ -81,6 +81,9 @@
* [ ] Public search * [ ] Public search
* [ ] Max number of participants * [ ] Max number of participants
* [ ] Chatroom code * [ ] 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 * [x] Option to use own Matrix account for messages sent from other KakaoTalk clients
* [ ] KakaoTalk friends list management * [ ] KakaoTalk friends list management
* [x] List friends * [x] List friends

View File

@ -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) 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} <URL>`")
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( @command_handler(
needs_auth=True, needs_auth=True,
management_only=False, management_only=False,
help_section=SECTION_CHANNELS, help_section=SECTION_CHANNELS,
help_text="Leave this KakaoTalk channel", help_text="Leave the current KakaoTalk channel",
) )
async def leave(evt: CommandEvent) -> None: async def leave(evt: CommandEvent) -> None:
if not evt.sender.is_connected: if not evt.sender.is_connected:

View File

@ -45,6 +45,8 @@ class Portal:
name_set: bool name_set: bool
topic_set: bool topic_set: bool
avatar_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) fully_read_kt_chat: Long | None = field(converter=to_optional_long)
relay_user_id: UserID | None relay_user_id: UserID | None
@ -58,7 +60,7 @@ class Portal:
_columns = ( _columns = (
"ktid, kt_receiver, kt_type, mxid, name, description, photo_id, avatar_url, encrypted, " "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 @classmethod
@ -99,6 +101,8 @@ class Portal:
self.encrypted, self.encrypted,
self.name_set, self.name_set,
self.avatar_set, self.avatar_set,
self.link_id,
self.link_url,
self.fully_read_kt_chat, self.fully_read_kt_chat,
self.relay_user_id, self.relay_user_id,
) )

View File

@ -21,3 +21,4 @@ from . import v01_initial_revision
from . import v02_channel_meta from . import v02_channel_meta
from . import v03_user_connection from . import v03_user_connection
from . import v04_read_receipt_sync from . import v04_read_receipt_sync
from . import v05_open_link

View File

@ -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, 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")

View File

@ -19,7 +19,6 @@ Currently a wrapper around a Node backend, but
the abstraction used here should be compatible the abstraction used here should be compatible
with any other potential backend. with any other potential backend.
""" """
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, cast, Awaitable, Type, Optional, Union from typing import TYPE_CHECKING, cast, Awaitable, Type, Optional, Union
@ -518,6 +517,26 @@ class Client:
user_id=ktid.serialize(), 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( def leave_channel(
self, self,
channel_props: ChannelProps, channel_props: ChannelProps,

View File

@ -81,6 +81,8 @@ class PortalChannelInfo(SerializableAttrs):
name: str name: str
description: Optional[str] = None description: Optional[str] = None
photoURL: 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 participantInfo: Optional[PortalChannelParticipantInfo] = None # May set to None to skip participant update
channel_info: Optional[ChannelInfoUnion] = None # Should be set manually by caller channel_info: Optional[ChannelInfoUnion] = None # Should be set manually by caller
@ -89,6 +91,7 @@ class PortalChannelInfo(SerializableAttrs):
class ChannelProps(SerializableAttrs): class ChannelProps(SerializableAttrs):
id: Long id: Long
type: ChannelType type: ChannelType
link_id: Optional[Long]
# TODO Add non-media types, like polls & maps # TODO Add non-media types, like polls & maps

View File

@ -28,7 +28,7 @@ class KnownChannelType(str, Enum):
@classmethod @classmethod
def is_direct(cls, value: Union["KnownChannelType", str]) -> bool: 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 @classmethod
def is_open(cls, value: Union["KnownChannelType", str]) -> bool: def is_open(cls, value: Union["KnownChannelType", str]) -> bool:

View File

@ -105,8 +105,7 @@ class MatrixHandler(BaseMatrixHandler):
) )
return return
self.log.debug(f"{user.mxid} joined {room_id}") await portal.handle_matrix_join(user)
# await portal.join_matrix(user, event_id)
async def handle_leave(self, room_id: RoomID, user_id: UserID, event_id: EventID) -> None: async def handle_leave(self, room_id: RoomID, user_id: UserID, event_id: EventID) -> None:
portal = await po.Portal.get_by_mxid(room_id) portal = await po.Portal.get_by_mxid(room_id)

View File

@ -166,6 +166,8 @@ class Portal(DBPortal, BasePortal):
_CHAT_TYPE_HANDLER_MAP: dict[ChatType, Callable[..., ACallable[list[EventID]]]] _CHAT_TYPE_HANDLER_MAP: dict[ChatType, Callable[..., ACallable[list[EventID]]]]
_STATE_EVENT_HANDLER_MAP: dict[EventType, StateEventHandler] _STATE_EVENT_HANDLER_MAP: dict[EventType, StateEventHandler]
OPEN_LINK_URL_PREFIX = "https://open.kakao.com/o/"
def __init__( def __init__(
self, self,
ktid: Long, ktid: Long,
@ -180,6 +182,8 @@ class Portal(DBPortal, BasePortal):
name_set: bool = False, name_set: bool = False,
topic_set: bool = False, topic_set: bool = False,
avatar_set: bool = False, avatar_set: bool = False,
link_id: Long | None = None,
link_url: str | None = None,
fully_read_kt_chat: Long | None = None, fully_read_kt_chat: Long | None = None,
relay_user_id: UserID | None = None, relay_user_id: UserID | None = None,
) -> None: ) -> None:
@ -196,6 +200,8 @@ class Portal(DBPortal, BasePortal):
name_set, name_set,
topic_set, topic_set,
avatar_set, avatar_set,
link_id,
link_url,
fully_read_kt_chat, fully_read_kt_chat,
relay_user_id, relay_user_id,
) )
@ -315,9 +321,14 @@ class Portal(DBPortal, BasePortal):
def channel_props(self) -> ChannelProps: def channel_props(self) -> ChannelProps:
return ChannelProps( return ChannelProps(
id=self.ktid, 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 @property
def main_intent(self) -> IntentAPI: def main_intent(self) -> IntentAPI:
if not self._main_intent: if not self._main_intent:
@ -378,6 +389,7 @@ class Portal(DBPortal, BasePortal):
self._update_name(info.name), self._update_name(info.name),
self._update_description(info.description), self._update_description(info.description),
self._update_photo(source, info.photoURL), self._update_photo(source, info.photoURL),
self._update_open_link(info.linkId, info.linkURL),
) )
) )
if info.participantInfo: if info.participantInfo:
@ -550,6 +562,24 @@ class Portal(DBPortal, BasePortal):
return True return True
return False 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: async def _update_photo(self, source: u.User, photo_id: str | None) -> bool:
if self.is_direct and not self.encrypted: if self.is_direct and not self.encrypted:
return False return False
@ -1375,6 +1405,13 @@ class Portal(DBPortal, BasePortal):
async def _revert_matrix_room_avatar(self, prev_content: RoomAvatarStateEventContent) -> None: async def _revert_matrix_room_avatar(self, prev_content: RoomAvatarStateEventContent) -> None:
await self.main_intent.set_room_avatar(self.mxid, prev_content.url) 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: async def handle_matrix_leave(self, user: u.User) -> None:
if self.is_direct: if self.is_direct:
self.log.info(f"{user.mxid} left private chat portal with {self.ktid}") self.log.info(f"{user.mxid} left private chat portal with {self.ktid}")

View File

@ -707,6 +707,10 @@ class User(DBUser, BaseUser):
# region Matrix->KakaoTalk commands # 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: async def leave_channel(self, portal: po.Portal) -> None:
await self.client.leave_channel(portal.channel_props) await self.client.leave_channel(portal.channel_props)
await self.on_channel_left(portal.ktid, portal.kt_type) await self.on_channel_left(portal.ktid, portal.kt_type)

View File

@ -30,6 +30,7 @@ import { ReadStreamUtil } from "node-kakao/stream"
/** @typedef {import("node-kakao").MentionStruct} MentionStruct */ /** @typedef {import("node-kakao").MentionStruct} MentionStruct */
/** @typedef {import("node-kakao").TalkNormalChannel} TalkNormalChannel */ /** @typedef {import("node-kakao").TalkNormalChannel} TalkNormalChannel */
/** @typedef {import("node-kakao").TalkOpenChannel} TalkOpenChannel */ /** @typedef {import("node-kakao").TalkOpenChannel} TalkOpenChannel */
/** @typedef {import("node-kakao").OpenLink} OpenLink */
/** @typedef {import("node-kakao/dist/talk").TalkChannelList} TalkChannelList */ /** @typedef {import("node-kakao/dist/talk").TalkChannelList} TalkChannelList */
// TODO Remove once/if some helper type hints are upstreamed // TODO Remove once/if some helper type hints are upstreamed
/** @typedef {import("node-kakao").OpenChannelUserInfo} OpenChannelUserInfo */ /** @typedef {import("node-kakao").OpenChannelUserInfo} OpenChannelUserInfo */
@ -46,6 +47,7 @@ import { emitLines, promisify } from "./util.js"
* @typedef {object} ChannelProps * @typedef {object} ChannelProps
* @property {Long} id * @property {Long} id
* @property {ChannelType} type * @property {ChannelType} type
* @property {?Long} link_id
*/ */
@ -297,8 +299,10 @@ class UserClient {
this.write("channel_meta_change", { this.write("channel_meta_change", {
info: { info: {
name: data.ol?.ln, name: data.ol?.ln,
description: data.ol?.desc || null, description: data.ol?.desc,
photoURL: data.ol?.liu || null, photoURL: data.ol?.liu || null,
linkId: data.ol?.linkId,
linkURL: data.ol?.linkURL,
}, },
channelId: channel.channelId, channelId: channel.channelId,
channelType: channel.info.type, channelType: channel.info.type,
@ -375,7 +379,11 @@ class UserClient {
this.#talkClient.channelList, this.#talkClient.channelList,
channelProps.type 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) { if (!res.success) {
this.error(`Unable to add ${channelProps.type} channel ${channelProps.id}`) this.error(`Unable to add ${channelProps.type} channel ${channelProps.id}`)
throw res throw res
@ -765,11 +773,16 @@ export default class PeerClient {
const res = await talkChannel.updateAll() const res = await talkChannel.updateAll()
if (!res.success) return res if (!res.success) return res
/** @type {?OpenLink} */
const openLink = talkChannel.info.openLink
return makeCommandResult({ return makeCommandResult({
name: talkChannel.getDisplayName(), name: talkChannel.getDisplayName(),
description: talkChannel.info.openLink?.description, description: openLink?.description,
// TODO Find out why linkCoverURL is blank, despite having updated the channel! // TODO Find out why linkCoverURL is blank, despite having updated the channel!
photoURL: talkChannel.info.openLink?.linkCoverURL || null, photoURL: openLink?.linkCoverURL || null,
linkId: openLink?.linkId,
linkURL: openLink?.linkURL,
participantInfo: { participantInfo: {
// TODO Get members from chatON? // TODO Get members from chatON?
participants: Array.from(talkChannel.getAllUserInfo()), 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 {object} req
* @param {string} req.mxid * @param {string} req.mxid
@ -1363,6 +1430,8 @@ export default class PeerClient {
set_channel_description: this.setChannelDescription, set_channel_description: this.setChannelDescription,
//set_channel_photo: this.setChannelPhoto, //set_channel_photo: this.setChannelPhoto,
create_direct_chat: this.createDirectChat, create_direct_chat: this.createDirectChat,
join_channel_by_url: this.joinChannelByURL,
join_channel: this.joinChannel,
leave_channel: this.leaveChannel, leave_channel: this.leaveChannel,
}[req.command] || this.handleUnknownCommand }[req.command] || this.handleUnknownCommand
} }