Compare commits

..

No commits in common. "0dc75b8f1c3c30f1146e04edda45681414147618" and "095641fe083bb40a1f510a36604aa27e23fa2224" have entirely different histories.

16 changed files with 160 additions and 430 deletions

View File

@ -24,10 +24,10 @@
* [x] Leave<sup>[3]</sup> * [x] Leave<sup>[3]</sup>
* [ ] Ban<sup>[4]</sup> * [ ] Ban<sup>[4]</sup>
* [ ] Unban<sup>[4]</sup> * [ ] Unban<sup>[4]</sup>
* [ ] Room metadata changes * [ ] Room metadata changes<sup>[1]</sup>
* [x] Name * [x] Name
* [x] Topic * [x] Topic
* [ ] Avatar * [x] Avatar
* [ ] Per-room user nick * [ ] Per-room user nick
* KakaoTalk → Matrix * KakaoTalk → Matrix
* [ ] Message content * [ ] Message content
@ -58,7 +58,7 @@
* [x] On live event * [x] On live event
* [x] Kick<sup>[4]</sup> * [x] Kick<sup>[4]</sup>
* [x] Unkick<sup>[4]</sup> * [x] Unkick<sup>[4]</sup>
* [x] Channel metadata * [ ] Channel metadata
* [x] Name * [x] Name
* [x] Description * [x] Description
* [x] Cover photo<sup>[5]</sup> * [x] Cover photo<sup>[5]</sup>
@ -74,13 +74,6 @@
* [x] When added to chat * [x] When added to chat
* [x] When receiving message * [x] When receiving message
* [x] Direct 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
* [ ] Open Chat creation by bot command
* [ ] Group Chat
* [ ] 1:1 Chat
* [ ] Open Chat settings management
* [ ] Public search
* [ ] Max number of participants
* [ ] Chatroom code
* [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 * [ ] KakaoTalk friends list management
* [x] List friends * [x] List friends
@ -89,9 +82,8 @@
* [x] By Matrix puppet of KakaoTalk user * [x] By Matrix puppet of KakaoTalk user
* [ ] By phone number * [ ] By phone number
* [x] Remove friend * [x] Remove friend
* [ ] Favourite friends * [ ] Manage favourite friends
* [ ] Hidden friends * [ ] Manage hidden friends
* [ ] Blocked users
* [x] KakaoTalk ID management * [x] KakaoTalk ID management
* [x] Set/Change ID * [x] Set/Change ID
* [x] Make ID searchable/hidden * [x] Make ID searchable/hidden
@ -100,4 +92,4 @@
<sup>[2]</sup> Only recently-sent KakaoTalk messages can be deleted <sup>[2]</sup> Only recently-sent KakaoTalk messages can be deleted
<sup>[3]</sup> To make your KakaoTalk account leave a channel, send the `leave` command in a Matrix portal room. Simply leaving a Matrix portal room will keep your KakaoTalk account in the channel. <sup>[3]</sup> To make your KakaoTalk account leave a channel, send the `leave` command in a Matrix portal room. Simply leaving a Matrix portal room will keep your KakaoTalk account in the channel.
<sup>[4]</sup> Kicks in KakaoTalk are equivalent to bans in Matrix <sup>[4]</sup> Kicks in KakaoTalk are equivalent to bans in Matrix
<sup>[5]</sup> Might not get synced on backfill or initial portal creation. Might get synced on live update to channel name/description <sup>[5]</sup> Might only get synced on backfill, or on changing channel name/description

View File

@ -69,30 +69,10 @@ async def login(evt: CommandEvent) -> None:
await evt.reply("You're already logged in") await evt.reply("You're already logged in")
return return
num_args = len(evt.args) save = len(evt.args) > 0 and evt.args[0] == "--save"
save = num_args > 0 and evt.args[0] == "--save" email = evt.args[0 if not save else 1] if len(evt.args) > 0 else None
# TODO Once web login is implemented, don't make <email> a mandatory argument
if not save and num_args != 1:
await evt.reply("**Usage:** `$cmdprefix+sp login [--save] <email>`")
return
email = evt.args[0 if not save else 1] if num_args > 0 else None
if email: if email:
try:
creds = await LoginCredential.get_by_mxid(evt.sender.mxid)
except:
evt.log.exception("Exception while looking for saved password")
creds = None
if creds and creds.email == email:
await evt.reply("Logging in with saved password")
evt.sender.command_status = {
"action": "Login with saved password",
"room_id": evt.room_id,
"save": True,
}
await _login_with_password(evt, email, creds.password, evt.sender.force_login)
return
evt.sender.command_status = { evt.sender.command_status = {
"action": "Login", "action": "Login",
"room_id": evt.room_id, "room_id": evt.room_id,
@ -101,6 +81,20 @@ async def login(evt: CommandEvent) -> None:
"save": save, "save": save,
"forced": evt.sender.force_login, "forced": evt.sender.force_login,
} }
try:
creds = await LoginCredential.get_by_mxid(evt.sender.mxid)
except:
evt.log.exception("Exception while looking for saved password")
creds = None
if creds and creds.email == email:
await evt.reply("Logging in with saved password")
await _login_with_password(
evt,
evt.sender.command_status.pop("email"),
creds.password,
evt.sender.command_status.pop("forced"),
)
return
""" TODO Implement web login """ TODO Implement web login
if evt.bridge.public_website: if evt.bridge.public_website:
@ -283,7 +277,7 @@ async def _handle_login_failure(evt: CommandEvent, e: Exception) -> None:
needs_auth=True, needs_auth=True,
management_only=True, management_only=True,
help_section=SECTION_AUTH, help_section=SECTION_AUTH,
help_text="Delete saved login password, if it was saved", help_text="Delete saved login password, if it was saved"
) )
async def forget_password(evt: CommandEvent) -> None: async def forget_password(evt: CommandEvent) -> None:
creds = await LoginCredential.get_by_mxid(evt.sender.mxid) creds = await LoginCredential.get_by_mxid(evt.sender.mxid)
@ -343,8 +337,7 @@ async def reset_device(evt: CommandEvent) -> None:
@command_handler( @command_handler(
needs_auth=False, needs_auth=False,
help_section=SECTION_AUTH, help_section=SECTION_AUTH,
help_text="When logging in, automatically log out of any other existing KakaoTalk session", help_text="When logging in, automatically log out of any other existing KakaoTalk session"
aliases=["enable-force-login"],
) )
async def enable_forced_login(evt: CommandEvent) -> None: async def enable_forced_login(evt: CommandEvent) -> None:
if evt.sender.force_login: if evt.sender.force_login:
@ -357,8 +350,7 @@ async def enable_forced_login(evt: CommandEvent) -> None:
@command_handler( @command_handler(
needs_auth=False, needs_auth=False,
help_section=SECTION_AUTH, help_section=SECTION_AUTH,
help_text="When logging in, ask before logging out of another existing KakaoTalk session, if one exists", help_text="When logging in, ask before logging out of another existing KakaoTalk session, if one exists"
aliases=["disable-force-login"],
) )
async def disable_forced_login(evt: CommandEvent) -> None: async def disable_forced_login(evt: CommandEvent) -> None:
if not evt.sender.force_login: if not evt.sender.force_login:

View File

