diff --git a/matrix_appservice_kakaotalk/commands/__init__.py b/matrix_appservice_kakaotalk/commands/__init__.py index 7d42116..f92dfd5 100644 --- a/matrix_appservice_kakaotalk/commands/__init__.py +++ b/matrix_appservice_kakaotalk/commands/__init__.py @@ -1,2 +1,3 @@ -from .auth import SECTION_AUTH#, enter_2fa_code +from .auth import SECTION_AUTH from .conn import SECTION_CONNECTION +from .kakaotalk import SECTION_FRIENDS diff --git a/matrix_appservice_kakaotalk/commands/kakaotalk.py b/matrix_appservice_kakaotalk/commands/kakaotalk.py new file mode 100644 index 0000000..3f149d4 --- /dev/null +++ b/matrix_appservice_kakaotalk/commands/kakaotalk.py @@ -0,0 +1,69 @@ +# 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 __future__ import annotations + +from typing import TYPE_CHECKING +import asyncio + +from mautrix.bridge.commands import HelpSection, command_handler + +from ..kt.types.api.struct import ApiUserType + +from .. import puppet as pu, user as u +from .typehint import CommandEvent + +from ..kt.client.errors import CommandException + +SECTION_FRIENDS = HelpSection("Friends management", 40, "") + +if TYPE_CHECKING: + from ..kt.types.api.struct import FriendStruct + + +async def _get_search_result_puppet(source: u.User, friend_struct: FriendStruct) -> pu.Puppet: + puppet = await pu.Puppet.get_by_ktid(friend_struct.userId) + if not puppet.name_set: + await puppet.update_info_from_friend(source, friend_struct) + return puppet + + +@command_handler( + needs_auth=True, + management_only=False, + help_section=SECTION_FRIENDS, + help_text="List all KakaoTalk friends", +) +async def list_friends(evt: CommandEvent) -> None: + try: + resp = await evt.sender.client.list_friends() + await evt.mark_read() + except CommandException as e: + await evt.reply(f"Error while listing friends: {e!s}") + return + puppets = await asyncio.gather( + *[ + _get_search_result_puppet(evt.sender, friend_struct) + for friend_struct in resp.friends if friend_struct.userType == ApiUserType.NORMAL + # NOTE Using NORMAL to avoid listing KakaoTalk bots, which are apparently PLUS users + ] + ) + results = "".join( + f"* [{puppet.name}](https://matrix.to/#/{puppet.default_mxid})\n" for puppet in puppets + ) + if results: + await evt.reply(f"{results}") + else: + await evt.reply("No friends found.") diff --git a/matrix_appservice_kakaotalk/kt/client/client.py b/matrix_appservice_kakaotalk/kt/client/client.py index 17b7e2b..7b4577d 100644 --- a/matrix_appservice_kakaotalk/kt/client/client.py +++ b/matrix_appservice_kakaotalk/kt/client/client.py @@ -37,6 +37,7 @@ from ...config import Config from ...rpc import RPCClient from ..types.api.struct.profile import ProfileReqStruct, ProfileStruct +from ..types.api.struct import FriendListStruct from ..types.bson import Long from ..types.client.client_session import LoginResult from ..types.chat.chat import Chatlog @@ -230,6 +231,12 @@ class Client: limit=limit ) + async def list_friends(self) -> FriendListStruct: + return await self._api_user_request_result( + FriendListStruct, + "list_friends", + ) + async def send_message(self, channel_props: ChannelProps, text: str) -> Chatlog: return await self._api_user_request_result( Chatlog, diff --git a/matrix_appservice_kakaotalk/kt/types/api/struct/__init__.py b/matrix_appservice_kakaotalk/kt/types/api/struct/__init__.py index 06fa069..eb92a23 100644 --- a/matrix_appservice_kakaotalk/kt/types/api/struct/__init__.py +++ b/matrix_appservice_kakaotalk/kt/types/api/struct/__init__.py @@ -13,6 +13,8 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -""" +##from .login import * +##from .account import * from .profile import * -""" +from .friends import * +##from .openlink import * diff --git a/matrix_appservice_kakaotalk/kt/types/api/struct/friends/__init__.py b/matrix_appservice_kakaotalk/kt/types/api/struct/friends/__init__.py new file mode 100644 index 0000000..251c973 --- /dev/null +++ b/matrix_appservice_kakaotalk/kt/types/api/struct/friends/__init__.py @@ -0,0 +1,21 @@ +# 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 .friend_blocked_list_struct import * +from .friend_find_struct import * +from .friend_list_struct import * +from .friend_req_struct import * +from .friend_search_struct import * +from .friend_struct import * diff --git a/matrix_appservice_kakaotalk/kt/types/api/struct/friends/friend_blocked_list_struct.py b/matrix_appservice_kakaotalk/kt/types/api/struct/friends/friend_blocked_list_struct.py new file mode 100644 index 0000000..12126fd --- /dev/null +++ b/matrix_appservice_kakaotalk/kt/types/api/struct/friends/friend_blocked_list_struct.py @@ -0,0 +1,31 @@ +# 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 attr import dataclass + +from mautrix.types import SerializableAttrs + +from .friend_struct import FriendStruct + + +@dataclass +class FriendBlockedListStruct(SerializableAttrs): + total: int + blockedFriends: list[FriendStruct] + + +__all__ = [ + "FriendBlockedListStruct", +] diff --git a/matrix_appservice_kakaotalk/kt/types/api/struct/friends/friend_find_struct.py b/matrix_appservice_kakaotalk/kt/types/api/struct/friends/friend_find_struct.py new file mode 100644 index 0000000..05bdbdb --- /dev/null +++ b/matrix_appservice_kakaotalk/kt/types/api/struct/friends/friend_find_struct.py @@ -0,0 +1,38 @@ +# 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 attr import dataclass + +from mautrix.types import SerializableAttrs + +from ....bson import Long +from .friend_struct import FriendStruct + + +@dataclass +class FriendFindIdStruct(SerializableAttrs): + token: Long + friend: FriendStruct + + +@dataclass +class FriendFindUUIDStruct(SerializableAttrs): + member: FriendStruct + + +__all__ = [ + "FriendFindIdStruct", + "FriendFindUUIDStruct", +] diff --git a/matrix_appservice_kakaotalk/kt/types/api/struct/friends/friend_list_struct.py b/matrix_appservice_kakaotalk/kt/types/api/struct/friends/friend_list_struct.py new file mode 100644 index 0000000..bde3391 --- /dev/null +++ b/matrix_appservice_kakaotalk/kt/types/api/struct/friends/friend_list_struct.py @@ -0,0 +1,32 @@ +# 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 attr import dataclass + +from mautrix.types import SerializableAttrs + +from ....bson import Long +from .friend_struct import FriendStruct + + +@dataclass +class FriendListStruct(SerializableAttrs): + token: Long + friends: list[FriendStruct] + + +__all__ = [ + "FriendListStruct", +] diff --git a/matrix_appservice_kakaotalk/kt/types/api/struct/friends/friend_req_struct.py b/matrix_appservice_kakaotalk/kt/types/api/struct/friends/friend_req_struct.py new file mode 100644 index 0000000..c167cff --- /dev/null +++ b/matrix_appservice_kakaotalk/kt/types/api/struct/friends/friend_req_struct.py @@ -0,0 +1,36 @@ +# 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 attr import dataclass + +from mautrix.types import SerializableAttrs + +from .friend_struct import FriendStruct + + +@dataclass +class FriendReqStruct(SerializableAttrs): + friend: FriendStruct + + +@dataclass +class FriendReqPhoneNumberStruct(SerializableAttrs): + pstn_number: str + + +__all__ = [ + "FriendReqStruct", + "FriendReqPhoneNumberStruct", +] diff --git a/matrix_appservice_kakaotalk/kt/types/api/struct/friends/friend_search_struct.py b/matrix_appservice_kakaotalk/kt/types/api/struct/friends/friend_search_struct.py new file mode 100644 index 0000000..b3fb5d1 --- /dev/null +++ b/matrix_appservice_kakaotalk/kt/types/api/struct/friends/friend_search_struct.py @@ -0,0 +1,43 @@ +# 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 typing import Optional + +from attr import dataclass + +from mautrix.types import SerializableAttrs + +from .friend_struct import FriendStruct + + +@dataclass +class FriendSearchUserListStruct(SerializableAttrs): + count: int + list: list[FriendStruct] + + +@dataclass(kw_only=True) +class FriendSearchStruct(SerializableAttrs): + query: str + user: Optional[FriendSearchUserListStruct] = None + plus: Optional[FriendSearchUserListStruct] = None + categories: list[str] + total_counts: int + + +__all__ = [ + "FriendSearchUserListStruct", + "FriendSearchStruct", +] diff --git a/matrix_appservice_kakaotalk/kt/types/api/struct/friends/friend_struct.py b/matrix_appservice_kakaotalk/kt/types/api/struct/friends/friend_struct.py new file mode 100644 index 0000000..b7f4182 --- /dev/null +++ b/matrix_appservice_kakaotalk/kt/types/api/struct/friends/friend_struct.py @@ -0,0 +1,72 @@ +# 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 typing import Union, Optional +from enum import IntEnum + +from attr import dataclass + +from mautrix.types import SerializableAttrs + +from ....bson import Long + + +class ApiUserType(IntEnum): + NORMAL = 0 + PLUS = 1 + + +@dataclass +class FriendExt(SerializableAttrs): + addible: bool + yellowid: bool + consultable: bool + friendsCount: int + verificationType: str + isAdult: bool + writable: bool + serviceTypeCode: int + isOfficial: bool + + +@dataclass(kw_only=True) +class FriendStruct(SerializableAttrs): + userId: Union[Long, int] + nickName: str + type: int + phoneNumber: str + statusMessage: str + UUID: str + friendNickName: str + phoneticName: Optional[str] = None + accountId: int + profileImageUrl: str + fullProfileImageUrl: str + originalProfileImageUrl: str + userType: ApiUserType; + ext: Union[FriendExt, str]; + hidden: bool + purged: bool + favorite: bool + screenToken: int + suspended: bool = False + directChatId: int + + +__all__ = [ + "ApiUserType", + "FriendExt", + "FriendStruct", +] \ No newline at end of file diff --git a/matrix_appservice_kakaotalk/kt/types/bson.py b/matrix_appservice_kakaotalk/kt/types/bson.py index ee71916..8456ad2 100644 --- a/matrix_appservice_kakaotalk/kt/types/bson.py +++ b/matrix_appservice_kakaotalk/kt/types/bson.py @@ -22,5 +22,4 @@ class Long(int, Serializable): @classmethod def deserialize(cls, raw: JSON) -> "Long": - assert isinstance(raw, str), f"Long deserialization expected a string, but got non-string value {raw}" return cls(raw) diff --git a/matrix_appservice_kakaotalk/kt/types/request.py b/matrix_appservice_kakaotalk/kt/types/request.py index ac92693..b945326 100644 --- a/matrix_appservice_kakaotalk/kt/types/request.py +++ b/matrix_appservice_kakaotalk/kt/types/request.py @@ -123,7 +123,7 @@ def deserialize_result( result_type: Type[ResultType], data: JSON ) -> Union[CommandResultDoneValue[ResultType], RootCommandResult]: """Returns equivalent of CommandResult. Does no consistency checking on success & result properties.""" - if "result" in data: + if "result" in data and data.get("success"): # TODO Allow arbitrary result object? return CommandResultDoneValue.deserialize_result(result_type, data) else: diff --git a/matrix_appservice_kakaotalk/portal.py b/matrix_appservice_kakaotalk/portal.py index 2b4bb94..249a756 100644 --- a/matrix_appservice_kakaotalk/portal.py +++ b/matrix_appservice_kakaotalk/portal.py @@ -420,7 +420,7 @@ class Portal(DBPortal, BasePortal): # TODO nick_map? for participant in participants: puppet = await p.Puppet.get_by_ktid(participant.userId) - await puppet.update_info(source, participant) + await puppet.update_info_from_participant(source, participant) if self.is_direct and self._kt_sender == puppet.ktid and self.encrypted: changed = await self._update_name(puppet.name) or changed changed = await self._update_photo_from_puppet(puppet) or changed diff --git a/matrix_appservice_kakaotalk/puppet.py b/matrix_appservice_kakaotalk/puppet.py index 65a7366..f26ebbc 100644 --- a/matrix_appservice_kakaotalk/puppet.py +++ b/matrix_appservice_kakaotalk/puppet.py @@ -33,6 +33,7 @@ from .db import Puppet as DBPuppet from .kt.types.bson import Long +from .kt.types.api.struct import FriendStruct from .kt.types.channel.channel_type import KnownChannelType from .kt.client.types import UserInfoUnion @@ -147,25 +148,51 @@ class Puppet(DBPuppet, BasePuppet): # region User info updating - async def update_info( + async def update_info_from_participant( self, source: u.User, info: UserInfoUnion, update_avatar: bool = True, + ) -> Puppet: + await self._update_info( + source, + info.nickname, + info.profileURL, + update_avatar + ) + + async def update_info_from_friend( + self, + source: u.User, + info: FriendStruct, + update_avatar: bool = True, + ) -> Puppet: + await self._update_info( + source, + info.nickName, + info.profileImageUrl, + update_avatar + ) + + async def _update_info( + self, + source: u.User, + name: str, + avatar_url: str, + update_avatar: bool = True, ) -> Puppet: self._last_info_sync = datetime.now() try: - changed = await self._update_name(info) + changed = await self._update_name(name) if update_avatar: - changed = await self._update_photo(source, info.profileURL) or changed + changed = await self._update_photo(source, avatar_url) or changed if changed: await self.save() except Exception: self.log.exception(f"Failed to update info from source {source.ktid}") return self - async def _update_name(self, info: UserInfoUnion) -> bool: - name = info.nickname + async def _update_name(self, name: str) -> bool: if name != self.name or not self.name_set: self.name = name try: diff --git a/node/src/client.js b/node/src/client.js index ff300ea..33d3c47 100644 --- a/node/src/client.js +++ b/node/src/client.js @@ -33,6 +33,23 @@ const { KnownChatType } = chat import { emitLines, promisify } from "./util.js" +ServiceApiClient.prototype.requestFriendList = async function() { + const res = await this._client.requestData( + "POST", + `${this.getFriendsApiPath("update.json")}`, + { + phone_number_type: 1, + } + ); + + return { + status: res.status, + success: res.status === 0, + result: res, + }; +} + + class UserClient { static #initializing = false @@ -270,8 +287,8 @@ export default class PeerClient { /** * Get the service client for the specified user ID, or create * and return a new service client if no user ID is provided. - * @param {string} mxid - * @param {OAuthCredential} oauth_credential + * @param {?string} mxid + * @param {?OAuthCredential} oauth_credential */ async #getServiceClient(mxid, oauth_credential) { return this.#tryGetUser(mxid)?.serviceClient || @@ -341,8 +358,8 @@ export default class PeerClient { /** * @param {Object} req - * @param {string} req.mxid - * @param {OAuthCredential} req.oauth_credential + * @param {?string} req.mxid + * @param {?OAuthCredential} req.oauth_credential */ getOwnProfile = async (req) => { const serviceClient = await this.#getServiceClient(req.mxid, req.oauth_credential) @@ -351,8 +368,8 @@ export default class PeerClient { /** * @param {Object} req - * @param {string} req.mxid - * @param {OAuthCredential} req.oauth_credential + * @param {?string} req.mxid + * @param {?OAuthCredential} req.oauth_credential * @param {Long} req.user_id */ getProfile = async (req) => { @@ -394,8 +411,8 @@ export default class PeerClient { * @param {Object} req * @param {string} req.mxid * @param {Object} req.channel_props - * @param {Long?} req.sync_from - * @param {Number?} req.limit + * @param {?Long} req.sync_from + * @param {?Number} req.limit */ getChats = async (req) => { const userClient = this.#getUser(req.mxid) @@ -408,6 +425,16 @@ export default class PeerClient { return res } + /** + * @param {Object} req + * @param {?string} req.mxid + * @param {?OAuthCredential} req.oauth_credential + */ + listFriends = async (req) => { + const serviceClient = await this.#getServiceClient(req.mxid, req.oauth_credential) + return await serviceClient.requestFriendList() + } + /** * @param {Object} req * @param {string} req.mxid @@ -500,6 +527,7 @@ export default class PeerClient { get_portal_channel_info: this.getPortalChannelInfo, get_participants: this.getParticipants, get_chats: this.getChats, + list_friends: this.listFriends, send_message: this.sendMessage, }[req.command] || this.handleUnknownCommand }