Compare commits
8 Commits
5ae5970ef0
...
4e6498f777
Author | SHA1 | Date | |
---|---|---|---|
4e6498f777 | |||
c3726220de | |||
a817049e12 | |||
dcf17fd40a | |||
c3b299d26c | |||
faf174c226 | |||
dfdd98da96 | |||
b994ca65ee |
17
ROADMAP.md
17
ROADMAP.md
@ -73,11 +73,20 @@
|
|||||||
* [x] At startup
|
* [x] At startup
|
||||||
* [x] When added to chat
|
* [x] When added to chat
|
||||||
* [x] When receiving message
|
* [x] When receiving message
|
||||||
* [ ] Private chat creation by inviting Matrix puppet of KakaoTalk user to new room
|
* [x] Direct 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
|
* [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>[1]</sup> Sometimes fails with "Invalid body" error
|
||||||
<sup>[2]</sup> Only recently-sent KakaoTalk messages can be deleted
|
<sup>[2]</sup> Only recently-sent KakaoTalk messages can be deleted
|
||||||
|
@ -14,12 +14,9 @@
|
|||||||
# You should have received a copy of the GNU Affero General Public License
|
# 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/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from mautrix.bridge.commands import HelpSection, command_handler
|
from mautrix.bridge.commands import HelpSection, command_handler
|
||||||
from mautrix.types import SerializerError
|
|
||||||
|
|
||||||
from .typehint import CommandEvent
|
from .typehint import CommandEvent
|
||||||
|
|
||||||
from ..kt.client.errors import CommandException
|
|
||||||
|
|
||||||
SECTION_CONNECTION = HelpSection("Connection management", 15, "")
|
SECTION_CONNECTION = HelpSection("Connection management", 15, "")
|
||||||
|
|
||||||
|
|
||||||
@ -57,42 +54,18 @@ async def disconnect(evt: CommandEvent) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@command_handler(
|
@command_handler(
|
||||||
needs_auth=True,
|
needs_auth=False,
|
||||||
management_only=True,
|
management_only=True,
|
||||||
help_section=SECTION_CONNECTION,
|
help_section=SECTION_CONNECTION,
|
||||||
help_text="Check if you're logged into KakaoTalk and retrieve your account information",
|
help_text="Check if you're logged in to KakaoTalk and connected to chats",
|
||||||
)
|
|
||||||
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:
|
async def ping(evt: CommandEvent) -> None:
|
||||||
assert evt.sender.client
|
if not await evt.sender.is_logged_in():
|
||||||
|
await evt.reply("You are **logged out** of KakaoTalk.")
|
||||||
|
else:
|
||||||
is_connected = evt.sender.is_connected and await evt.sender.client.is_connected()
|
is_connected = evt.sender.is_connected and await evt.sender.client.is_connected()
|
||||||
await evt.reply(
|
await evt.reply(
|
||||||
|
"You are logged into KakaoTalk.\n\n"
|
||||||
f"You are {'connected to' if is_connected else '**disconnected** from'} KakaoTalk chats."
|
f"You are {'connected to' if is_connected else '**disconnected** from'} KakaoTalk chats."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -15,24 +15,143 @@
|
|||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Awaitable
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from mautrix.bridge.commands import HelpSection, command_handler
|
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 ..kt.types.api.struct import ApiUserType
|
||||||
|
from ..rpc.types import RPCError
|
||||||
|
|
||||||
from .. import puppet as pu, user as u
|
from .. import puppet as pu, user as u
|
||||||
from .typehint import CommandEvent
|
from .typehint import CommandEvent
|
||||||
|
|
||||||
from ..kt.client.errors import CommandException
|
from ..kt.client.errors import CommandException
|
||||||
|
|
||||||
|
SECTION_ACCOUNT = HelpSection("Account management", 35, "")
|
||||||
SECTION_FRIENDS = HelpSection("Friends management", 40, "")
|
SECTION_FRIENDS = HelpSection("Friends management", 40, "")
|
||||||
SECTION_CHANNELS = HelpSection("Channel management", 45, "")
|
SECTION_CHANNELS = HelpSection("Channel management", 45, "")
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..kt.types.api.struct import FriendStruct
|
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:
|
async def _get_search_result_puppet(source: u.User, friend_struct: FriendStruct) -> pu.Puppet:
|
||||||
puppet = await pu.Puppet.get_by_ktid(friend_struct.userId)
|
puppet = await pu.Puppet.get_by_ktid(friend_struct.userId)
|
||||||
@ -48,9 +167,9 @@ async def _get_search_result_puppet(source: u.User, friend_struct: FriendStruct)
|
|||||||
help_text="List all KakaoTalk friends",
|
help_text="List all KakaoTalk friends",
|
||||||
)
|
)
|
||||||
async def list_friends(evt: CommandEvent) -> None:
|
async def list_friends(evt: CommandEvent) -> None:
|
||||||
|
await evt.mark_read()
|
||||||
try:
|
try:
|
||||||
resp = await evt.sender.client.list_friends()
|
resp = await evt.sender.client.list_friends()
|
||||||
await evt.mark_read()
|
|
||||||
except CommandException as e:
|
except CommandException as e:
|
||||||
await evt.reply(f"Error while listing friends: {e!s}")
|
await evt.reply(f"Error while listing friends: {e!s}")
|
||||||
return
|
return
|
||||||
@ -70,6 +189,114 @@ async def list_friends(evt: CommandEvent) -> None:
|
|||||||
await evt.reply("No friends found.")
|
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(
|
@command_handler(
|
||||||
needs_auth=True,
|
needs_auth=True,
|
||||||
management_only=False,
|
management_only=False,
|
||||||
|
@ -324,12 +324,52 @@ class Client:
|
|||||||
unread_chat_ids=[c.serialize() for c in unread_chat_ids],
|
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]:
|
def list_friends(self) -> Awaitable[FriendListStruct]:
|
||||||
return self._api_user_request_result(
|
return self._api_user_request_result(
|
||||||
FriendListStruct,
|
FriendListStruct,
|
||||||
"list_friends",
|
"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:
|
async def get_friend_dm_id(self, friend_id: Long) -> Long | None:
|
||||||
try:
|
try:
|
||||||
return await self._api_user_request_result(
|
return await self._api_user_request_result(
|
||||||
@ -465,6 +505,13 @@ class Client:
|
|||||||
photo_url=photo_url,
|
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(
|
def leave_channel(
|
||||||
self,
|
self,
|
||||||
channel_props: ChannelProps,
|
channel_props: ChannelProps,
|
||||||
|
@ -28,7 +28,7 @@ class CommandException(Exception):
|
|||||||
In the case that different status codes map to the same error subclass, the status code
|
In the case that different status codes map to the same error subclass, the status code
|
||||||
can be retrieved from the "status" property.
|
can be retrieved from the "status" property.
|
||||||
"""
|
"""
|
||||||
# NOTE unsuccessful responses do not set a result, hence using RootCommandResult here
|
# TODO Handle result object
|
||||||
# TODO Print _unrecognized?
|
# TODO Print _unrecognized?
|
||||||
self.status = result.status
|
self.status = result.status
|
||||||
self.message = _status_code_message_map.get(self.status, self._default_message)
|
self.message = _status_code_message_map.get(self.status, self._default_message)
|
||||||
|
@ -13,7 +13,7 @@
|
|||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# 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/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from typing import NewType, Union
|
from typing import NewType, Optional, Union
|
||||||
|
|
||||||
from attr import dataclass
|
from attr import dataclass
|
||||||
|
|
||||||
@ -43,13 +43,13 @@ class LoginResult(SerializableAttrs):
|
|||||||
"""Return value of TalkClient.login"""
|
"""Return value of TalkClient.login"""
|
||||||
channelList: list[LoginDataItem]
|
channelList: list[LoginDataItem]
|
||||||
userId: Long
|
userId: Long
|
||||||
lastChannelId: Long
|
|
||||||
lastTokenId: Long
|
lastTokenId: Long
|
||||||
mcmRevision: int
|
mcmRevision: int
|
||||||
removedChannelIdList: list[Long]
|
removedChannelIdList: list[Long]
|
||||||
revision: int
|
revision: int
|
||||||
revisionInfo: str
|
revisionInfo: str
|
||||||
minLogId: Long
|
minLogId: Long
|
||||||
|
lastChannelId: Optional[Long] = None # NEW Made optional
|
||||||
# TODO Consider catching SerializerError for channelList entries
|
# TODO Consider catching SerializerError for channelList entries
|
||||||
|
|
||||||
|
|
||||||
|
@ -93,6 +93,7 @@ class RootCommandResult(ResponseState):
|
|||||||
ResultType = TypeVar("ResultType", bound=Serializable)
|
ResultType = TypeVar("ResultType", bound=Serializable)
|
||||||
|
|
||||||
def ResultListType(result_type: Type[ResultType]):
|
def ResultListType(result_type: Type[ResultType]):
|
||||||
|
"""Custom type for setting a result to a list of serializable objects."""
|
||||||
class _ResultListType(list[result_type], Serializable):
|
class _ResultListType(list[result_type], Serializable):
|
||||||
def serialize(self) -> list[JSON]:
|
def serialize(self) -> list[JSON]:
|
||||||
return [v.serialize() for v in self]
|
return [v.serialize() for v in self]
|
||||||
@ -104,6 +105,7 @@ def ResultListType(result_type: Type[ResultType]):
|
|||||||
return _ResultListType
|
return _ResultListType
|
||||||
|
|
||||||
def ResultRawType(result_type: Type):
|
def ResultRawType(result_type: Type):
|
||||||
|
"""Custom type for setting a result to a primitive."""
|
||||||
class _ResultRawType(result_type, Serializable):
|
class _ResultRawType(result_type, Serializable):
|
||||||
def serialize(self) -> result_type:
|
def serialize(self) -> result_type:
|
||||||
return self
|
return self
|
||||||
@ -132,6 +134,7 @@ def deserialize_result(
|
|||||||
) -> Union[CommandResultDoneValue[ResultType], RootCommandResult]:
|
) -> Union[CommandResultDoneValue[ResultType], RootCommandResult]:
|
||||||
"""Returns equivalent of CommandResult<T>. Does no consistency checking on success & result properties."""
|
"""Returns equivalent of CommandResult<T>. Does no consistency checking on success & result properties."""
|
||||||
if "result" in data and data.get("success"):
|
if "result" in data and data.get("success"):
|
||||||
|
# TODO Handle result object in unsuccessful response
|
||||||
# TODO Allow arbitrary result object?
|
# TODO Allow arbitrary result object?
|
||||||
return CommandResultDoneValue.deserialize_result(result_type, data)
|
return CommandResultDoneValue.deserialize_result(result_type, data)
|
||||||
else:
|
else:
|
||||||
|
@ -30,7 +30,7 @@ from mautrix.types import (
|
|||||||
UserID,
|
UserID,
|
||||||
)
|
)
|
||||||
|
|
||||||
from . import portal as po, user as u
|
from . import portal as po, puppet as pu, user as u
|
||||||
from .db import Message as DBMessage
|
from .db import Message as DBMessage
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@ -63,6 +63,19 @@ class MatrixHandler(BaseMatrixHandler):
|
|||||||
room_id, "This room has been marked as your KakaoTalk bridge notice room."
|
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(
|
async def handle_invite(
|
||||||
self, room_id: RoomID, user_id: UserID, invited_by: u.User, event_id: EventID
|
self, room_id: RoomID, user_id: UserID, invited_by: u.User, event_id: EventID
|
||||||
) -> None:
|
) -> None:
|
||||||
|
@ -278,8 +278,8 @@ class User(DBUser, BaseUser):
|
|||||||
self.log.warning(f"UUID mismatch: expected {self.uuid}, got {oauth_credential.deviceUUID}")
|
self.log.warning(f"UUID mismatch: expected {self.uuid}, got {oauth_credential.deviceUUID}")
|
||||||
self.uuid = oauth_credential.deviceUUID
|
self.uuid = oauth_credential.deviceUUID
|
||||||
|
|
||||||
async def get_own_info(self) -> SettingsStruct | None:
|
async def get_own_info(self, *, force: bool = False) -> SettingsStruct | None:
|
||||||
if self._client and (not self._logged_in_info or self._logged_in_info_time + 60 * 60 < time.monotonic()):
|
if force or 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 = await self._client.get_settings()
|
||||||
self._logged_in_info_time = time.monotonic()
|
self._logged_in_info_time = time.monotonic()
|
||||||
return self._logged_in_info
|
return self._logged_in_info
|
||||||
@ -471,7 +471,7 @@ class User(DBUser, BaseUser):
|
|||||||
if self.config["bridge.sync_on_startup"] or not is_startup:
|
if self.config["bridge.sync_on_startup"] or not is_startup:
|
||||||
sync_count = self.config["bridge.initial_chat_sync"]
|
sync_count = self.config["bridge.initial_chat_sync"]
|
||||||
else:
|
else:
|
||||||
sync_count = None
|
sync_count = 0
|
||||||
await self.connect_and_sync(sync_count, force_sync=False)
|
await self.connect_and_sync(sync_count, force_sync=False)
|
||||||
else:
|
else:
|
||||||
await self.send_bridge_notice(
|
await self.send_bridge_notice(
|
||||||
@ -685,14 +685,18 @@ class User(DBUser, BaseUser):
|
|||||||
else:
|
else:
|
||||||
ktid = memo_ids[0]
|
ktid = memo_ids[0]
|
||||||
if len(memo_ids) > 1:
|
if len(memo_ids) > 1:
|
||||||
self.log.info("Found multiple memo chats, so using the first one as a fallback")
|
self.log.warning("Found multiple memo chats, so using the first one as a fallback")
|
||||||
if ktid:
|
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(
|
return await po.Portal.get_by_ktid(
|
||||||
ktid, kt_receiver=self.ktid, create=create, kt_type=kt_type
|
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
|
# region KakaoTalk event handling
|
||||||
|
|
||||||
|
@ -834,6 +834,51 @@ export default class PeerClient {
|
|||||||
return makeCommandResult(receipts)
|
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 {Object} req
|
||||||
* @param {string} req.mxid
|
* @param {string} req.mxid
|
||||||
@ -842,6 +887,57 @@ export default class PeerClient {
|
|||||||
return await this.#getUser(req.mxid).serviceClient.requestFriendList()
|
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 {Object} req
|
||||||
* @param {string} req.mxid The user whose friend is being looked up.
|
* @param {string} req.mxid The user whose friend is being looked up.
|
||||||
@ -863,6 +959,7 @@ export default class PeerClient {
|
|||||||
/** @type Long[] */
|
/** @type Long[] */
|
||||||
const channelIds = []
|
const channelIds = []
|
||||||
const channelList = this.#getUser(req.mxid).talkClient.channelList
|
const channelList = this.#getUser(req.mxid).talkClient.channelList
|
||||||
|
// TODO channelList.all() doesn't really return *all* channels...
|
||||||
for (const channel of channelList.all()) {
|
for (const channel of channelList.all()) {
|
||||||
if (channel.info.type == "MemoChat") {
|
if (channel.info.type == "MemoChat") {
|
||||||
channelIds.push(channel.channelId)
|
channelIds.push(channel.channelId)
|
||||||
@ -1032,6 +1129,20 @@ 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 {Object} req
|
||||||
* @param {string} req.mxid
|
* @param {string} req.mxid
|
||||||
@ -1123,7 +1234,12 @@ export default class PeerClient {
|
|||||||
get_participants: this.getParticipants,
|
get_participants: this.getParticipants,
|
||||||
get_chats: this.getChats,
|
get_chats: this.getChats,
|
||||||
get_read_receipts: this.getReadReceipts,
|
get_read_receipts: this.getReadReceipts,
|
||||||
|
can_change_uuid: this.canChangeUUID,
|
||||||
|
change_uuid: this.changeUUID,
|
||||||
|
set_uuid_searchable: this.setUUIDSearchable,
|
||||||
list_friends: this.listFriends,
|
list_friends: this.listFriends,
|
||||||
|
edit_friend: this.editFriend,
|
||||||
|
edit_friend_by_uuid: this.editFriendByUUID,
|
||||||
get_friend_dm_id: req => this.getFriendProperty(req, "directChatId"),
|
get_friend_dm_id: req => this.getFriendProperty(req, "directChatId"),
|
||||||
get_memo_ids: this.getMemoIds,
|
get_memo_ids: this.getMemoIds,
|
||||||
download_file: this.downloadFile,
|
download_file: this.downloadFile,
|
||||||
@ -1135,6 +1251,7 @@ export default class PeerClient {
|
|||||||
set_channel_name: this.setChannelName,
|
set_channel_name: this.setChannelName,
|
||||||
set_channel_description: this.setChannelDescription,
|
set_channel_description: this.setChannelDescription,
|
||||||
set_channel_photo: this.setChannelPhoto,
|
set_channel_photo: this.setChannelPhoto,
|
||||||
|
create_direct_chat: this.createDirectChat,
|
||||||
leave_channel: this.leaveChannel,
|
leave_channel: this.leaveChannel,
|
||||||
}[req.command] || this.handleUnknownCommand
|
}[req.command] || this.handleUnknownCommand
|
||||||
}
|
}
|
||||||
@ -1152,11 +1269,15 @@ export default class PeerClient {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
resp.command = "error"
|
resp.command = "error"
|
||||||
resp.error = err instanceof ProtocolError ? err.message : err.toString()
|
if (err instanceof ProtocolError) {
|
||||||
|
resp.error = err.message
|
||||||
|
} else {
|
||||||
|
resp.error = err.toString()
|
||||||
this.log(`Error handling request ${resp.id} ${err.stack}`)
|
this.log(`Error handling request ${resp.id} ${err.stack}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// TODO Check if session is broken. If it is, close the PeerClient
|
// TODO Check if session is broken. If it is, close the PeerClient
|
||||||
}
|
|
||||||
}
|
|
||||||
await this.write(resp)
|
await this.write(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user