@ -63,7 +63,7 @@ async def ping(evt: CommandEvent) -> None:
if not await evt.sender.is_logged_in(): if not await evt.sender.is_logged_in():
await evt.reply("You are **logged out** of KakaoTalk.") await evt.reply("You are **logged out** of KakaoTalk.")
else: else:
is_connected = await evt.sender.is_connected_now() 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" "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."
@ -76,7 +76,6 @@ async def ping(evt: CommandEvent) -> None:
help_section=SECTION_CONNECTION, help_section=SECTION_CONNECTION,
help_text="(Re)connect to KakaoTalk chats & sync any missed chat updates", help_text="(Re)connect to KakaoTalk chats & sync any missed chat updates",
help_args="[_number of channels to sync_]", help_args="[_number of channels to sync_]",
aliases=["connect"],
) )
async def sync(evt: CommandEvent) -> None: async def sync(evt: CommandEvent) -> None:
try: try:

View File

@ -19,7 +19,7 @@ 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 Format, SerializerError from mautrix.types import SerializerError
from mautrix.util import utf16_surrogate from mautrix.util import utf16_surrogate
from mautrix.util.formatter import ( from mautrix.util.formatter import (
EntityString, EntityString,
@ -65,13 +65,9 @@ async def whoami(evt: CommandEvent) -> None:
await evt.reply(f"Error from KakaoTalk: {e}") await evt.reply(f"Error from KakaoTalk: {e}")
return return
if own_info: if own_info:
puppet = await pu.Puppet.get_by_ktid(evt.sender.ktid)
uuid = f"`{own_info.more.uuid}` ({'searchable' if own_info.more.uuidSearchable else 'hidden'})" if own_info.more.uuid else "_none_" uuid = f"`{own_info.more.uuid}` ({'searchable' if own_info.more.uuidSearchable else 'hidden'})" if own_info.more.uuid else "_none_"
await evt.reply( await evt.reply(
f"You're logged in as **{own_info.more.nickName}**" f"You're logged in as **{own_info.more.nickName}** (KakaoTalk ID: {uuid}, internal ID: `{evt.sender.ktid}`)"
f"\n* KakaoTalk ID: {uuid}"
f"\n* Internal ID: `{evt.sender.ktid}`"
f"\n* Matrix user: [{puppet.name}](https://matrix.to/#/{puppet.default_mxid})"
) )
else: else:
await evt.reply( await evt.reply(
@ -157,13 +153,13 @@ async def _set_id_searchable(evt: CommandEvent, searchable: bool) -> None:
else: else:
await evt.reply(f"Successfully made KakaoTalk ID {'searchable' if searchable else 'hidden'}") 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)
if not puppet.name_set: if not puppet.name_set:
await puppet.update_info_from_friend(source, friend_struct) await puppet.update_info_from_friend(source, friend_struct)
return puppet return puppet
@command_handler( @command_handler(
needs_auth=True, needs_auth=True,
management_only=False, management_only=False,
@ -241,10 +237,10 @@ async def _edit_friend(evt: CommandEvent, add: bool) -> None:
if not evt.args: if not evt.args:
await evt.reply(f"**Usage:** `$cmdprefix+sp {evt.command} <KakaoTalk ID|Matrix user ID>`") await evt.reply(f"**Usage:** `$cmdprefix+sp {evt.command} <KakaoTalk ID|Matrix user ID>`")
return return
if evt.content.get("format", None) == Format.HTML and evt.content["formatted_body"]: formatted_body = evt.content.get("formatted_body")
parsed = await MentionParser().parse(utf16_surrogate.add( if formatted_body:
evt.content["formatted_body"][len(evt.command):].strip() arg = formatted_body[len(evt.command):].strip()
)) parsed = await MentionParser().parse(utf16_surrogate.add(arg))
if not parsed.entities: if not parsed.entities:
await evt.reply("No user found") await evt.reply("No user found")
return return
@ -315,7 +311,5 @@ async def leave(evt: CommandEvent) -> None:
await evt.reply("This command may only be used in a KakaoTalk channel portal room") await evt.reply("This command may only be used in a KakaoTalk channel portal room")
return return
await evt.mark_read() await evt.mark_read()
try: await evt.sender.client.leave_channel(evt.portal.channel_props)
await evt.sender.leave_channel(evt.portal) await evt.sender.on_channel_left(evt.portal.ktid, evt.portal.kt_type)
except CommandException as e:
await evt.reply(f"Error from KakaoTalk: {e}")

View File

@ -154,11 +154,7 @@ async def matrix_to_kakaotalk(
) )
else: else:
reply_to = None reply_to = None
if ( if content.get("format", None) == Format.HTML and content["formatted_body"] and content.msgtype.is_text:
content.get("format", None) == Format.HTML and content["formatted_body"] and
content.msgtype.is_text and
not portal.is_direct
):
parsed = await ToKakaoTalkParser().parse(utf16_surrogate.add(content["formatted_body"])) parsed = await ToKakaoTalkParser().parse(utf16_surrogate.add(content["formatted_body"]))
text = utf16_surrogate.remove(parsed.text) text = utf16_surrogate.remove(parsed.text)
mentions_by_user: dict[Long, MentionStruct] = {} mentions_by_user: dict[Long, MentionStruct] = {}

View File

@ -109,10 +109,6 @@ class Client:
await cls._rpc_client.connect() await cls._rpc_client.connect()
await cls._rpc_client.wait_for_disconnection() await cls._rpc_client.wait_for_disconnection()
@classmethod
def wait_for_connection(cls) -> Awaitable[None]:
return cls._rpc_client.wait_for_connection()
@classmethod @classmethod
def stop_cls(cls) -> None: def stop_cls(cls) -> None:
"""Stop and disconnect from the Node backend.""" """Stop and disconnect from the Node backend."""
@ -498,7 +494,6 @@ class Client:
description=description, description=description,
) )
""" TODO
def set_channel_photo( def set_channel_photo(
self, self,
channel_props: ChannelProps, channel_props: ChannelProps,
@ -509,7 +504,6 @@ class Client:
channel_props=channel_props.serialize(), channel_props=channel_props.serialize(),
photo_url=photo_url, photo_url=photo_url,
) )
"""
def create_direct_chat(self, ktid: Long) -> Awaitable[Long]: def create_direct_chat(self, ktid: Long) -> Awaitable[Long]:
return self._api_user_request_result( return self._api_user_request_result(
@ -687,9 +681,6 @@ class Client:
def _on_error(self, data: dict[str, JSON]) -> Awaitable[None]: def _on_error(self, data: dict[str, JSON]) -> Awaitable[None]:
return self.user.on_error(data) return self.user.on_error(data)
def _on_unexpected_disconnect(self, _: dict[str, JSON]) -> Awaitable[None]:
return self.user.on_unexpected_disconnect()
def _start_listen(self) -> None: def _start_listen(self) -> None:
self._add_event_handler("chat", self._on_chat) self._add_event_handler("chat", self._on_chat)
@ -707,7 +698,6 @@ class Client:
self._add_event_handler("disconnected", self._on_listen_disconnect) self._add_event_handler("disconnected", self._on_listen_disconnect)
self._add_event_handler("switch_server", self._on_switch_server) self._add_event_handler("switch_server", self._on_switch_server)
self._add_event_handler("error", self._on_error) self._add_event_handler("error", self._on_error)
self._add_event_handler("unexpected_disconnect", self._on_unexpected_disconnect)
def _stop_listen(self) -> None: def _stop_listen(self) -> None:
for method in self._handler_methods: for method in self._handler_methods:

View File

@ -96,12 +96,12 @@ class MatrixHandler(BaseMatrixHandler):
) )
return return
elif ( elif (
not user.is_connected not await user.is_logged_in()
and not portal.has_relay and not portal.has_relay
and not self.config["bridge.allow_invites"] and not self.config["bridge.allow_invites"]
): ):
await portal.main_intent.kick_user( await portal.main_intent.kick_user(
room_id, user.mxid, "You are not connected to this KakaoTalk bridge." room_id, user.mxid, "You are not logged in to this KakaoTalk bridge."
) )
return return

View File

@ -1219,6 +1219,9 @@ class Portal(DBPortal, BasePortal):
# Misses should be guarded by supports_state_event, but handle this just in case # Misses should be guarded by supports_state_event, but handle this just in case
self.log.error(f"Skipping Matrix state event {evt.event_id} of unsupported type {evt.type}") self.log.error(f"Skipping Matrix state event {evt.event_id} of unsupported type {evt.type}")
return return
if not self.is_open:
self.log.info(f"Not bridging f{handler.action_name} change of portal for non-open channel")
return
try: try:
effective_sender, _ = await self.get_relay_sender(sender, f"{handler.action_name} {evt.event_id}") effective_sender, _ = await self.get_relay_sender(sender, f"{handler.action_name} {evt.event_id}")
if effective_sender: if effective_sender:
@ -1335,9 +1338,6 @@ class Portal(DBPortal, BasePortal):
) -> None: ) -> None:
if content.topic == prev_content.topic: if content.topic == prev_content.topic:
return return
if not self.is_open:
self.log.info(f"Not bridging topic change of portal for non-open channel")
return
if not (sender and sender.is_connected): if not (sender and sender.is_connected):
raise Exception( raise Exception(
"Only users connected to KakaoTalk can set the description of a KakaoTalk channel" "Only users connected to KakaoTalk can set the description of a KakaoTalk channel"
@ -1758,20 +1758,19 @@ class Portal(DBPortal, BasePortal):
self, source: u.User, sender: p.Puppet | None, removed: p.Puppet self, source: u.User, sender: p.Puppet | None, removed: p.Puppet
) -> None: ) -> None:
sender_intent = sender.intent_for(self) if sender else self.main_intent sender_intent = sender.intent_for(self) if sender else self.main_intent
removed_user = await u.User.get_by_ktid(removed.ktid)
if sender == removed: if sender == removed:
if not removed.is_real_user and removed_user: removed_intent = removed.intent_for(self)
try: if removed_intent != self.main_intent:
await sender_intent.kick_user(self.mxid, removed_user.mxid, "Left channel from KakaoTalk") await removed_intent.leave_room(self.mxid)
except MForbidden: if not removed.is_real_user:
pass user = await u.User.get_by_ktid(removed.ktid)
await removed.intent_for(self).leave_room(self.mxid) if user:
await self.main_intent.kick_user(self.mxid, user.mxid, "Left channel from KakaoTalk")
else: else:
for removed_mxid in (r.mxid for r in ( for removed_mxid in (r.mxid for r in (
removed, removed,
removed_user if not removed.is_real_user else None await u.User.get_by_ktid(removed.ktid) if not removed.is_real_user else None
) if r): ) if r):
# NOTE KakaoTalk kick = Matrix ban
try: try:
await sender_intent.ban_user( await sender_intent.ban_user(
self.mxid, removed_mxid, None if sender else "Kicked by channel admin" self.mxid, removed_mxid, None if sender else "Kicked by channel admin"
@ -1782,11 +1781,7 @@ class Portal(DBPortal, BasePortal):
await self.main_intent.ban_user( await self.main_intent.ban_user(
self.mxid, removed_mxid, reason=f"Kicked by {sender.name}" self.mxid, removed_mxid, reason=f"Kicked by {sender.name}"
) )
if self.is_direct and removed.ktid == self.kt_receiver: # TODO Clean and delete if removed is real user and portal is direct / not open
self.log.info(
f"{removed.ktid} was the recipient puppet of this portal. Cleaning up and deleting..."
)
await self.cleanup_and_delete()
# TODO Find when or if there is a listener for this # TODO Find when or if there is a listener for this
# TODO Confirm whether this can refer to any user that was kicked, or only to the current user # TODO Confirm whether this can refer to any user that was kicked, or only to the current user
@ -1871,7 +1866,7 @@ class Portal(DBPortal, BasePortal):
self.log.trace("Leaving room with %s post-backfill", intent.mxid) self.log.trace("Leaving room with %s post-backfill", intent.mxid)
await intent.leave_room(self.mxid) await intent.leave_room(self.mxid)
self.log.info("Backfilled %d messages through %s", len(chats), source.mxid) self.log.info("Backfilled %d messages through %s", len(chats), source.mxid)
await self._sync_read_receipts(source) self._sync_read_receipts(source)
# region Database getters # region Database getters
@ -1882,7 +1877,7 @@ class Portal(DBPortal, BasePortal):
if not self.is_direct: if not self.is_direct:
self._main_intent = self.az.intent self._main_intent = self.az.intent
else: else:
# TODO Save kt_sender in DB instead? Only do that if keeping a unique DM portal for each receiver # TODO Save kt_sender in DB instead? Depends on if DM channels are shared...
user = await u.User.get_by_ktid(self.kt_receiver) user = await u.User.get_by_ktid(self.kt_receiver)
assert user, f"Found no user for this portal's receiver of {self.kt_receiver}" assert user, f"Found no user for this portal's receiver of {self.kt_receiver}"
if self.kt_type == KnownChannelType.MemoChat: if self.kt_type == KnownChannelType.MemoChat:
@ -1921,7 +1916,7 @@ class Portal(DBPortal, BasePortal):
create: bool = True, create: bool = True,
kt_type: ChannelType | None = None, kt_type: ChannelType | None = None,
) -> Portal | None: ) -> Portal | None:
# TODO Direct chats are shared, so can remove kt_receiver if DM portals should be shared # TODO Find out if direct channels are shared. If so, don't need kt_receiver!
if kt_type: if kt_type:
kt_receiver = kt_receiver if KnownChannelType.is_direct(kt_type) else 0 kt_receiver = kt_receiver if KnownChannelType.is_direct(kt_type) else 0
ktid_full = (ktid, kt_receiver) ktid_full = (ktid, kt_receiver)

