Compare commits

...

11 Commits

Author SHA1 Message Date
Andrew Ferrazzutti 8228293202 Outbound joins, and manage OpenLink URLs 2022-05-16 03:10:33 -04:00
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
21 changed files with 504 additions and 132 deletions

View File

@ -20,13 +20,13 @@
* [x] Power level<sup>[1]</sup> * [x] Power level<sup>[1]</sup>
* [ ] Membership actions * [ ] Membership actions
* [ ] Invite * [ ] Invite
* [ ] Join * [x] Join
* [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
* [x] Name<sup>[1]</sup> * [x] Name
* [x] Topic<sup>[1]</sup> * [x] Topic
* [ ] Avatar * [ ] Avatar
* [ ] Per-room user nick * [ ] Per-room user nick
* KakaoTalk → Matrix * KakaoTalk → Matrix
@ -74,6 +74,16 @@
* [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] Display Open Chat public URL
* [x] Join Open Chat via public URL
* [ ] Join passcode-protected Open Chat
* [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 +92,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

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."

View File

@ -301,11 +301,46 @@ async def _on_friend_edited(evt: CommandEvent, friend_struct: FriendStruct | Non
await puppet.update_info_from_friend(evt.sender, friend_struct) await puppet.update_info_from_friend(evt.sender, friend_struct)
@command_handler(
management_only=False,
help_section=SECTION_CHANNELS,
help_text="If the current KakaoTalk channel is an Open Chat, display its URL",
)
async def get_url(evt: CommandEvent) -> None:
if not evt.is_portal:
await evt.reply("This command may only be used in a KakaoTalk channel portal room")
return
await evt.reply(
evt.portal.full_link_url or "This channel has no URL."
if evt.portal.is_open else "This channel is not an Open Chat."
)
@command_handler(
needs_auth=True,
management_only=True,
help_section=SECTION_CHANNELS,
help_text="Join a KakaoTalk Open Chat",
help_args="<_URL_>",
)
async def join(evt: CommandEvent) -> None:
if len(evt.args) != 1:
await evt.reply(f"**Usage:** `$cmdprefix+sp {evt.command} <URL>`")
return
if not evt.sender.is_connected:
await evt.reply("You are not connected to KakaoTalk chats")
return
await evt.mark_read()
try:
await evt.sender.join_channel(evt.args[0])
except CommandException as e:
await evt.reply(f"Error from KakaoTalk: {e}")
@command_handler( @command_handler(
needs_auth=True, needs_auth=True,
management_only=False, management_only=False,
help_section=SECTION_CHANNELS, help_section=SECTION_CHANNELS,
help_text="Leave this KakaoTalk channel", help_text="Leave the current KakaoTalk channel",
) )
async def leave(evt: CommandEvent) -> None: async def leave(evt: CommandEvent) -> None:
if not evt.sender.is_connected: if not evt.sender.is_connected:

View File

@ -117,7 +117,6 @@ class Config(BaseBridgeConfig):
else: else:
copy("rpc.connection.host") copy("rpc.connection.host")
copy("rpc.connection.port") copy("rpc.connection.port")
copy("rpc.logging_keys")
def _get_permissions(self, key: str) -> tuple[bool, bool, bool, str]: def _get_permissions(self, key: str) -> tuple[bool, bool, bool, str]:
level = self["bridge.permissions"].get(key, "") level = self["bridge.permissions"].get(key, "")

View File

@ -45,6 +45,8 @@ class Portal:
name_set: bool name_set: bool
topic_set: bool topic_set: bool
avatar_set: bool avatar_set: bool
link_id: Long | None = field(converter=to_optional_long)
link_url: str | None
fully_read_kt_chat: Long | None = field(converter=to_optional_long) fully_read_kt_chat: Long | None = field(converter=to_optional_long)
relay_user_id: UserID | None relay_user_id: UserID | None
@ -58,7 +60,7 @@ class Portal:
_columns = ( _columns = (
"ktid, kt_receiver, kt_type, mxid, name, description, photo_id, avatar_url, encrypted, " "ktid, kt_receiver, kt_type, mxid, name, description, photo_id, avatar_url, encrypted, "
"name_set, avatar_set, fully_read_kt_chat, relay_user_id" "name_set, avatar_set, link_id, link_url, fully_read_kt_chat, relay_user_id"
) )
@classmethod @classmethod
@ -99,6 +101,8 @@ class Portal:
self.encrypted, self.encrypted,
self.name_set, self.name_set,
self.avatar_set, self.avatar_set,
self.link_id,
self.link_url,
self.fully_read_kt_chat, self.fully_read_kt_chat,
self.relay_user_id, self.relay_user_id,
) )

View File

@ -21,3 +21,4 @@ from . import v01_initial_revision
from . import v02_channel_meta from . import v02_channel_meta
from . import v03_user_connection from . import v03_user_connection
from . import v04_read_receipt_sync from . import v04_read_receipt_sync
from . import v05_open_link

View File

@ -0,0 +1,24 @@
# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge.
# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from mautrix.util.async_db import Connection, Scheme
from . import upgrade_table
@upgrade_table.register(description="Track OpenChannel public link IDs and URLs")
async def upgrade_v5(conn: Connection) -> None:
await conn.execute("ALTER TABLE portal ADD COLUMN link_id BIGINT")
await conn.execute("ALTER TABLE portal ADD COLUMN link_url TEXT")

View File

@ -252,11 +252,6 @@ rpc:
# Only for type: tcp # Only for type: tcp
host: localhost host: localhost
port: 29392 port: 29392
# Command arguments to print in logs. Optional.
# TODO Support nested arguments, like channel_props.ktid
logging_keys:
- mxid
#- channel_props
# Python logging configuration. # Python logging configuration.
# #

View File

@ -19,7 +19,6 @@ Currently a wrapper around a Node backend, but
the abstraction used here should be compatible the abstraction used here should be compatible
with any other potential backend. with any other potential backend.
""" """
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, cast, Awaitable, Type, Optional, Union from typing import TYPE_CHECKING, cast, Awaitable, Type, Optional, Union
@ -518,6 +517,26 @@ class Client:
user_id=ktid.serialize(), user_id=ktid.serialize(),
) )
def join_channel_by_url(self, url: str) -> Awaitable[Long]:
return self._api_user_request_result(
Long,
"join_channel_by_url",
url=url,
)
async def join_channel(
self,
channel_id: Long,
link_id: Long,
) -> None:
joined_id = await self._api_user_request_result(
Long,
"join_channel",
channel_id=channel_id.serialize(),
link_id=link_id.serialize(),
)
assert channel_id == joined_id, f"Mismatch of joined channel ID: expected {channel_id}, got {joined_id}"
def leave_channel( def leave_channel(
self, self,
channel_props: ChannelProps, channel_props: ChannelProps,
@ -687,6 +706,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)
@ -704,6 +726,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

@ -81,6 +81,8 @@ class PortalChannelInfo(SerializableAttrs):
name: str name: str
description: Optional[str] = None description: Optional[str] = None
photoURL: Optional[str] = None photoURL: Optional[str] = None
linkId: Optional[Long] = None
linkURL: Optional[str] = None
participantInfo: Optional[PortalChannelParticipantInfo] = None # May set to None to skip participant update participantInfo: Optional[PortalChannelParticipantInfo] = None # May set to None to skip participant update
channel_info: Optional[ChannelInfoUnion] = None # Should be set manually by caller channel_info: Optional[ChannelInfoUnion] = None # Should be set manually by caller
@ -89,6 +91,7 @@ class PortalChannelInfo(SerializableAttrs):
class ChannelProps(SerializableAttrs): class ChannelProps(SerializableAttrs):
id: Long id: Long
type: ChannelType type: ChannelType
link_id: Optional[Long]
# TODO Add non-media types, like polls & maps # TODO Add non-media types, like polls & maps

View File

@ -28,7 +28,7 @@ class KnownChannelType(str, Enum):
@classmethod @classmethod
def is_direct(cls, value: Union["KnownChannelType", str]) -> bool: def is_direct(cls, value: Union["KnownChannelType", str]) -> bool:
return value in [cls.DirectChat, cls.MemoChat] return value in [cls.DirectChat, cls.MemoChat, cls.OD]
@classmethod @classmethod
def is_open(cls, value: Union["KnownChannelType", str]) -> bool: def is_open(cls, value: Union["KnownChannelType", str]) -> bool:

View File

@ -96,17 +96,16 @@ 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
self.log.debug(f"{user.mxid} joined {room_id}") await portal.handle_matrix_join(user)
# await portal.join_matrix(user, event_id)
async def handle_leave(self, room_id: RoomID, user_id: UserID, event_id: EventID) -> None: async def handle_leave(self, room_id: RoomID, user_id: UserID, event_id: EventID) -> None:
portal = await po.Portal.get_by_mxid(room_id) portal = await po.Portal.get_by_mxid(room_id)

View File

@ -166,6 +166,8 @@ class Portal(DBPortal, BasePortal):
_CHAT_TYPE_HANDLER_MAP: dict[ChatType, Callable[..., ACallable[list[EventID]]]] _CHAT_TYPE_HANDLER_MAP: dict[ChatType, Callable[..., ACallable[list[EventID]]]]
_STATE_EVENT_HANDLER_MAP: dict[EventType, StateEventHandler] _STATE_EVENT_HANDLER_MAP: dict[EventType, StateEventHandler]
OPEN_LINK_URL_PREFIX = "https://open.kakao.com/o/"
def __init__( def __init__(
self, self,
ktid: Long, ktid: Long,
@ -180,6 +182,8 @@ class Portal(DBPortal, BasePortal):
name_set: bool = False, name_set: bool = False,
topic_set: bool = False, topic_set: bool = False,
avatar_set: bool = False, avatar_set: bool = False,
link_id: Long | None = None,
link_url: str | None = None,
fully_read_kt_chat: Long | None = None, fully_read_kt_chat: Long | None = None,
relay_user_id: UserID | None = None, relay_user_id: UserID | None = None,
) -> None: ) -> None:
@ -196,6 +200,8 @@ class Portal(DBPortal, BasePortal):
name_set, name_set,
topic_set, topic_set,
avatar_set, avatar_set,
link_id,
link_url,
fully_read_kt_chat, fully_read_kt_chat,
relay_user_id, relay_user_id,
) )
@ -315,9 +321,14 @@ class Portal(DBPortal, BasePortal):
def channel_props(self) -> ChannelProps: def channel_props(self) -> ChannelProps:
return ChannelProps( return ChannelProps(
id=self.ktid, id=self.ktid,
type=self.kt_type type=self.kt_type,
link_id=self.link_id,
) )
@property
def full_link_url(self) -> str:
return self.OPEN_LINK_URL_PREFIX + self.link_url if self.link_url else ""
@property @property
def main_intent(self) -> IntentAPI: def main_intent(self) -> IntentAPI:
if not self._main_intent: if not self._main_intent:
@ -378,6 +389,7 @@ class Portal(DBPortal, BasePortal):
self._update_name(info.name), self._update_name(info.name),
self._update_description(info.description), self._update_description(info.description),
self._update_photo(source, info.photoURL), self._update_photo(source, info.photoURL),
self._update_open_link(info.linkId, info.linkURL),
) )
) )
if info.participantInfo: if info.participantInfo:
@ -550,6 +562,24 @@ class Portal(DBPortal, BasePortal):
return True return True
return False return False
async def _update_open_link(self, link_id: Long | None, link_url: str | None) -> bool:
changed = False
if self.link_id != link_id:
self.log.trace(f"Updating OpenLink ID {self.link_id} -> {link_id}")
self.link_id = link_id
changed = True
if self.link_url != link_url:
if link_url:
if not link_url.startswith(self.OPEN_LINK_URL_PREFIX):
self.log.error(f"Unexpected prefix for OpenLink URL {link_url}")
link_url = None
else:
link_url = link_url.removeprefix(self.OPEN_LINK_URL_PREFIX)
self.log.trace(f"Updating OpenLink URL {self.link_url} -> {link_url}")
self.link_url = link_url
changed = True
return changed
async def _update_photo(self, source: u.User, photo_id: str | None) -> bool: async def _update_photo(self, source: u.User, photo_id: str | None) -> bool:
if self.is_direct and not self.encrypted: if self.is_direct and not self.encrypted:
return False return False
@ -1219,9 +1249,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 +1365,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"
@ -1375,6 +1405,13 @@ class Portal(DBPortal, BasePortal):
async def _revert_matrix_room_avatar(self, prev_content: RoomAvatarStateEventContent) -> None: async def _revert_matrix_room_avatar(self, prev_content: RoomAvatarStateEventContent) -> None:
await self.main_intent.set_room_avatar(self.mxid, prev_content.url) await self.main_intent.set_room_avatar(self.mxid, prev_content.url)
async def handle_matrix_join(self, user: u.User) -> None:
if self.link_id:
try:
await user.client.join_channel(self.ktid, self.link_id)
except Exception as e:
await self.main_intent.kick_user(self.mxid, user.mxid, str(e))
async def handle_matrix_leave(self, user: u.User) -> None: async def handle_matrix_leave(self, user: u.User) -> None:
if self.is_direct: if self.is_direct:
self.log.info(f"{user.mxid} left private chat portal with {self.ktid}") self.log.info(f"{user.mxid} left private chat portal with {self.ktid}")

