Compare commits

..

No commits in common. "4e6498f7775ea700373857d50d7af9c0559d8acb" and "5ae5970ef00ec8e3faaadc7f91c075cd348c8d99" have entirely different histories.

10 changed files with 59 additions and 456 deletions

View File

@ -73,20 +73,11 @@
* [x] At startup
* [x] When added to chat
* [x] When receiving message
* [x] Direct chat creation by inviting Matrix puppet of KakaoTalk user to new room
* [ ] Private chat creation by inviting Matrix puppet of KakaoTalk user to new room
* [x] For existing recently-updated KakaoTalk channels
* [ ] 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
* [x] KakaoTalk ID management
* [x] Set/Change ID
* [x] Make ID searchable/hidden
<sup>[1]</sup> Sometimes fails with "Invalid body" error
<sup>[2]</sup> Only recently-sent KakaoTalk messages can be deleted

View File

@ -14,9 +14,12 @@
# 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.bridge.commands import HelpSection, command_handler
from mautrix.types import SerializerError
from .typehint import CommandEvent
from ..kt.client.errors import CommandException
SECTION_CONNECTION = HelpSection("Connection management", 15, "")
@ -54,18 +57,42 @@ async def disconnect(evt: CommandEvent) -> None:
@command_handler(
needs_auth=False,
needs_auth=True,
management_only=True,
help_section=SECTION_CONNECTION,
help_text="Check if you're logged in to KakaoTalk and connected to chats",
help_text="Check if you're logged into KakaoTalk and retrieve your account information",
)
async def whoami(evt: CommandEvent) -> None:
await evt.mark_read()
try:
own_info = await evt.sender.get_own_info()
except SerializerError:
evt.sender.log.exception("Failed to deserialize settings struct")
own_info = None
except CommandException as e:
await evt.reply(f"Error from KakaoTalk: {e}")
return
if own_info:
uuid = f"`{own_info.more.uuid}` ({'' if own_info.more.uuidSearchable else 'not '}searchable)" if own_info.more.uuid else "_none_"
await evt.reply(
f"You're logged in as **{own_info.more.nickName}** (KakaoTalk ID: {uuid}, internal ID: `{evt.sender.ktid}`)"
)
else:
await evt.reply(
f"You're logged in, but the bridge is unable to retrieve your profile information (internal ID: {evt.sender.ktid})"
)
@command_handler(
needs_auth=True,
management_only=True,
help_section=SECTION_CONNECTION,
help_text="Check if you're connected to KakaoTalk chats",
)
async def ping(evt: CommandEvent) -> None:
if not await evt.sender.is_logged_in():
await evt.reply("You are **logged out** of KakaoTalk.")
else:
assert evt.sender.client
is_connected = evt.sender.is_connected and await evt.sender.client.is_connected()
await evt.reply(
"You are logged into KakaoTalk.\n\n"
f"You are {'connected to' if is_connected else '**disconnected** from'} KakaoTalk chats."
)

View File

