Compare commits

...

33 Commits

Author SHA1 Message Date
Andrew Ferrazzutti 0dc75b8f1c Update roadmap with as-of-yet unsupported features 2022-05-16 03:10:33 -04:00
Andrew Ferrazzutti 46ace01fea Check for connectivity, not login status, on join from Matrix
Because connectivity is a cached & more specific than login status
2022-05-16 03:01:12 -04:00
Andrew Ferrazzutti 82d64b9b37 Support a more obvious non-cached connection check 2022-05-16 03:01:12 -04:00
Andrew Ferrazzutti 47b9623446 Fast-fail when trying to create a DM with a non-friend user 2022-05-16 03:01:12 -04:00
Andrew Ferrazzutti 1541732d0b Fixes to outbound channel name & topic setting
- Use the correct API for open channels
- Allow changing the name (title) of normal channels
2022-05-16 03:01:12 -04:00
Andrew Ferrazzutti bccd0ed4e0 Allow Node command handlers to throw responses and non-Error objects 2022-05-16 00:11:53 -04:00
Andrew Ferrazzutti bb9cdbd15e Log primitive response values 2022-05-16 00:10:08 -04:00
Andrew Ferrazzutti 1897c1e494 Try to catch unexpected TalkClient disconnects 2022-05-16 00:08:00 -04:00
Andrew Ferrazzutti 2cd7697aa5 Object -> object in JS type hints
And other minor JS comment fixes
2022-05-15 22:21:49 -04:00
Andrew Ferrazzutti 27b2c15ad3 Changes to RPC object logging and Node config
- Move config from Python to Node
- Also log responses
- Only log request/response object properties in Node, as logging them
in both Node and Python is redundant
- Error-out if Node listen config is missing
- For convenience, make PeerClient copy setting properties from
ClientManager instead of referencing them
2022-05-15 22:17:28 -04:00
Andrew Ferrazzutti 454d1b72cc Don't document that outbound portal avatars are supported
Also comment-out its unused code
2022-05-12 03:50:58 -04:00
Andrew Ferrazzutti 79fe8748b1 Minor style changes 2022-05-10 21:43:26 -04:00
Andrew Ferrazzutti d7d8cbbbb6 Use proper equality operator for Long 2022-05-10 21:43:26 -04:00
Andrew Ferrazzutti d4d02e8aba Add missing null check for inbound read receipts 2022-05-10 21:43:26 -04:00
Andrew Ferrazzutti fbd3d514e3 Reformat output of "whoami" command
Also print user pill for easily creating a MemoChat
2022-05-10 21:43:26 -04:00
Andrew Ferrazzutti 53d3170c04 Fixes to leaves and DM creation
Fixes -203 error for MemoChats, and automatically retries channel
creation when it fails the first time
2022-05-10 21:43:26 -04:00
Andrew Ferrazzutti 45fdd5ca29 Allow creating a MemoChat from Matrix by inviting own puppet to DM
WARNING: Seems to always fail with -203 (Invalid body)
2022-05-10 21:42:06 -04:00
Andrew Ferrazzutti 36598c34f6 Set default register timeout
Otherwise, bad things can happen if it's missing from config.json
2022-05-10 01:28:26 -04:00
Andrew Ferrazzutti 3f8660a3c4 Print "unknown peer" instead of null, if no RPC peer is known 2022-05-10 01:17:05 -04:00
Andrew Ferrazzutti 652aa22048 Update some comments
- TalkAPIClient#close is the "logout" command that was wanted
- Direct Chats do share the same ID for every member
2022-05-10 01:17:05 -04:00
Andrew Ferrazzutti c691372c6a Don't delete saved creds if a login is blocked & forced login is off 2022-05-10 01:17:05 -04:00
Andrew Ferrazzutti 76d0ead99f Make disconnections an important notice 2022-05-10 01:17:05 -04:00
Andrew Ferrazzutti 8da7f1efbd Wait for RPC connection before sending notice to log back in 2022-05-10 01:17:05 -04:00
Andrew Ferrazzutti 20bdbf9cd1 Add config for logging arguments of RPC commands 2022-05-10 01:17:05 -04:00
Andrew Ferrazzutti 3c0d890577 Add Node config setting for RPC registration timeout 2022-05-10 01:17:05 -04:00
Andrew Ferrazzutti 54c772d3ac "login" command: Don't delete saved password when using it 2022-05-09 03:31:14 -04:00
Andrew Ferrazzutti b8b451b751 Validate arguments passed to "login" command 2022-05-09 03:31:14 -04:00
Andrew Ferrazzutti d594fb98d1 Roadmap corrections 2022-05-09 03:31:14 -04:00
Andrew Ferrazzutti c90f86849e Move core of "leave" command into Portal method 2022-05-09 03:31:14 -04:00
Andrew Ferrazzutti 5b16694f78 Add some command aliases 2022-05-09 03:31:14 -04:00
Andrew Ferrazzutti 37101f42c1 Friend cmds: Use same mention formatting as outbound message formatter 2022-05-09 03:31:14 -04:00
Andrew Ferrazzutti a49e2768a3 Don't send outbound mentions for direct chats
because KT doesn't allow them, and errors when the bridge sends them
2022-05-09 03:31:14 -04:00
Andrew Ferrazzutti 128979e06b Add missing await 2022-05-09 03:31:14 -04:00
16 changed files with 430 additions and 160 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<sup>[1]</sup> * [ ] Room metadata changes
* [x] Name * [x] Name
* [x] Topic * [x] Topic
* [x] Avatar * [ ] 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>
* [ ] Channel metadata * [x] Channel metadata
* [x] Name * [x] Name
* [x] Description * [x] Description
* [x] Cover photo<sup>[5]</sup> * [x] Cover photo<sup>[5]</sup>
@ -74,6 +74,13 @@
* [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
@ -82,8 +89,9 @@
* [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
* [ ] Manage favourite friends * [ ] Favourite friends
* [ ] Manage hidden friends * [ ] 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
@ -92,4 +100,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 only get synced on backfill, or on changing channel name/description <sup>[5]</sup> Might not get synced on backfill or initial portal creation. Might get synced on live update to channel name/description

View File

@ -69,10 +69,30 @@ async def login(evt: CommandEvent) -> None:
await evt.reply("You're already logged in") await evt.reply("You're already logged in")
return return
save = len(evt.args) > 0 and evt.args[0] == "--save" num_args = len(evt.args)
email = evt.args[0 if not save else 1] if len(evt.args) > 0 else None save = num_args > 0 and evt.args[0] == "--save"
# 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,
@ -81,20 +101,6 @@ 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:
@ -277,7 +283,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)
@ -337,7 +343,8 @@ 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:
@ -350,7 +357,8 @@ 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 = evt.sender.is_connected and await evt.sender.client.is_connected() is_connected = await evt.sender.is_connected_now()
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,6 +76,7 @@ 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 SerializerError from mautrix.types import Format, 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,9 +65,13 @@ 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}** (KakaoTalk ID: {uuid}, internal ID: `{evt.sender.ktid}`)" f"You're logged in as **{own_info.more.nickName}**"
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(
@ -153,13 +157,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,
@ -237,10 +241,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
formatted_body = evt.content.get("formatted_body") if evt.content.get("format", None) == Format.HTML and evt.content["formatted_body"]:
if formatted_body: parsed = await MentionParser().parse(utf16_surrogate.add(
arg = formatted_body[len(evt.command):].strip() evt.content["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
@ -311,5 +315,7 @@ 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()
await evt.sender.client.leave_channel(evt.portal.channel_props) try:
await evt.sender.on_channel_left(evt.portal.ktid, evt.portal.kt_type) await evt.sender.leave_channel(evt.portal)
except CommandException as e:
await evt.reply(f"Error from KakaoTalk: {e}")

View File

@ -154,7 +154,11 @@ async def matrix_to_kakaotalk(
) )
else: else:
reply_to = None reply_to = None
if content.get("format", None) == Format.HTML and content["formatted_body"] and content.msgtype.is_text: if (
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,6 +109,10 @@ 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."""
@ -494,6 +498,7 @@ class Client:
description=description, description=description,
) )
""" TODO
def set_channel_photo( def set_channel_photo(
self, self,
channel_props: ChannelProps, channel_props: ChannelProps,
@ -504,6 +509,7 @@ 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(
@ -681,6 +687,9 @@ 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)
@ -698,6 +707,7 @@ 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 await user.is_logged_in() not user.is_connected
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 logged in to this KakaoTalk bridge." room_id, user.mxid, "You are not connected to this KakaoTalk bridge."
) )
return return

View File

@ -1219,9 +1219,6 @@ 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:
@ -1338,6 +1335,9 @@ 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,19 +1758,20 @@ 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:
removed_intent = removed.intent_for(self) if not removed.is_real_user and removed_user:
if removed_intent != self.main_intent: try:
await removed_intent.leave_room(self.mxid) await sender_intent.kick_user(self.mxid, removed_user.mxid, "Left channel from KakaoTalk")
if not removed.is_real_user: except MForbidden:
user = await u.User.get_by_ktid(removed.ktid) pass
if user: await removed.intent_for(self).leave_room(self.mxid)
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,
await u.User.get_by_ktid(removed.ktid) if not removed.is_real_user else None removed_user 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"
@ -1781,7 +1782,11 @@ 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}"
) )
# TODO Clean and delete if removed is real user and portal is direct / not open if self.is_direct and removed.ktid == self.kt_receiver:
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
@ -1866,7 +1871,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)
self._sync_read_receipts(source) await self._sync_read_receipts(source)
# region Database getters # region Database getters
@ -1877,7 +1882,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? Depends on if DM channels are shared... # TODO Save kt_sender in DB instead? Only do that if keeping a unique DM portal for each receiver
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:
@ -1916,7 +1921,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 Find out if direct channels are shared. If so, don't need kt_receiver! # TODO Direct chats are shared, so can remove kt_receiver if DM portals should be shared
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,6 +247,7 @@ 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,6 +177,9 @@ 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
@ -317,6 +320,8 @@ 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()
@ -388,6 +393,9 @@ 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,
@ -429,7 +437,6 @@ 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)
@ -681,7 +688,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 = Long(0) ktid = None
else: else:
ktid = memo_ids[0] ktid = memo_ids[0]
if len(memo_ids) > 1: if len(memo_ids) > 1:
@ -698,6 +705,13 @@ 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:
@ -746,7 +760,10 @@ 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(f"Disconnected from KakaoTalk: {reason_str} {reason_suffix}") await self.send_bridge_notice(
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(
@ -756,6 +773,22 @@ 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,3 +2,11 @@
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,5 +3,10 @@
"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,5 +3,10 @@
"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,6 +80,7 @@ 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 =
@ -97,6 +98,7 @@ 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 }
@ -155,10 +157,19 @@ class UserClient {
}) })
this.#talkClient.on("chat_read", (chat, channel, reader) => { this.#talkClient.on("chat_read", (chat, channel, reader) => {
this.log(`Chat ${chat.logId} read in channel ${channel.channelId} by ${reader.userId}`) let senderId
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: reader.userId, senderId: senderId,
channelId: channel.channelId, channelId: channel.channelId,
channelType: channel.info.type, channelType: channel.info.type,
}) })
@ -298,7 +309,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", {
@ -313,7 +324,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,
@ -355,20 +366,39 @@ class UserClient {
* @param {ChannelProps} channelProps * @param {ChannelProps} channelProps
*/ */
async getChannel(channelProps) { async getChannel(channelProps) {
let channel = this.#talkClient.channelList.get(channelProps.id) const talkChannel = this.#talkClient.channelList.get(channelProps.id)
if (channel) { if (talkChannel) {
return channel return talkChannel
} else {
const channelList = getChannelListForType(
this.#talkClient.channelList,
channelProps.type
)
const res = await channelList.addChannel({ channelId: channelProps.id })
if (!res.success) {
throw new Error(`Unable to add ${channelProps.type} channel ${channelProps.id}`)
}
return res.result
} }
const channelList = getChannelListForType(
this.#talkClient.channelList,
channelProps.type
)
const res = await channelList.addChannel({ channelId: channelProps.id })
if (!res.success) {
this.error(`Unable to add ${channelProps.type} channel ${channelProps.id}`)
throw res
}
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
} }
/** /**
@ -380,19 +410,26 @@ class UserClient {
if (credential && this.#credential != credential) { if (credential && this.#credential != credential) {
await this.setCredential(credential) await this.setCredential(credential)
} }
return await this.#talkClient.login(this.#credential) const res = await this.#talkClient.login(this.#credential)
this.#connected = res.success
return res
} }
disconnect() { disconnect() {
if (this.#talkClient.logon) { if (this.isConnected()) {
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.
* *
@ -416,15 +453,17 @@ 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 = null this.peerID = ""
this.deviceName = "KakaoTalk Bridge" this.deviceName = "KakaoTalk Bridge"
/** @type {Map<string, UserClient>} */ /** @type {Map<string, UserClient>} */
this.userClients = new Map() this.userClients = new Map()
} }
@ -455,10 +494,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 3 seconds, terminating") this.log(`Didn't receive register request within ${this.registerTimeout/1000} seconds, terminating`)
this.stop("Register request timeout") this.stop("Register request timeout")
} }
}, 3000) }, this.registerTimeout)
} }
async stop(error = null) { async stop(error = null) {
@ -482,11 +521,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})`) this.log(`Connection closed (peer: ${this.peerID || "unknown peer"})`)
} }
#closeUsers() { #closeUsers() {
this.log("Closing all API clients for", this.peerID) this.log(`Closing all API clients for ${this.peerID || "unknown peer"}`)
for (const userClient of this.userClients.values()) { for (const userClient of this.userClients.values()) {
userClient.disconnect() userClient.disconnect()
} }
@ -504,10 +543,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
@ -517,16 +556,15 @@ 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) {
@ -574,23 +612,41 @@ 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) {
const permActual = talkChannel.getUserInfo({ userId: userClient.userId }).perm await this.#requireChannelPerm(talkChannel, permNeeded, action)
if (permNeeded.indexOf(permActual) == -1) {
throw new PermError(permNeeded, permActual, action)
}
} }
return talkChannel return talkChannel
} }
/** /**
* @param {Object} req * @param {TalkOpenChannel} talkChannel
* @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
*/ */
@ -605,7 +661,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
*/ */
@ -624,7 +680,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) => {
@ -633,7 +689,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
*/ */
@ -642,7 +698,7 @@ export default class PeerClient {
} }
/** /**
* @param {Object} req * @param {object} req
* @param {string} req.mxid * @param {string} req.mxid
*/ */
handleDisconnect = (req) => { handleDisconnect = (req) => {
@ -650,7 +706,7 @@ export default class PeerClient {
} }
/** /**
* @param {Object} req * @param {object} req
* @param {string} req.mxid * @param {string} req.mxid
*/ */
isConnected = (req) => { isConnected = (req) => {
@ -658,7 +714,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) => {
@ -682,7 +738,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) => {
@ -690,7 +746,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
*/ */
@ -699,7 +755,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
*/ */
@ -723,7 +779,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
*/ */
@ -757,7 +813,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
*/ */
@ -767,7 +823,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
@ -794,7 +850,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
@ -824,7 +880,7 @@ export default class PeerClient {
} }
/** /**
* @typedef {Object} Receipt * @typedef {object} Receipt
* @property {Long} userId * @property {Long} userId
* @property {Long} chatId * @property {Long} chatId
*/ */
@ -835,7 +891,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
*/ */
@ -844,7 +900,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
*/ */
@ -858,7 +914,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
*/ */
@ -880,7 +936,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) => {
@ -888,7 +944,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
@ -902,7 +958,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
@ -939,7 +995,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.
@ -952,7 +1008,7 @@ export default class PeerClient {
} }
/** /**
* @param {Object} req * @param {object} req
* @param {string} req.mxid * @param {string} req.mxid
*/ */
getMemoIds = (req) => { getMemoIds = (req) => {
@ -969,7 +1025,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
@ -984,7 +1040,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
@ -1003,7 +1059,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
@ -1027,7 +1083,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
@ -1041,7 +1097,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
@ -1055,7 +1111,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
@ -1072,50 +1128,69 @@ 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 == talkChannel.clientUser.userId) { if (req.user_id.equals(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) => {
const talkChannel = await this.#getUserChannel( if (!isChannelTypeOpen(req.channel_props.type)) {
req.mxid, const talkChannel = await this.#getUserNormalChannel(req.mxid, req.channel_props.id)
req.channel_props, return await talkChannel.setTitleMeta(req.name)
[OpenChannelUserPerm.OWNER], } else {
"change channel name" return await this.#setOpenChannelProperty(req.mxid, req.channel_props, "linkName", req.name)
) }
return await talkChannel.setTitleMeta(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) => {
const talkChannel = await this.#getUserChannel( return await this.#setOpenChannelProperty(req.mxid, req.channel_props, "description", req.description)
req.mxid,
req.channel_props,
[OpenChannelUserPerm.OWNER],
"change channel description"
)
return await talkChannel.setNoticeMeta(req.description)
} }
/** /**
* @param {Object} req * @param {string} mxid
* @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,
@ -1128,23 +1203,59 @@ 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 channelList = this.#getUser(req.mxid).talkClient.channelList.normal const userClient = this.#getUser(req.mxid)
const res = await channelList.createChannel({ await this.#requireInFriendsList(userClient.serviceClient, req.user_id)
userList: [{ userId: req.user_id }],
}) const channelList = userClient.talkClient.channelList.normal
if (!res.success) return res const createChannel =
return makeCommandResult(res.result.channelId) !req.user_id.equals(userClient.userId)
? channelList.createChannel.bind(channelList, {
userList: [{ userId: req.user_id }],
})
: channelList.createMemoChannel.bind(channelList)
const retry_delay = 2000
let retries_left = 1
let res
do {
res = await createChannel()
if (res.success) {
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 {Object} req * @param {ServiceApiClient} serviceClient
* @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
*/ */
@ -1162,9 +1273,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) => {
@ -1200,7 +1311,7 @@ export default class PeerClient {
this.log("Ignoring old request", req.id) this.log("Ignoring old request", req.id)
return return
} }
this.log("Received request", req.id, "with command", req.command) this.log(`Request ${req.id}:`, this.#logObj(req, req.command, this.loggingKeys.request))
this.maxCommandID = req.id this.maxCommandID = req.id
let handler let handler
if (!this.peerID) { if (!this.peerID) {
@ -1250,7 +1361,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
@ -1267,21 +1378,59 @@ 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
} else { errorDetails = err.message
} else if (err instanceof Error) {
resp.error = err.toString() resp.error = err.toString()
this.log(`Error handling request ${resp.id} ${err.stack}`) errorDetails = err.stack
} else {
resp.error = JSON.stringify(err)
errorDetails = `throwed ${resp.error}`
} }
this.error(`Response ${resp.id}: ${errorDetails}`)
} }
} }
// TODO Check if session is broken. If it is, close the PeerClient 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
)
)
}
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 {
@ -1289,7 +1438,7 @@ export default class PeerClient {
} }
} }
#readReviver = function(key, value) { #readReviver(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,10 +20,33 @@ 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()
@ -42,7 +65,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).start() new PeerClient(this, sock, connID, this.loggingKeys).start()
} }
} }

View File

@ -32,7 +32,11 @@ 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(config.listen) const manager = new ClientManager(
config.listen,
config.register_timeout,
config.logging_keys
)
function stop() { function stop() {
manager.stop().then(() => { manager.stop().then(() => {