View File

@ -86,7 +86,6 @@ class RPCClient:
_is_connected: CancelableEvent _is_connected: CancelableEvent
_is_disconnected: CancelableEvent _is_disconnected: CancelableEvent
_connection_lock: asyncio.Lock _connection_lock: asyncio.Lock
_logging_keys: list[str]
def __init__(self, config: Config, register_config_key: str) -> None: def __init__(self, config: Config, register_config_key: str) -> None:
self.config = config self.config = config
@ -106,7 +105,6 @@ class RPCClient:
self._is_disconnected = CancelableEvent(self.loop) self._is_disconnected = CancelableEvent(self.loop)
self._is_disconnected.set() self._is_disconnected.set()
self._connection_lock = asyncio.Lock() self._connection_lock = asyncio.Lock()
self._logging_keys = config["rpc.logging_keys"]
async def connect(self) -> None: async def connect(self) -> None:
async with self._connection_lock: async with self._connection_lock:
@ -149,8 +147,7 @@ class RPCClient:
self._read_task = self.loop.create_task(self._try_read_loop()) self._read_task = self.loop.create_task(self._try_read_loop())
await self._raw_request("register", await self._raw_request("register",
peer_id=self.config["appservice.address"], peer_id=self.config["appservice.address"],
register_config=self.config[self.register_config_key], register_config=self.config[self.register_config_key])
logging_keys=self._logging_keys)
self._is_connected.set() self._is_connected.set()
self._is_disconnected.clear() self._is_disconnected.clear()
@ -250,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)))
@ -305,10 +303,7 @@ class RPCClient:
req_id = self._next_req_id req_id = self._next_req_id
future = self._response_waiters[req_id] = self.loop.create_future() future = self._response_waiters[req_id] = self.loop.create_future()
req = {"id": req_id, "command": command, **data} req = {"id": req_id, "command": command, **data}
self.log.debug("Request %d: %s", req_id, self.log.debug("Request %d: %s", req_id, command)
', '.join(
[command] +
[f"{k}: {data[k]}" for k in self._logging_keys if k in data]))
assert self._writer is not None assert self._writer is not None
self._writer.write(json.dumps(req).encode("utf-8")) self._writer.write(json.dumps(req).encode("utf-8"))
self._writer.write(b"\n") self._writer.write(b"\n")

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
@ -704,6 +707,10 @@ class User(DBUser, BaseUser):
# region Matrix->KakaoTalk commands # region Matrix->KakaoTalk commands
async def join_channel(self, url: str) -> None:
await self.client.join_channel_by_url(url)
# TODO Get channel ID(s) and sync
async def leave_channel(self, portal: po.Portal) -> None: async def leave_channel(self, portal: po.Portal) -> None:
await self.client.leave_channel(portal.channel_props) await self.client.leave_channel(portal.channel_props)
await self.on_channel_left(portal.ktid, portal.kt_type) await self.on_channel_left(portal.ktid, portal.kt_type)
@ -770,6 +777,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