@ -15,144 +15,25 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import TYPE_CHECKING, Awaitable
from typing import TYPE_CHECKING
import asyncio
from mautrix.bridge.commands import HelpSection, command_handler
from mautrix.types import SerializerError
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
from ..kt.client.errors import CommandException
SECTION_ACCOUNT = HelpSection("Account management", 35, "")
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
@command_handler(
needs_auth=True,
management_only=True,
help_section=SECTION_ACCOUNT,
help_text="Retrieve your KakaoTalk account information",
)
async def whoami(evt: CommandEvent) -> None:
await evt.mark_read()
try:
own_info = await evt.sender.get_own_info(force=True)
except SerializerError:
evt.sender.log.exception("Failed to deserialize settings struct")
own_info = None
except CommandException as e:
await evt.reply(f"Error from KakaoTalk: {e}")
return
if own_info:
uuid = f"`{own_info.more.uuid}` ({'searchable' if own_info.more.uuidSearchable else 'hidden'})" if own_info.more.uuid else "_none_"
await evt.reply(
f"You're logged in as **{own_info.more.nickName}** (KakaoTalk ID: {uuid}, internal ID: `{evt.sender.ktid}`)"
)
else:
await evt.reply(
f"You're logged in, but the bridge is unable to retrieve your profile information (internal ID: {evt.sender.ktid})"
)
_CMD_CONFIRM_CHANGE_ID = "confirm-change-id"
@command_handler(
needs_auth=True,
management_only=False,
help_section=SECTION_ACCOUNT,
help_text="Set or change your KakaoTalk ID",
help_args="<_KakaoTalk ID_>",
aliases=["set-id"],
)
async def change_id(evt: CommandEvent) -> None:
if len(evt.args) != 1:
await evt.reply(f"**Usage:** `$cmdprefix+sp {evt.command} <KakaoTalk ID>`")
return
new_id = evt.args[0]
if len(new_id) > 20:
await evt.reply("ID must not exceed 20 characters. Please choose a shorter ID.")
return
await evt.mark_read()
if await evt.sender.client.can_change_uuid(new_id):
await evt.reply(
"Once set, your KakaoTalk ID can be changed only once! "
f"If you are sure that you want to change it, type `$cmdprefix+sp {_CMD_CONFIRM_CHANGE_ID}`. "
"Otherwise, type `$cmdprefix+sp cancel`."
)
evt.sender.command_status = {
"action": "ID change",
"room_id": evt.room_id,
"next": _confirm_change_id,
"new_id": new_id,
}
else:
await evt.reply(
f"Cannot change KakaoTalk ID to `{new_id}`. "
"That ID might already be in use or have restricted characters. "
"Either try a different ID, or try again later."
)
async def _confirm_change_id(evt: CommandEvent) -> None:
assert evt.sender.command_status
new_id = evt.sender.command_status.pop("new_id")
evt.sender.command_status = None
await evt.mark_read()
try:
await evt.sender.client.change_uuid(new_id)
except CommandException:
await evt.reply(f"Failed to change KakaoTalk ID to `{new_id}`. Try again later.")
else:
await evt.reply(f"Successfully changed ID to `{new_id}`")
@command_handler(
needs_auth=True,
management_only=False,
help_section=SECTION_ACCOUNT,
help_text="Allow others to search by your KakaoTalk ID",
)
def make_id_searchable(evt: CommandEvent) -> Awaitable[None]:
return _set_id_searchable(evt, True)
@command_handler(
needs_auth=True,
management_only=False,
help_section=SECTION_ACCOUNT,
help_text="Prevent others from searching by your KakaoTalk ID",
)
def make_id_hidden(evt: CommandEvent) -> Awaitable[None]:
return _set_id_searchable(evt, False)
async def _set_id_searchable(evt: CommandEvent, searchable: bool) -> None:
await evt.mark_read()
try:
await evt.sender.client.set_uuid_searchable(searchable)
except RPCError as e:
await evt.reply(str(e))
else:
await evt.reply(f"Successfully made KakaoTalk ID {'searchable' if searchable else 'hidden'}")
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:
@ -167,9 +48,9 @@ async def _get_search_result_puppet(source: u.User, friend_struct: FriendStruct)
help_text="List all KakaoTalk friends",
)
async def list_friends(evt: CommandEvent) -> None:
await evt.mark_read()
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
@ -189,114 +70,6 @@ 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_>",
)
def add_friend(evt: CommandEvent) -> Awaitable[None]:
return _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_>",
)
def remove_friend(evt: CommandEvent) -> Awaitable[None]:
return _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} <KakaoTalk ID|Matrix user ID>`")
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:
await evt.mark_read()
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:
await evt.mark_read()
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,

View File

@ -324,52 +324,12 @@ class Client:
unread_chat_ids=[c.serialize() for c in unread_chat_ids],
)
async def can_change_uuid(self, uuid: str) -> bool:
try:
await self._api_user_request_void("can_change_uuid", uuid=uuid)
except CommandException:
return False
else:
return True
def change_uuid(self, uuid: str) -> Awaitable[None]:
return self._api_user_request_void("change_uuid", uuid=uuid)
def set_uuid_searchable(self, searchable: bool) -> Awaitable[None]:
return self._api_user_request_void("set_uuid_searchable", searchable=searchable)
def list_friends(self) -> Awaitable[FriendListStruct]:
return self._api_user_request_result(
FriendListStruct,
"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(
@ -505,13 +465,6 @@ class Client:
photo_url=photo_url,
)
def create_direct_chat(self, ktid: Long) -> Awaitable[Long]:
return self._api_user_request_result(
Long,
"create_direct_chat",
user_id=ktid.serialize(),
)
def leave_channel(
self,
channel_props: ChannelProps,

View File

@ -28,7 +28,7 @@ class CommandException(Exception):
In the case that different status codes map to the same error subclass, the status code
can be retrieved from the "status" property.
"""
# TODO Handle result object
# NOTE unsuccessful responses do not set a result, hence using RootCommandResult here
# TODO Print _unrecognized?
self.status = result.status
self.message = _status_code_message_map.get(self.status, self._default_message)

View File

@ -13,7 +13,7 @@
#
# 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 typing import NewType, Optional, Union
from typing import NewType, Union
from attr import dataclass
@ -43,13 +43,13 @@ class LoginResult(SerializableAttrs):
"""Return value of TalkClient.login"""
channelList: list[LoginDataItem]
userId: Long
lastChannelId: Long
lastTokenId: Long
mcmRevision: int
removedChannelIdList: list[Long]
revision: int
revisionInfo: str
minLogId: Long
lastChannelId: Optional[Long] = None # NEW Made optional
# TODO Consider catching SerializerError for channelList entries

View File

@ -93,7 +93,6 @@ class RootCommandResult(ResponseState):
ResultType = TypeVar("ResultType", bound=Serializable)
def ResultListType(result_type: Type[ResultType]):
"""Custom type for setting a result to a list of serializable objects."""
class _ResultListType(list[result_type], Serializable):
def serialize(self) -> list[JSON]:
return [v.serialize() for v in self]
@ -105,7 +104,6 @@ def ResultListType(result_type: Type[ResultType]):
return _ResultListType
def ResultRawType(result_type: Type):
"""Custom type for setting a result to a primitive."""
class _ResultRawType(result_type, Serializable):
def serialize(self) -> result_type:
return self
@ -134,7 +132,6 @@ def deserialize_result(
) -> Union[CommandResultDoneValue[ResultType], RootCommandResult]:
"""Returns equivalent of CommandResult<T>. Does no consistency checking on success & result properties."""
if "result" in data and data.get("success"):
# TODO Handle result object in unsuccessful response
# TODO Allow arbitrary result object?
return CommandResultDoneValue.deserialize_result(result_type, data)
else:

View File

@ -30,7 +30,7 @@ from mautrix.types import (
UserID,
)
from . import portal as po, puppet as pu, user as u
from . import portal as po, user as u
from .db import Message as DBMessage
if TYPE_CHECKING:
@ -63,19 +63,6 @@ class MatrixHandler(BaseMatrixHandler):
room_id, "This room has been marked as your KakaoTalk bridge notice room."
)
async def handle_puppet_dm_invite(
self, room_id: RoomID, puppet: pu.Puppet, invited_by: u.User, evt: StateEvent
) -> None:
# TODO Make upstream request to return custom failure message,
# instead of having to reimplement the entire function
portal = await invited_by.get_portal_with(puppet)
if portal:
await portal.accept_matrix_dm(room_id, invited_by, puppet)
else:
await puppet.default_mxid_intent.leave_room(
room_id, reason="Unable to create a DM for this KakaoTalk user"
)
async def handle_invite(
self, room_id: RoomID, user_id: UserID, invited_by: u.User, event_id: EventID
) -> None:

View File

@ -278,8 +278,8 @@ class User(DBUser, BaseUser):
self.log.warning(f"UUID mismatch: expected {self.uuid}, got {oauth_credential.deviceUUID}")
self.uuid = oauth_credential.deviceUUID
async def get_own_info(self, *, force: bool = False) -> SettingsStruct | None:
if force or self._client and (not self._logged_in_info or self._logged_in_info_time + 60 * 60 < time.monotonic()):
async def get_own_info(self) -> SettingsStruct | None:
if self._client and (not self._logged_in_info or self._logged_in_info_time + 60 * 60 < time.monotonic()):
self._logged_in_info = await self._client.get_settings()
self._logged_in_info_time = time.monotonic()
return self._logged_in_info
@ -471,7 +471,7 @@ class User(DBUser, BaseUser):
if self.config["bridge.sync_on_startup"] or not is_startup:
sync_count = self.config["bridge.initial_chat_sync"]
else:
sync_count = 0
sync_count = None
await self.connect_and_sync(sync_count, force_sync=False)
else:
await self.send_bridge_notice(
@ -685,18 +685,14 @@ class User(DBUser, BaseUser):
else:
ktid = memo_ids[0]
if len(memo_ids) > 1:
self.log.warning("Found multiple memo chats, so using the first one as a fallback")
self.log.info("Found multiple memo chats, so using the first one as a fallback")
if ktid:
self.log.debug(f"Found existing direct chat with KakaoTalk user {puppet.ktid}")
else:
self.log.debug(f"Didn't find an existing direct chat with KakaoTalk user {puppet.ktid}, so will create one")
try:
ktid = await self.client.create_direct_chat(puppet.ktid)
except:
self.log.exception(f"Failed to create direct chat with {puppet.ktid}")
return await po.Portal.get_by_ktid(
ktid, kt_receiver=self.ktid, create=create, kt_type=kt_type
) if ktid else None
)
else:
self.log.warning(f"Didn't find an existing DM channel with KakaoTalk user {puppet.ktid}, so not creating one")
return None
# region KakaoTalk event handling

View File

@ -834,51 +834,6 @@ export default class PeerClient {
return makeCommandResult(receipts)
}
/**
* @param {Object} req
* @param {string} req.mxid
* @param {string} req.uuid
*/
canChangeUUID = async (req) => {
return await this.#getUser(req.mxid).serviceClient.canChangeUUID(req.uuid)
}
/**
* @param {Object} req
* @param {string} req.mxid
* @param {string} req.uuid
*/
changeUUID = async (req) => {
const serviceClient = this.#getUser(req.mxid).serviceClient
const checkRes = await serviceClient.canChangeUUID(req.uuid)
if (!checkRes.success) return checkRes
return await serviceClient.changeUUID(req.uuid)
}
/**
* @param {Object} req
* @param {string} req.mxid
* @param {boolean} req.searchable
*/
setUUIDSearchable = async (req) => {
const serviceClient = this.#getUser(req.mxid).serviceClient
const moreRes = await serviceClient.requestMoreSettings()
if (!moreRes.success) {
throw new ProtocolError("Error checking status of KakaoTalk ID")
}
if (!moreRes.result.uuid) {
throw new ProtocolError("You do not yet have a KakaoTalk ID")
}
if (req.searchable == moreRes.result.uuidSearchable) {
throw new ProtocolError(`Your KakaoTalk ID is already ${req.searchable ? "searchable" : "hidden"}`)
}
return await serviceClient.updateSettings({
uuid_searchable: req.searchable,
})
}
/**
* @param {Object} req
* @param {string} req.mxid
@ -887,57 +842,6 @@ 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.
@ -959,7 +863,6 @@ export default class PeerClient {
/** @type Long[] */
const channelIds = []
const channelList = this.#getUser(req.mxid).talkClient.channelList
// TODO channelList.all() doesn't really return *all* channels...
for (const channel of channelList.all()) {
if (channel.info.type == "MemoChat") {
channelIds.push(channel.channelId)
@ -1129,20 +1032,6 @@ export default class PeerClient {
})
}
/**
* @param {Object} req
* @param {string} req.mxid
* @param {Long} req.user_id
*/
createDirectChat = async (req) => {
const channelList = this.#getUser(req.mxid).talkClient.channelList.normal
const res = await channelList.createChannel({
userList: [{ userId: req.user_id }],
})
if (!res.success) return res
return makeCommandResult(res.result.channelId)
}
/**
* @param {Object} req
* @param {string} req.mxid
@ -1234,12 +1123,7 @@ export default class PeerClient {
get_participants: this.getParticipants,
get_chats: this.getChats,
get_read_receipts: this.getReadReceipts,
can_change_uuid: this.canChangeUUID,
change_uuid: this.changeUUID,
set_uuid_searchable: this.setUUIDSearchable,
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,
@ -1251,7 +1135,6 @@ export default class PeerClient {
set_channel_name: this.setChannelName,
set_channel_description: this.setChannelDescription,
set_channel_photo: this.setChannelPhoto,
create_direct_chat: this.createDirectChat,
leave_channel: this.leaveChannel,
}[req.command] || this.handleUnknownCommand
}
@ -1269,15 +1152,11 @@ export default class PeerClient {
}
} else {
resp.command = "error"
if (err instanceof ProtocolError) {
resp.error = err.message
} else {
resp.error = err.toString()
resp.error = err instanceof ProtocolError ? err.message : err.toString()
this.log(`Error handling request ${resp.id} ${err.stack}`)
}
}
}
// TODO Check if session is broken. If it is, close the PeerClient
}
}
await this.write(resp)
}