Compare commits
1 Commits
Author | SHA1 | Date |
---|---|---|
Andrew Ferrazzutti | a07f719495 |
|
@ -20,7 +20,7 @@
|
|||
* [x] Power level<sup>[1]</sup>
|
||||
* [ ] Membership actions
|
||||
* [ ] Invite
|
||||
* [ ] Join
|
||||
* [x] Join
|
||||
* [x] Leave<sup>[3]</sup>
|
||||
* [ ] Ban<sup>[4]</sup>
|
||||
* [ ] Unban<sup>[4]</sup>
|
||||
|
@ -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
|
||||
|
|
|
@ -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} <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(
|
||||
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:
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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}")
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -22,11 +22,6 @@ import sd from "systemd-daemon"
|
|||
import ClientManager from "./clientmanager.js"
|
||||
|
||||
|
||||
throw Error(
|
||||
"Using this bridge may currently cause your KakaoTalk to be BANNED! " +
|
||||
"If you wish to use it anyways, please remove this error from source code first."
|
||||
)
|
||||
|
||||
const args = arg({
|
||||
"--config": String,
|
||||
"-c": "--config",
|
||||
|
|
Loading…
Reference in New Issue