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,