View File

@ -247,7 +247,6 @@ class RPCClient:
self.log.debug(f"Nobody waiting for response to {req_id}") self.log.debug(f"Nobody waiting for response to {req_id}")
return return
if command == "response": if command == "response":
self.log.debug("Received response %d", req_id)
waiter.set_result(req.get("response")) waiter.set_result(req.get("response"))
elif command == "error": elif command == "error":
waiter.set_exception(RPCError(req.get("error", line))) waiter.set_exception(RPCError(req.get("error", line)))

View File

@ -177,9 +177,6 @@ class User(DBUser, BaseUser):
self._is_connected = val self._is_connected = val
self._connection_time = time.monotonic() self._connection_time = time.monotonic()
async def is_connected_now(self) -> bool:
return self._client is not None and await self._client.is_connected()
@property @property
def connection_time(self) -> float: def connection_time(self) -> float:
return self._connection_time return self._connection_time
@ -320,8 +317,6 @@ class User(DBUser, BaseUser):
oauth_credential = await Client.login(uuid=uuid, form=form, forced=True) oauth_credential = await Client.login(uuid=uuid, form=form, forced=True)
except OAuthException as e: except OAuthException as e:
latest_exc = e latest_exc = e
else:
return False
if oauth_credential: if oauth_credential:
self.oauth_credential = oauth_credential self.oauth_credential = oauth_credential
await self.save() await self.save()
@ -393,9 +388,6 @@ class User(DBUser, BaseUser):
) -> None: ) -> None:
try: try:
if not await self._load_session(is_startup=is_startup) and self.has_state: if not await self._load_session(is_startup=is_startup) and self.has_state:
self.log.debug("reload_session failure: wait for connection to Node module before prompting for manual login")
await Client.wait_for_connection()
self.log.debug("reload_session failure: now connected to Node module")
await self.send_bridge_notice( await self.send_bridge_notice(
"Logged out of KakaoTalk. Must use the `login` command to log back in.", "Logged out of KakaoTalk. Must use the `login` command to log back in.",
important=True, important=True,
@ -437,6 +429,7 @@ class User(DBUser, BaseUser):
async def logout(self, *, remove_ktid: bool = True, reset_device: bool = False) -> None: async def logout(self, *, remove_ktid: bool = True, reset_device: bool = False) -> None:
if self._client: if self._client:
# TODO Look for a logout API call
await self._client.stop() await self._client.stop()
if remove_ktid: if remove_ktid:
await self.push_bridge_state(BridgeStateEvent.LOGGED_OUT) await self.push_bridge_state(BridgeStateEvent.LOGGED_OUT)
@ -688,7 +681,7 @@ class User(DBUser, BaseUser):
kt_type = KnownChannelType.MemoChat kt_type = KnownChannelType.MemoChat
memo_ids = await self.client.get_memo_ids() memo_ids = await self.client.get_memo_ids()
if not memo_ids: if not memo_ids:
ktid = None ktid = Long(0)
else: else:
ktid = memo_ids[0] ktid = memo_ids[0]
if len(memo_ids) > 1: if len(memo_ids) > 1:
@ -705,13 +698,6 @@ class User(DBUser, BaseUser):
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 ) if ktid else None
# region Matrix->KakaoTalk commands
async def leave_channel(self, portal: po.Portal) -> None:
await self.client.leave_channel(portal.channel_props)
await self.on_channel_left(portal.ktid, portal.kt_type)
# endregion
# region KakaoTalk event handling # region KakaoTalk event handling
async def on_connect(self, force_sync: bool) -> bool: async def on_connect(self, force_sync: bool) -> bool:
@ -760,10 +746,7 @@ class User(DBUser, BaseUser):
reason_suffix = "To reconnect, use the `sync` command." reason_suffix = "To reconnect, use the `sync` command."
else: else:
reason_suffix = "You are now logged out. To log back in, use the `login` command." reason_suffix = "You are now logged out. To log back in, use the `login` command."
await self.send_bridge_notice( await self.send_bridge_notice(f"Disconnected from KakaoTalk: {reason_str} {reason_suffix}")
f"Disconnected from KakaoTalk: {reason_str} {reason_suffix}",
important=True,
)
async def on_error(self, error: JSON) -> None: async def on_error(self, error: JSON) -> None:
await self.send_bridge_notice( await self.send_bridge_notice(
@ -773,22 +756,6 @@ class User(DBUser, BaseUser):
error_message=str(error), error_message=str(error),
) )
async def on_unexpected_disconnect(self) -> None:
self.is_connected = False
self._track_metric(METRIC_CONNECTED, False)
if self.config["bridge.remain_logged_in_on_disconnect"]:
# TODO What bridge state to push?
self.was_connected = False
await self.save()
reason_suffix = "To reconnect, use the `sync` command."
else:
await self.logout()
reason_suffix = "You are now logged out. To log back in, use the `login` command."
await self.send_bridge_notice(
f"Disconnected from KakaoTalk: unexpected error in backend helper module. {reason_suffix}",
important=True,
)
async def on_client_disconnect(self) -> None: async def on_client_disconnect(self) -> None:
self.is_connected = False self.is_connected = False
self._track_metric(METRIC_CONNECTED, False) self._track_metric(METRIC_CONNECTED, False)

View File

@ -2,11 +2,3 @@
If `type` is `unix`, `path` is the path where to create the socket, and `force` is whether to overwrite the socket file if it already exists. If `type` is `unix`, `path` is the path where to create the socket, and `force` is whether to overwrite the socket file if it already exists.
If `type` is `tcp`, `port` and `host` are the host/port where to listen. If `type` is `tcp`, `port` and `host` are the host/port where to listen.
### Register timeout config
`register_timeout` is the amount of time (in milliseconds) that a connecting peer must send a "register" command after initiating a connection.
### Logging config
`logging_keys` specifies which properties of RPC request & response objects to print in logs. Optional.
A special-case logging key for responses is `value`, which enables logging responses that are primitives instead of objects.

View File

@ -3,10 +3,5 @@
"type": "unix", "type": "unix",
"path": "/data/rpc.sock", "path": "/data/rpc.sock",
"force": true "force": true
},
"register_timeout": 3000,
"logging_keys": {
"request": ["mxid"],
"response": ["status"]
} }
} }

