diff --git a/ROADMAP.md b/ROADMAP.md index 93105ee..1d791a8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -78,6 +78,15 @@ * [ ] For existing long-idled KakaoTalk channels * [ ] For new KakaoTalk channels * [x] Option to use own Matrix account for messages sent from other KakaoTalk clients + * [ ] KakaoTalk friends list management + * [x] List friends + * [ ] Add friend + * [x] By KakaoTalk ID + * [x] By Matrix puppet of KakaoTalk user + * [ ] By phone number + * [x] Remove friend + * [ ] Manage favourite friends + * [ ] Manage hidden friends [1] Sometimes fails with "Invalid body" error [2] Only recently-sent KakaoTalk messages can be deleted diff --git a/matrix_appservice_kakaotalk/commands/kakaotalk.py b/matrix_appservice_kakaotalk/commands/kakaotalk.py index 5e02ab3..8b70e3d 100644 --- a/matrix_appservice_kakaotalk/commands/kakaotalk.py +++ b/matrix_appservice_kakaotalk/commands/kakaotalk.py @@ -19,8 +19,17 @@ from typing import TYPE_CHECKING import asyncio from mautrix.bridge.commands import HelpSection, command_handler +from mautrix.util import utf16_surrogate +from mautrix.util.formatter import ( + EntityString, + EntityType, + MarkdownString, + MatrixParser, + SimpleEntity, +) from ..kt.types.api.struct import ApiUserType +from ..rpc.types import RPCError from .. import puppet as pu, user as u from .typehint import CommandEvent @@ -31,7 +40,10 @@ SECTION_FRIENDS = HelpSection("Friends management", 40, "") SECTION_CHANNELS = HelpSection("Channel management", 45, "") if TYPE_CHECKING: + from mautrix.types import UserID + from ..kt.types.api.struct import FriendStruct + from ..kt.types.bson import Long async def _get_search_result_puppet(source: u.User, friend_struct: FriendStruct) -> pu.Puppet: @@ -70,6 +82,112 @@ async def list_friends(evt: CommandEvent) -> None: await evt.reply("No friends found.") +class MentionFormatString(EntityString[SimpleEntity, EntityType], MarkdownString): + def format(self, entity_type: EntityType, **kwargs) -> MentionFormatString: + if entity_type == EntityType.USER_MENTION: + self.entities.append( + SimpleEntity( + type=entity_type, + offset=0, + length=len(self.text), + extra_info={"user_id": kwargs["user_id"]}, + ) + ) + return self + +class MentionParser(MatrixParser[MentionFormatString]): + fs = MentionFormatString + +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 + puppet = await pu.Puppet.get_by_mxid(mxid, create=False) + return puppet.ktid if puppet else None + + +@command_handler( + needs_auth=True, + management_only=False, + help_section=SECTION_FRIENDS, + help_text="Add a KakaoTalk user to your KakaoTalk friends list", + help_args="<_KakaoTalk ID_|_Matrix user ID_>", +) +async def add_friend(evt: CommandEvent) -> None: + await _edit_friend(evt, True) + +@command_handler( + needs_auth=True, + management_only=False, + help_section=SECTION_FRIENDS, + help_text="Remove a KakaoTalk user from your KakaoTalk friends list", + help_args="<_KakaoTalk ID_|_Matrix user ID_>", +) +async def remove_friend(evt: CommandEvent) -> None: + await _edit_friend(evt, False) + +async def _edit_friend(evt: CommandEvent, add: bool) -> None: + if not evt.args: + await evt.reply(f"**Usage:** `$cmdprefix+sp {evt.command} `") + return + formatted_body = evt.content.get("formatted_body") + if formatted_body: + arg = formatted_body[len(evt.command):].strip() + parsed = await MentionParser().parse(utf16_surrogate.add(arg)) + if not parsed.entities: + await evt.reply("No user found") + return + if ( + len(parsed.entities) > 1 or + parsed.entities[0].offset != 0 or + parsed.entities[0].length != len(utf16_surrogate.remove(parsed.text)) + ): + await evt.reply("Can add only one friend at a time") + return + mxid = parsed.entities[0].extra_info["user_id"] + ktid = await _get_id_from_mxid(mxid) + if not ktid: + await evt.reply("No KakaoTalk user found for this Matrix ID") + else: + await _edit_friend_by_ktid(evt, ktid, add) + else: + arg = evt.content.body[len(evt.command):].strip() + ktid = await _get_id_from_mxid(arg) + if ktid: + await _edit_friend_by_ktid(evt, ktid, add) + else: + await _edit_friend_by_uuid(evt, arg, add) + +async def _edit_friend_by_ktid(evt: CommandEvent, ktid: Long, add: bool) -> None: + try: + friend_struct = await evt.sender.client.edit_friend(ktid, add) + except RPCError as e: + await evt.reply(str(e)) + else: + await _on_friend_edited(evt, friend_struct, add) + +async def _edit_friend_by_uuid(evt: CommandEvent, uuid: str, add: bool) -> None: + try: + friend_struct = await evt.sender.client.edit_friend_by_uuid(uuid, add) + except RPCError as e: + await evt.reply(str(e)) + except CommandException as e: + if e.status == -1002: + await evt.reply( + f"Failed to {'add' if add else 'remove'} friend. Ensure their ID is spelled correctly." + ) + else: + raise + else: + await _on_friend_edited(evt, friend_struct, add) + +async def _on_friend_edited(evt: CommandEvent, friend_struct: FriendStruct | None, add: bool): + await evt.reply(f"Friend {'added' if add else 'removed'}") + if friend_struct: + puppet = await pu.Puppet.get_by_ktid(friend_struct.userId) + await puppet.update_info_from_friend(evt.sender, friend_struct) + + @command_handler( needs_auth=True, management_only=False, diff --git a/matrix_appservice_kakaotalk/kt/client/client.py b/matrix_appservice_kakaotalk/kt/client/client.py index 020bb91..e4783a4 100644 --- a/matrix_appservice_kakaotalk/kt/client/client.py +++ b/matrix_appservice_kakaotalk/kt/client/client.py @@ -330,6 +330,32 @@ class Client: "list_friends", ) + async def edit_friend(self, ktid: Long, add: bool) -> FriendStruct | None: + try: + friend_req_struct = await self._api_user_request_result( + FriendReqStruct, + "edit_friend", + user_id=ktid.serialize(), + add=add, + ) + return friend_req_struct.friend + except SerializerError: + self.log.exception("Unable to deserialize friend struct, but friend should have been edited nonetheless") + return None + + async def edit_friend_by_uuid(self, uuid: str, add: bool) -> FriendStruct | None: + try: + friend_req_struct = await self._api_user_request_result( + FriendReqStruct, + "edit_friend_by_uuid", + uuid=uuid, + add=add, + ) + return friend_req_struct.friend + except SerializerError: + self.log.exception("Unable to deserialize friend struct, but friend should have been edited nonetheless") + return None + async def get_friend_dm_id(self, friend_id: Long) -> Long | None: try: return await self._api_user_request_result( diff --git a/node/src/client.js b/node/src/client.js index 8024007..7cada60 100644 --- a/node/src/client.js +++ b/node/src/client.js @@ -842,6 +842,57 @@ export default class PeerClient { return await this.#getUser(req.mxid).serviceClient.requestFriendList() } + /** + * @param {Object} req + * @param {string} req.mxid + * @param {Long} req.user_id + * @param {boolean} req.add + */ + editFriend = async (req) => { + return await this.#editFriend( + this.#getUser(req.mxid).serviceClient, + req.user_id, + req.add + ) + } + + /** + * @param {Object} req + * @param {string} req.mxid + * @param {string} req.uuid + * @param {boolean} req.add + */ + editFriendByUUID = async (req) => { + const serviceClient = this.#getUser(req.mxid).serviceClient + + const res = await serviceClient.findFriendByUUID(req.uuid) + if (!res.success) return res + + return await this.#editFriend( + serviceClient, + res.result.member.userId instanceof Long + ? res.result.member.userId + : Long.fromNumber(res.result.member.userId), + req.add + ) + } + + /** + * @param {ServiceApiClient} serviceClient + * @param {Long} id + * @param {boolean} add + */ + async #editFriend(serviceClient, id, add) { + const listRes = await serviceClient.requestFriendList() + if (listRes.success) { + const isFriend = -1 != listRes.result.friends.findIndex(friend => id.equals(friend.userId)) + if (isFriend == add) { + throw new ProtocolError(`User is already ${add ? "in" : "absent from"} friends list`) + } + } + return add ? await serviceClient.addFriend(id) : await serviceClient.removeFriend(id) + } + /** * @param {Object} req * @param {string} req.mxid The user whose friend is being looked up. @@ -1124,6 +1175,8 @@ export default class PeerClient { get_chats: this.getChats, get_read_receipts: this.getReadReceipts, list_friends: this.listFriends, + edit_friend: this.editFriend, + edit_friend_by_uuid: this.editFriendByUUID, get_friend_dm_id: req => this.getFriendProperty(req, "directChatId"), get_memo_ids: this.getMemoIds, download_file: this.downloadFile,