@ -3,5 +3,10 @@ If `type` is `unix`, `path` is the path where to create the socket, and `force`
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 ### 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. `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

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

View File

@ -30,6 +30,7 @@ import { ReadStreamUtil } from "node-kakao/stream"
/** @typedef {import("node-kakao").MentionStruct} MentionStruct */ /** @typedef {import("node-kakao").MentionStruct} MentionStruct */
/** @typedef {import("node-kakao").TalkNormalChannel} TalkNormalChannel */ /** @typedef {import("node-kakao").TalkNormalChannel} TalkNormalChannel */
/** @typedef {import("node-kakao").TalkOpenChannel} TalkOpenChannel */ /** @typedef {import("node-kakao").TalkOpenChannel} TalkOpenChannel */
/** @typedef {import("node-kakao").OpenLink} OpenLink */
/** @typedef {import("node-kakao/dist/talk").TalkChannelList} TalkChannelList */ /** @typedef {import("node-kakao/dist/talk").TalkChannelList} TalkChannelList */
// TODO Remove once/if some helper type hints are upstreamed // TODO Remove once/if some helper type hints are upstreamed
/** @typedef {import("node-kakao").OpenChannelUserInfo} OpenChannelUserInfo */ /** @typedef {import("node-kakao").OpenChannelUserInfo} OpenChannelUserInfo */
@ -43,9 +44,10 @@ 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
* @property {?Long} link_id
*/ */
@ -80,6 +82,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 +100,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 }
@ -295,8 +299,10 @@ class UserClient {
this.write("channel_meta_change", { this.write("channel_meta_change", {
info: { info: {
name: data.ol?.ln, name: data.ol?.ln,
description: data.ol?.desc || null, description: data.ol?.desc,
photoURL: data.ol?.liu || null, photoURL: data.ol?.liu || null,
linkId: data.ol?.linkId,
linkURL: data.ol?.linkURL,
}, },
channelId: channel.channelId, channelId: channel.channelId,
channelType: channel.info.type, channelType: channel.info.type,
@ -364,20 +370,43 @@ 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
)
let res = await channelList.addChannel({ channelId: channelProps.id })
if (!res.success && channelProps.link_id) {
res = await this.#talkClient.channelList.open.joinChannel({ linkId: channelProps.link_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
} }
/** /**
@ -389,19 +418,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.
* *
@ -425,6 +461,9 @@ 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
@ -433,8 +472,6 @@ export default class PeerClient {
this.maxCommandID = 0 this.maxCommandID = 0
this.peerID = "" this.peerID = ""
this.deviceName = "KakaoTalk Bridge" this.deviceName = "KakaoTalk Bridge"
/** @type {[string]} */
this.loggingKeys = []
/** @type {Map<string, UserClient>} */ /** @type {Map<string, UserClient>} */
this.userClients = new Map() this.userClients = new Map()
} }
@ -465,10 +502,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.manager.registerTimeout/1000} 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")
} }
}, this.manager.registerTimeout) }, this.registerTimeout)
} }
async stop(error = null) { async stop(error = null) {
@ -514,10 +551,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
@ -527,9 +564,9 @@ 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
@ -583,23 +620,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
*/ */
@ -614,7 +669,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
*/ */
@ -633,7 +688,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) => {
@ -642,7 +697,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
*/ */
@ -651,7 +706,7 @@ export default class PeerClient {
} }
/** /**
* @param {Object} req * @param {object} req
* @param {string} req.mxid * @param {string} req.mxid
*/ */
handleDisconnect = (req) => { handleDisconnect = (req) => {
@ -659,7 +714,7 @@ export default class PeerClient {
} }
/** /**
* @param {Object} req * @param {object} req
* @param {string} req.mxid * @param {string} req.mxid
*/ */
isConnected = (req) => { isConnected = (req) => {
@ -667,7 +722,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) => {
@ -691,7 +746,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) => {
@ -699,7 +754,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
*/ */
@ -708,7 +763,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
*/ */
@ -718,11 +773,16 @@ export default class PeerClient {
const res = await talkChannel.updateAll() const res = await talkChannel.updateAll()
if (!res.success) return res if (!res.success) return res
/** @type {?OpenLink} */
const openLink = talkChannel.info.openLink
return makeCommandResult({ return makeCommandResult({
name: talkChannel.getDisplayName(), name: talkChannel.getDisplayName(),
description: talkChannel.info.openLink?.description, description: openLink?.description,
// TODO Find out why linkCoverURL is blank, despite having updated the channel! // TODO Find out why linkCoverURL is blank, despite having updated the channel!
photoURL: talkChannel.info.openLink?.linkCoverURL || null, photoURL: openLink?.linkCoverURL || null,
linkId: openLink?.linkId,
linkURL: openLink?.linkURL,
participantInfo: { participantInfo: {
// TODO Get members from chatON? // TODO Get members from chatON?
participants: Array.from(talkChannel.getAllUserInfo()), participants: Array.from(talkChannel.getAllUserInfo()),
@ -732,7 +792,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
*/ */
@ -766,7 +826,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
*/ */
@ -776,7 +836,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
@ -803,7 +863,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
@ -833,7 +893,7 @@ export default class PeerClient {
} }
/** /**
* @typedef {Object} Receipt * @typedef {object} Receipt
* @property {Long} userId * @property {Long} userId
* @property {Long} chatId * @property {Long} chatId
*/ */
@ -844,7 +904,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
*/ */
@ -853,7 +913,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
*/ */
@ -867,7 +927,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
*/ */
@ -889,7 +949,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) => {
@ -897,7 +957,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
@ -911,7 +971,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
@ -948,7 +1008,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.
@ -961,7 +1021,7 @@ export default class PeerClient {
} }
/** /**
* @param {Object} req * @param {object} req
* @param {string} req.mxid * @param {string} req.mxid
*/ */
getMemoIds = (req) => { getMemoIds = (req) => {
@ -978,7 +1038,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
@ -993,7 +1053,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
@ -1012,7 +1072,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
@ -1036,7 +1096,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
@ -1050,7 +1110,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
@ -1064,7 +1124,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
@ -1088,40 +1148,59 @@ 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.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" * @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
) )
return await talkChannel.setNoticeMeta(req.description)
} }
/* /*
* TODO * TODO
* @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.photo_url * @param {string} req.photo_url
@ -1140,14 +1219,15 @@ 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
*/ */
createDirectChat = async (req) => { createDirectChat = async (req) => {
const userClient = this.#getUser(req.mxid) const userClient = this.#getUser(req.mxid)
const channelList = userClient.talkClient.channelList.normal await this.#requireInFriendsList(userClient.serviceClient, req.user_id)
const channelList = userClient.talkClient.channelList.normal
const createChannel = const createChannel =
!req.user_id.equals(userClient.userId) !req.user_id.equals(userClient.userId)
? channelList.createChannel.bind(channelList, { ? channelList.createChannel.bind(channelList, {
@ -1172,7 +1252,77 @@ export default class PeerClient {
} }
/** /**
* @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.url
*/
joinChannelByURL = async (req) => {
const channelList = this.#getUser(req.mxid).talkClient.channelList.open
const inviteRes = await channelList.getJoinInfo(req.url)
if (!inviteRes.success) throw inviteRes
const channelIds = channelList.getLinkChannelList(inviteRes.result.openLink.linkId)
if (channelIds.length == 0) {
throw new ProtocolError(`No channel found for OpenLink URL ${req.url}`)
}
if (channelIds.length > 1) {
this.log(`Multiple channels found for OpenLink URL ${req.url}: ${channelIds.join(", ")}`)
}
for (const channelId of channelList.getLinkChannelList(inviteRes.result.openLink.linkId)) {
if (channelList.get(channelId)) {
this.log(`Already joined channel ${channelId}`)
continue
}
const joinRes = await channelList.joinChannel(inviteRes.result.openLink, {})
if (!joinRes.success) {
this.error(`Failed to join channel ${channelId} via ${inviteRes.result.openLink.linkId}`)
} else {
this.log(`Joined channel ${channelId} via ${inviteRes.result.openLink.linkId}`)
}
}
// TODO Consider returning ID of each joined channel
}
/**
* @param {object} req
* @param {string} req.mxid
* @param {Long} req.channel_id
* @param {Long} req.link_id
*/
joinChannel = async (req) => {
const channelList = this.#getUser(req.mxid).talkClient.channelList.open
let talkChannel = channelList.get(req.channel_id)
if (talkChannel) {
this.log(`Already joined channel ${channelId}`)
} else {
const joinRes = await channelList.joinChannel({ linkId: req.link_id }, {})
if (!joinRes.success) return joinRes
this.log(`Joined channel ${channelId} via ${req.link_id}`)
talkChannel = joinRes.result
}
return makeCommandResult(talkChannel.channelId)
}
/**
* @param {object} req
* @param {string} req.mxid * @param {string} req.mxid
* @param {ChannelProps} req.channel_props * @param {ChannelProps} req.channel_props
*/ */
@ -1190,16 +1340,14 @@ 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
* @param {?[string]} req.logging_keys
*/ */
handleRegister = async (req) => { handleRegister = async (req) => {
this.peerID = req.peer_id this.peerID = req.peer_id
this.deviceName = req.register_config.device_name || this.deviceName this.deviceName = req.register_config.device_name || this.deviceName
this.loggingKeys = req.logging_keys || this.loggingKeys
this.log(`Registered socket ${this.connID} -> ${this.peerID}`) this.log(`Registered socket ${this.connID} -> ${this.peerID}`)
if (this.manager.clients.has(this.peerID)) { if (this.manager.clients.has(this.peerID)) {
const oldClient = this.manager.clients.get(this.peerID) const oldClient = this.manager.clients.get(this.peerID)
@ -1230,12 +1378,7 @@ export default class PeerClient {
this.log("Ignoring old request", req.id) this.log("Ignoring old request", req.id)
return return
} }
this.log( this.log(`Request ${req.id}:`, this.#logObj(req, req.command, this.loggingKeys.request))
`Request ${req.id}:`,
[req.command].concat(
this.loggingKeys.filter(k => k in req).map(k => `${k}: ${JSON.stringify(req[k], this.#writeReplacer)}`))
.join(', ')
)
this.maxCommandID = req.id this.maxCommandID = req.id
let handler let handler
if (!this.peerID) { if (!this.peerID) {
@ -1287,6 +1430,8 @@ export default class PeerClient {
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,
join_channel_by_url: this.joinChannelByURL,
join_channel: this.joinChannel,
leave_channel: this.leaveChannel, leave_channel: this.leaveChannel,
}[req.command] || this.handleUnknownCommand }[req.command] || this.handleUnknownCommand
} }
@ -1302,21 +1447,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 {
@ -1324,7 +1507,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,11 +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, registerTimeout) { /**
* @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.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()
@ -43,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, config.register_timeout) const manager = new ClientManager(
config.listen,
config.register_timeout,
config.logging_keys
)
function stop() { function stop() {
manager.stop().then(() => { manager.stop().then(() => {