View File

@ -3,10 +3,5 @@
"type": "unix", "type": "unix",
"path": "/var/run/matrix-appservice-kakaotalk/rpc.sock", "path": "/var/run/matrix-appservice-kakaotalk/rpc.sock",
"force": false "force": false
},
"register_timeout": 3000,
"logging_keys": {
"request": ["mxid"],
"response": ["status"]
} }
} }

View File

@ -43,7 +43,7 @@ const { KnownChatType } = chat
import { emitLines, promisify } from "./util.js" import { emitLines, promisify } from "./util.js"
/** /**
* @typedef {object} ChannelProps * @typedef {Object} ChannelProps
* @property {Long} id * @property {Long} id
* @property {ChannelType} type * @property {ChannelType} type
*/ */
@ -80,7 +80,6 @@ class PermError extends ProtocolError {
/** /**
* @param {?OpenChannelUserPerm[]} permNeeded * @param {?OpenChannelUserPerm[]} permNeeded
* @param {?OpenChannelUserPerm} permActual * @param {?OpenChannelUserPerm} permActual
* @param {string} action
*/ */
constructor(permNeeded, permActual, action) { constructor(permNeeded, permActual, action) {
const who = const who =
@ -98,7 +97,6 @@ class PermError extends ProtocolError {
class UserClient { class UserClient {
static #initializing = false static #initializing = false
#connected = false
#talkClient = new TalkClient() #talkClient = new TalkClient()
get talkClient() { return this.#talkClient } get talkClient() { return this.#talkClient }
@ -157,19 +155,10 @@ class UserClient {
}) })
this.#talkClient.on("chat_read", (chat, channel, reader) => { this.#talkClient.on("chat_read", (chat, channel, reader) => {
let senderId this.log(`Chat ${chat.logId} read in channel ${channel.channelId} by ${reader.userId}`)
if (reader) {
senderId = reader.userId
} else if (channel.info.type == "MemoChat") {
senderId = channel.clientUser.userId
} else {
this.error(`Chat ${chat.logId} read in channel ${channel.channelId} by unknown reader (channel type: ${channel.info.type || "none"})`)
return
}
this.log(`Chat ${chat.logId} read in channel ${channel.channelId} by ${senderId}`)
this.write("chat_read", { this.write("chat_read", {
chatId: chat.logId, chatId: chat.logId,
senderId: senderId, senderId: reader.userId,
channelId: channel.channelId, channelId: channel.channelId,
channelType: channel.info.type, channelType: channel.info.type,
}) })
@ -309,7 +298,7 @@ class UserClient {
} }
}) })
this.#talkClient.on("disconnected", reason => { this.#talkClient.on("disconnected", (reason) => {
this.log(`Disconnected (reason=${reason})`) this.log(`Disconnected (reason=${reason})`)
this.disconnect() this.disconnect()
this.write("disconnected", { this.write("disconnected", {
@ -324,7 +313,7 @@ class UserClient {
}) })
}) })
this.#talkClient.on("error", err => { this.#talkClient.on("error", (err) => {
this.log(`Client error: ${err}`) this.log(`Client error: ${err}`)
this.write("error", { this.write("error", {
error: err, error: err,
@ -366,39 +355,20 @@ class UserClient {
* @param {ChannelProps} channelProps * @param {ChannelProps} channelProps
*/ */
async getChannel(channelProps) { async getChannel(channelProps) {
const talkChannel = this.#talkClient.channelList.get(channelProps.id) let channel = this.#talkClient.channelList.get(channelProps.id)
if (talkChannel) { if (channel) {
return talkChannel return channel
} } else {
const channelList = getChannelListForType( const channelList = getChannelListForType(
this.#talkClient.channelList, this.#talkClient.channelList,
channelProps.type channelProps.type
) )
const res = await channelList.addChannel({ channelId: channelProps.id }) const res = await channelList.addChannel({ channelId: channelProps.id })
if (!res.success) { if (!res.success) {
this.error(`Unable to add ${channelProps.type} channel ${channelProps.id}`) throw new Error(`Unable to add ${channelProps.type} channel ${channelProps.id}`)
throw res
} }
return res.result return res.result
} }
/**
* @param {Long} channelId
*/
async getNormalChannel(channelId) {
const channelList = this.#talkClient.channelList.normal
const talkChannel = channelList.get(channelId)
if (talkChannel) {
return talkChannel
}
const res = await channelList.addChannel({ channelId: channelId })
if (!res.success) {
this.error(`Unable to add normal channel ${channelProps.id}`)
throw res
}
return res.result
} }
/** /**
@ -410,26 +380,19 @@ class UserClient {
if (credential && this.#credential != credential) { if (credential && this.#credential != credential) {
await this.setCredential(credential) await this.setCredential(credential)
} }
const res = await this.#talkClient.login(this.#credential) return await this.#talkClient.login(this.#credential)
this.#connected = res.success
return res
} }
disconnect() { disconnect() {
if (this.isConnected()) { if (this.#talkClient.logon) {
this.#talkClient.close() this.#talkClient.close()
} }
this.#connected = false
} }
isConnected() { isConnected() {
return this.#talkClient?.logon || false return this.#talkClient?.logon || false
} }
isUnexpectedlyDisconnected() {
return this.#connected && !this.isConnected()
}
/** /**
* Send a user-specific command with (optional) data to the socket. * Send a user-specific command with (optional) data to the socket.
* *
@ -453,17 +416,15 @@ export default class PeerClient {
*/ */
constructor(manager, socket, connID) { constructor(manager, socket, connID) {
this.manager = manager this.manager = manager
this.registerTimeout = manager.registerTimeout
this.loggingKeys = manager.loggingKeys
this.socket = socket this.socket = socket
this.connID = connID this.connID = connID
this.stopped = false this.stopped = false
this.notificationID = 0 this.notificationID = 0
this.maxCommandID = 0 this.maxCommandID = 0
this.peerID = "" this.peerID = null
this.deviceName = "KakaoTalk Bridge" this.deviceName = "KakaoTalk Bridge"
/** @type {Map<string, UserClient>} */ /** @type {Map<string, UserClient>} */
this.userClients = new Map() this.userClients = new Map()
} }
@ -494,10 +455,10 @@ export default class PeerClient {
setTimeout(() => { setTimeout(() => {
if (!this.peerID && !this.stopped) { if (!this.peerID && !this.stopped) {
this.log(`Didn't receive register request within ${this.registerTimeout/1000} seconds, terminating`) this.log("Didn't receive register request within 3 seconds, terminating")
this.stop("Register request timeout") this.stop("Register request timeout")
} }
}, this.registerTimeout) }, 3000)
} }
async stop(error = null) { async stop(error = null) {
@ -521,11 +482,11 @@ export default class PeerClient {
if (this.peerID && this.manager.clients.get(this.peerID) === this) { if (this.peerID && this.manager.clients.get(this.peerID) === this) {
this.manager.clients.delete(this.peerID) this.manager.clients.delete(this.peerID)
} }
this.log(`Connection closed (peer: ${this.peerID || "unknown peer"})`) this.log(`Connection closed (peer: ${this.peerID})`)
} }
#closeUsers() { #closeUsers() {
this.log(`Closing all API clients for ${this.peerID || "unknown peer"}`) this.log("Closing all API clients for", this.peerID)
for (const userClient of this.userClients.values()) { for (const userClient of this.userClients.values()) {
userClient.disconnect() userClient.disconnect()
} }
@ -543,10 +504,10 @@ export default class PeerClient {
} }
/** /**
* @param {object} req * @param {Object} req
* @param {string} req.passcode * @param {string} req.passcode
* @param {string} req.uuid * @param {string} req.uuid
* @param {object} req.form * @param {Object} req.form
*/ */
registerDevice = async (req) => { registerDevice = async (req) => {
// TODO Look for a deregister API call // TODO Look for a deregister API call
@ -556,15 +517,16 @@ export default class PeerClient {
/** /**
* Obtain login tokens. If this fails due to not having a device, also request a device passcode. * Obtain login tokens. If this fails due to not having a device, also request a device passcode.
* @param {object} req * @param {Object} req
* @param {string} req.uuid * @param {string} req.uuid
* @param {object} req.form * @param {Object} req.form
* @param {boolean} req.forced * @param {boolean} req.forced
* @returns The response of the login attempt, including obtained * @returns The response of the login attempt, including obtained
* credentials for subsequent token-based login. If a required device passcode * credentials for subsequent token-based login. If a required device passcode
* request failed, its status is stored here. * request failed, its status is stored here.
*/ */
handleLogin = async (req) => { handleLogin = async (req) => {
// TODO Look for a logout API call
const authClient = await this.#createAuthClient(req.uuid) const authClient = await this.#createAuthClient(req.uuid)
const loginRes = await authClient.login(req.form, req.forced) const loginRes = await authClient.login(req.form, req.forced)
if (loginRes.status === KnownAuthStatusCode.DEVICE_NOT_REGISTERED) { if (loginRes.status === KnownAuthStatusCode.DEVICE_NOT_REGISTERED) {
@ -612,41 +574,23 @@ export default class PeerClient {
* @param {string} mxid * @param {string} mxid
* @param {ChannelProps} channelProps * @param {ChannelProps} channelProps
* @param {?OpenChannelUserPerm[]} permNeeded If set, throw if the user's permission level matches none of the values in this list. * @param {?OpenChannelUserPerm[]} permNeeded If set, throw if the user's permission level matches none of the values in this list.
* @param {?string} action The action requiring permission, to be used in an error message if throwing. * @param {?string} action The action requiring permission, to be used in an error message if throwing..
* @throws {PermError} if the user does not have the specified permission level. * @throws {PermError} if the user does not have the specified permission level.
*/ */
async #getUserChannel(mxid, channelProps, permNeeded, action) { async #getUserChannel(mxid, channelProps, permNeeded, action) {
const userClient = this.#getUser(mxid) const userClient = this.#getUser(mxid)
const talkChannel = await userClient.getChannel(channelProps) const talkChannel = await userClient.getChannel(channelProps)
if (permNeeded) { if (permNeeded) {
await this.#requireChannelPerm(talkChannel, permNeeded, action) const permActual = talkChannel.getUserInfo({ userId: userClient.userId }).perm
if (permNeeded.indexOf(permActual) == -1) {
throw new PermError(permNeeded, permActual, action)
}
} }
return talkChannel return talkChannel
} }
/** /**
* @param {TalkOpenChannel} talkChannel * @param {Object} req
* @param {OpenChannelUserPerm[]} permNeeded Throw if the user's permission level matches none of the values in this list.
* @param {string} action The action requiring permission
* @throws {PermError} if the user does not have the specified permission level.
*/
async #requireChannelPerm(talkChannel, permNeeded, action) {
const permActual = talkChannel.getUserInfo({ userId: talkChannel.clientUser.userId }).perm
if (permNeeded.indexOf(permActual) == -1) {
throw new PermError(permNeeded, permActual, action)
}
}
/**
* @param {string} mxid
* @param {Long} channelId
*/
async #getUserNormalChannel(mxid, channelId) {
return await this.#getUser(mxid).getNormalChannel(channelId)
}
/**
* @param {object} req
* @param {string} req.mxid * @param {string} req.mxid
* @param {OAuthCredential} req.oauth_credential * @param {OAuthCredential} req.oauth_credential
*/ */
@ -661,7 +605,7 @@ export default class PeerClient {
} }
/** /**
* @param {object} req * @param {Object} req
* @param {string} req.mxid * @param {string} req.mxid
* @param {OAuthCredential} req.oauth_credential * @param {OAuthCredential} req.oauth_credential
*/ */
@ -680,7 +624,7 @@ export default class PeerClient {
} }
/** /**
* @param {object} req * @param {Object} req
* @param {string} req.mxid * @param {string} req.mxid
*/ */
userStop = async (req) => { userStop = async (req) => {
@ -689,7 +633,7 @@ export default class PeerClient {
} }
/** /**
* @param {object} req * @param {Object} req
* @param {string} req.mxid * @param {string} req.mxid
* @param {?OAuthCredential} req.oauth_credential * @param {?OAuthCredential} req.oauth_credential
*/ */
@ -698,7 +642,7 @@ export default class PeerClient {
} }
/** /**
* @param {object} req * @param {Object} req
* @param {string} req.mxid * @param {string} req.mxid
*/ */
handleDisconnect = (req) => { handleDisconnect = (req) => {
@ -706,7 +650,7 @@ export default class PeerClient {
} }
/** /**
* @param {object} req * @param {Object} req
* @param {string} req.mxid * @param {string} req.mxid
*/ */
isConnected = (req) => { isConnected = (req) => {
@ -714,7 +658,7 @@ export default class PeerClient {
} }
/** /**
* @param {object} req * @param {Object} req
* @param {string} req.mxid * @param {string} req.mxid
*/ */
getSettings = async (req) => { getSettings = async (req) => {
@ -738,7 +682,7 @@ export default class PeerClient {
} }
/** /**
* @param {object} req * @param {Object} req
* @param {string} req.mxid * @param {string} req.mxid
*/ */
getOwnProfile = async (req) => { getOwnProfile = async (req) => {
@ -746,7 +690,7 @@ export default class PeerClient {
} }
/** /**
* @param {object} req * @param {Object} req
* @param {string} req.mxid * @param {string} req.mxid
* @param {Long} req.user_id * @param {Long} req.user_id
*/ */
@ -755,7 +699,7 @@ export default class PeerClient {
} }
/** /**
* @param {object} req * @param {Object} req
* @param {string} req.mxid * @param {string} req.mxid
* @param {ChannelProps} req.channel_props * @param {ChannelProps} req.channel_props
*/ */
@ -779,7 +723,7 @@ export default class PeerClient {
} }
/** /**
* @param {object} req * @param {Object} req
* @param {string} req.mxid * @param {string} req.mxid
* @param {ChannelProps} req.channel_props * @param {ChannelProps} req.channel_props
*/ */
@ -813,7 +757,7 @@ export default class PeerClient {
} }
/** /**
* @param {object} req * @param {Object} req
* @param {string} req.mxid * @param {string} req.mxid
* @param {ChannelProps} req.channel_props * @param {ChannelProps} req.channel_props
*/ */
@ -823,7 +767,7 @@ export default class PeerClient {
} }
/** /**
* @param {object} req * @param {Object} req
* @param {string} req.mxid * @param {string} req.mxid
* @param {ChannelProps} req.channel_props * @param {ChannelProps} req.channel_props
* @param {?Long} req.sync_from * @param {?Long} req.sync_from
@ -850,7 +794,7 @@ export default class PeerClient {
} }
/** /**
* @param {object} req * @param {Object} req
* @param {string} req.mxid * @param {string} req.mxid
* @param {ChannelProps} req.channel_props * @param {ChannelProps} req.channel_props
* @param {[Long]} req.unread_chat_ids Must be in DECREASING order * @param {[Long]} req.unread_chat_ids Must be in DECREASING order
@ -880,7 +824,7 @@ export default class PeerClient {
} }
/** /**
* @typedef {object} Receipt * @typedef {Object} Receipt
* @property {Long} userId * @property {Long} userId
* @property {Long} chatId * @property {Long} chatId
*/ */
@ -891,7 +835,7 @@ export default class PeerClient {
} }
/** /**
* @param {object} req * @param {Object} req
* @param {string} req.mxid * @param {string} req.mxid
* @param {string} req.uuid * @param {string} req.uuid
*/ */
@ -900,7 +844,7 @@ export default class PeerClient {
} }
/** /**
* @param {object} req * @param {Object} req
* @param {string} req.mxid * @param {string} req.mxid
* @param {string} req.uuid * @param {string} req.uuid
*/ */
@ -914,7 +858,7 @@ export default class PeerClient {
} }
/** /**
* @param {object} req * @param {Object} req
* @param {string} req.mxid * @param {string} req.mxid
* @param {boolean} req.searchable * @param {boolean} req.searchable
*/ */
@ -936,7 +880,7 @@ export default class PeerClient {
} }
/** /**
* @param {object} req * @param {Object} req
* @param {string} req.mxid * @param {string} req.mxid
*/ */
listFriends = async (req) => { listFriends = async (req) => {
@ -944,7 +888,7 @@ export default class PeerClient {
} }
/** /**
* @param {object} req * @param {Object} req
* @param {string} req.mxid * @param {string} req.mxid
* @param {Long} req.user_id * @param {Long} req.user_id
* @param {boolean} req.add * @param {boolean} req.add
@ -958,7 +902,7 @@ export default class PeerClient {
} }
/** /**
* @param {object} req * @param {Object} req
* @param {string} req.mxid * @param {string} req.mxid
* @param {string} req.uuid * @param {string} req.uuid
* @param {boolean} req.add * @param {boolean} req.add
@ -995,7 +939,7 @@ export default class PeerClient {
} }
/** /**
* @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.
* @param {string} req.friend_id The friend to search for. * @param {string} req.friend_id The friend to search for.
* @param {string} propertyName The property to retrieve from the specified friend. * @param {string} propertyName The property to retrieve from the specified friend.
@ -1008,7 +952,7 @@ export default class PeerClient {
} }
/** /**
* @param {object} req * @param {Object} req
* @param {string} req.mxid * @param {string} req.mxid
*/ */
getMemoIds = (req) => { getMemoIds = (req) => {
@ -1025,7 +969,7 @@ export default class PeerClient {
} }
/** /**
* @param {object} req * @param {Object} req
* @param {string} req.mxid * @param {string} req.mxid
* @param {ChannelProps} req.channel_props * @param {ChannelProps} req.channel_props
* @param {string} req.key * @param {string} req.key
@ -1040,7 +984,7 @@ export default class PeerClient {
} }
/** /**
* @param {object} req * @param {Object} req
* @param {string} req.mxid * @param {string} req.mxid
* @param {ChannelProps} req.channel_props * @param {ChannelProps} req.channel_props
* @param {string} req.text * @param {string} req.text
@ -1059,7 +1003,7 @@ export default class PeerClient {
} }
/** /**
* @param {object} req * @param {Object} req
* @param {string} req.mxid * @param {string} req.mxid
* @param {ChannelProps} req.channel_props * @param {ChannelProps} req.channel_props
* @param {int} req.type * @param {int} req.type
@ -1083,7 +1027,7 @@ export default class PeerClient {
} }
/** /**
* @param {object} req * @param {Object} req
* @param {string} req.mxid * @param {string} req.mxid
* @param {ChannelProps} req.channel_props * @param {ChannelProps} req.channel_props
* @param {Long} req.chat_id * @param {Long} req.chat_id
@ -1097,7 +1041,7 @@ export default class PeerClient {
} }
/** /**
* @param {object} req * @param {Object} req
* @param {string} req.mxid * @param {string} req.mxid
* @param {ChannelProps} req.channel_props * @param {ChannelProps} req.channel_props
* @param {Long} req.read_until_chat_id * @param {Long} req.read_until_chat_id
@ -1111,7 +1055,7 @@ export default class PeerClient {
} }
/** /**
* @param {object} req * @param {Object} req
* @param {string} req.mxid * @param {string} req.mxid
* @param {ChannelProps} req.channel_props * @param {ChannelProps} req.channel_props
* @param {Long} req.user_id * @param {Long} req.user_id
@ -1128,69 +1072,50 @@ export default class PeerClient {
if (!talkChannel.getUserInfo(user)) { if (!talkChannel.getUserInfo(user)) {
throw new ProtocolError("Cannot set permission level of a user that is not a channel participant") throw new ProtocolError("Cannot set permission level of a user that is not a channel participant")
} }
if (req.user_id.equals(talkChannel.clientUser.userId)) { if (req.user_id == talkChannel.clientUser.userId) {
throw new ProtocolError("Cannot change own permission level") throw new ProtocolError("Cannot change own permission level")
} }
return await talkChannel.setUserPerm(user, req.perm) return await talkChannel.setUserPerm(user, req.perm)
} }
/** /**
* @param {object} req * @param {Object} req
* @param {string} req.mxid * @param {string} req.mxid
* @param {ChannelProps} req.channel_props * @param {ChannelProps} req.channel_props
* @param {string} req.name * @param {string} req.name
*/ */
setChannelName = async (req) => { setChannelName = async (req) => {
if (!isChannelTypeOpen(req.channel_props.type)) { const talkChannel = await this.#getUserChannel(
const talkChannel = await this.#getUserNormalChannel(req.mxid, req.channel_props.id) req.mxid,
req.channel_props,
[OpenChannelUserPerm.OWNER],
"change channel name"
)
return await talkChannel.setTitleMeta(req.name) return await talkChannel.setTitleMeta(req.name)
} else {
return await this.#setOpenChannelProperty(req.mxid, req.channel_props, "linkName", req.name)
}
} }
/** /**
* @param {object} req * @param {Object} req
* @param {string} req.mxid * @param {string} req.mxid
* @param {ChannelProps} req.channel_props * @param {ChannelProps} req.channel_props
* @param {string} req.description * @param {string} req.description
*/ */
setChannelDescription = async (req) => { setChannelDescription = async (req) => {
return await this.#setOpenChannelProperty(req.mxid, req.channel_props, "description", req.description) const talkChannel = await this.#getUserChannel(
req.mxid,
req.channel_props,
[OpenChannelUserPerm.OWNER],
"change channel description"
)
return await talkChannel.setNoticeMeta(req.description)
} }
/** /**
* @param {string} mxid * @param {Object} req
* @param {ChannelProps} channelProps
* @param {string} propertyName
* @param {any} propertyValue
*/
async #setOpenChannelProperty(mxid, channelProps, propertyName, propertyValue) {
if (isChannelTypeOpen(channelProps)) {
throw ProtocolError(`Cannot set ${propertyName} of non-open channel ${channelProps.id} (type = ${channelProps.type})`)
}
const userClient = this.#getUser(mxid)
/** @type {TalkOpenChannel} */
const talkChannel = await userClient.getChannel(channelProps)
this.#requireChannelPerm(talkChannel, [OpenChannelUserPerm.OWNER], `change channel ${propertyName}`)
const linkRes = await talkChannel.getLatestOpenLink()
if (!linkRes.success) throw linkRes
const link = linkRes.result
link[propertyName] = propertyValue
return await userClient.talkClient.channelList.open.updateOpenLink(
{ linkId: link.linkId }, link
)
}
/*
* TODO
* @param {object} req
* @param {string} req.mxid * @param {string} req.mxid
* @param {ChannelProps} req.channel_props * @param {ChannelProps} req.channel_props
* @param {string} req.photo_url * @param {string} req.photo_url
*/
setChannelPhoto = async (req) => { setChannelPhoto = async (req) => {
const talkChannel = await this.#getUserChannel( const talkChannel = await this.#getUserChannel(
req.mxid, req.mxid,
@ -1203,59 +1128,23 @@ export default class PeerClient {
fullImageUrl: req.photo_url, fullImageUrl: req.photo_url,
}) })
} }
*/
/** /**
* @param {object} req * @param {Object} req
* @param {string} req.mxid * @param {string} req.mxid
* @param {Long} req.user_id * @param {Long} req.user_id
*/ */
createDirectChat = async (req) => { createDirectChat = async (req) => {
const userClient = this.#getUser(req.mxid) const channelList = this.#getUser(req.mxid).talkClient.channelList.normal
await this.#requireInFriendsList(userClient.serviceClient, req.user_id) const res = await channelList.createChannel({
const channelList = userClient.talkClient.channelList.normal
const createChannel =
!req.user_id.equals(userClient.userId)
? channelList.createChannel.bind(channelList, {
userList: [{ userId: req.user_id }], userList: [{ userId: req.user_id }],
}) })
: channelList.createMemoChannel.bind(channelList) if (!res.success) return res
const retry_delay = 2000
let retries_left = 1
let res
do {
res = await createChannel()
if (res.success) {
return makeCommandResult(res.result.channelId) return makeCommandResult(res.result.channelId)
} }
this.error(`Failed to create direct chat, try again in ${retry_delay} ms (${retries_left - 1} tries remaining)`)
await new Promise(resolve => setTimeout(resolve, retry_delay))
} while (retries_left--)
this.error(`Failed to create direct chat, not retrying`)
return res
}
/** /**
* @param {ServiceApiClient} serviceClient * @param {Object} req
* @param {Long} id
*/
async #requireInFriendsList(serviceClient, id) {
let listRes = await serviceClient.requestFriendList()
if (!listRes.success) {
this.error("Failed to check friends list")
throw listRes
}
const isFriend = -1 != listRes.result.friends.findIndex(friend => id.equals(friend.userId))
if (!isFriend) {
throw new ProtocolError("This user is not in your friends list")
}
}
/**
* @param {object} req
* @param {string} req.mxid * @param {string} req.mxid
* @param {ChannelProps} req.channel_props * @param {ChannelProps} req.channel_props
*/ */
@ -1273,9 +1162,9 @@ export default class PeerClient {
} }
/** /**
* @param {object} req * @param {Object} req
* @param {string} req.peer_id * @param {string} req.peer_id
* @param {object} req.register_config * @param {Object} req.register_config
* @param {string} req.register_config.device_name * @param {string} req.register_config.device_name
*/ */
handleRegister = async (req) => { handleRegister = async (req) => {
@ -1311,7 +1200,7 @@ export default class PeerClient {
this.log("Ignoring old request", req.id) this.log("Ignoring old request", req.id)
return return
} }
this.log(`Request ${req.id}:`, this.#logObj(req, req.command, this.loggingKeys.request)) this.log("Received request", req.id, "with command", req.command)
this.maxCommandID = req.id this.maxCommandID = req.id
let handler let handler
if (!this.peerID) { if (!this.peerID) {
@ -1361,7 +1250,7 @@ export default class PeerClient {
send_perm: this.sendPerm, send_perm: this.sendPerm,
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, create_direct_chat: this.createDirectChat,
leave_channel: this.leaveChannel, leave_channel: this.leaveChannel,
}[req.command] || this.handleUnknownCommand }[req.command] || this.handleUnknownCommand
@ -1378,59 +1267,21 @@ export default class PeerClient {
success: false, success: false,
status: err.response.status, status: err.response.status,
} }
} else if ("status" in err) {
resp.response = err
} else { } else {
resp.command = "error" resp.command = "error"
let errorDetails
if (err instanceof ProtocolError) { if (err instanceof ProtocolError) {
resp.error = err.message resp.error = err.message
errorDetails = err.message
} else if (err instanceof Error) {
resp.error = err.toString()
errorDetails = err.stack
} else { } else {
resp.error = JSON.stringify(err) resp.error = err.toString()
errorDetails = `throwed ${resp.error}` this.log(`Error handling request ${resp.id} ${err.stack}`)
}
this.error(`Response ${resp.id}: ${errorDetails}`)
} }
} }
if (resp.response) {
const success = resp.response.success !== false
const logger = (success ? this.log : this.error).bind(this)
logger(
`Response ${resp.id}:`,
this.#logObj(
resp.response instanceof Object ? resp.response : {value: resp.response},
success ? "success" : "failure",
this.loggingKeys.response
)
)
} }
// TODO Check if session is broken. If it is, close the PeerClient
await this.write(resp) await this.write(resp)
if ("mxid" in req) {
const userClient = this.#tryGetUser(req.mxid)
if (userClient && userClient.isUnexpectedlyDisconnected()) {
this.error("Unexpected disconnect for user", req.mxid)
this.userClients.delete(req.mxid)
await userClient.write("unexpected_disconnect")
}
}
} }
/** #writeReplacer = function(key, value) {
* @param {object} obj
* @param {string} desc
* @param {[string]} keys
*/
#logObj(obj, desc, keys) {
return [desc].concat(
keys.filter(key => key in obj).map(key => `${key}: ${JSON.stringify(obj[key], this.#writeReplacer)}`)
).join(', ')
}
#writeReplacer(key, value) {
if (value instanceof Long) { if (value instanceof Long) {
return value.toString() return value.toString()
} else { } else {
@ -1438,7 +1289,7 @@ export default class PeerClient {
} }
} }
#readReviver(key, value) { #readReviver = function(key, value) {
if (value instanceof Object) { if (value instanceof Object) {
// TODO Use a type map if there will be many possible types // TODO Use a type map if there will be many possible types
if (value.__type__ == "Long") { if (value.__type__ == "Long") {

View File

@ -20,33 +20,10 @@ import path from "path"
import PeerClient from "./client.js" import PeerClient from "./client.js"
import { promisify } from "./util.js" import { promisify } from "./util.js"
/**
* @typedef {object} ListenConfig
* @property {string} type
* @property {string} path
* @property {boolean} force
*/
/**
* @typedef {object} LoggingKeys
* @property {[string]} request
* @property {[string]} response
*/
export default class ClientManager { export default class ClientManager {
/** constructor(listenConfig) {
* @param {ListenConfig} listenConfig
* @param {number} registerTimeout
* @param {?LoggingKeys} loggingKeys
*/
constructor(listenConfig, registerTimeout, loggingKeys) {
if (!listenConfig) {
throw new Error("Listen config missing")
}
this.listenConfig = listenConfig this.listenConfig = listenConfig
this.registerTimeout = registerTimeout || 3000
this.loggingKeys = loggingKeys || {request: [], response: []}
this.server = net.createServer(this.acceptConnection) this.server = net.createServer(this.acceptConnection)
this.connections = [] this.connections = []
this.clients = new Map() this.clients = new Map()
@ -65,7 +42,7 @@ export default class ClientManager {
} else { } else {
const connID = this.connIDSequence++ const connID = this.connIDSequence++
this.connections[connID] = sock this.connections[connID] = sock
new PeerClient(this, sock, connID, this.loggingKeys).start() new PeerClient(this, sock, connID).start()
} }
} }

View File

@ -32,11 +32,7 @@ const configPath = args["--config"] || "config.json"
console.log("[Main] Reading config from", configPath) console.log("[Main] Reading config from", configPath)
const config = JSON.parse(fs.readFileSync(configPath).toString()) const config = JSON.parse(fs.readFileSync(configPath).toString())
const manager = new ClientManager( const manager = new ClientManager(config.listen)
config.listen,
config.register_timeout,
config.logging_keys
)
function stop() { function stop() {
manager.stop().then(() => { manager.stop().then(() => {