Compare commits
11 Commits
454d1b72cc
...
8228293202
Author | SHA1 | Date |
---|---|---|
Andrew Ferrazzutti | 8228293202 | |
Andrew Ferrazzutti | 0dc75b8f1c | |
Andrew Ferrazzutti | 46ace01fea | |
Andrew Ferrazzutti | 82d64b9b37 | |
Andrew Ferrazzutti | 47b9623446 | |
Andrew Ferrazzutti | 1541732d0b | |
Andrew Ferrazzutti | bccd0ed4e0 | |
Andrew Ferrazzutti | bb9cdbd15e | |
Andrew Ferrazzutti | 1897c1e494 | |
Andrew Ferrazzutti | 2cd7697aa5 | |
Andrew Ferrazzutti | 27b2c15ad3 |
21
ROADMAP.md
21
ROADMAP.md
|
@ -20,13 +20,13 @@
|
|||
* [x] Power level<sup>[1]</sup>
|
||||
* [ ] Membership actions
|
||||
* [ ] Invite
|
||||
* [ ] Join
|
||||
* [x] Join
|
||||
* [x] Leave<sup>[3]</sup>
|
||||
* [ ] Ban<sup>[4]</sup>
|
||||
* [ ] Unban<sup>[4]</sup>
|
||||
* [ ] Room metadata changes
|
||||
* [x] Name<sup>[1]</sup>
|
||||
* [x] Topic<sup>[1]</sup>
|
||||
* [x] Name
|
||||
* [x] Topic
|
||||
* [ ] Avatar
|
||||
* [ ] Per-room user nick
|
||||
* KakaoTalk → Matrix
|
||||
|
@ -74,6 +74,16 @@
|
|||
* [x] When added to chat
|
||||
* [x] When receiving message
|
||||
* [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
|
||||
* [ ] KakaoTalk friends list management
|
||||
* [x] List friends
|
||||
|
@ -82,8 +92,9 @@
|
|||
* [x] By Matrix puppet of KakaoTalk user
|
||||
* [ ] By phone number
|
||||
* [x] Remove friend
|
||||
* [ ] Manage favourite friends
|
||||
* [ ] Manage hidden friends
|
||||
* [ ] Favourite friends
|
||||
* [ ] Hidden friends
|
||||
* [ ] Blocked users
|
||||
* [x] KakaoTalk ID management
|
||||
* [x] Set/Change ID
|
||||
* [x] Make ID searchable/hidden
|
||||
|
|
|
@ -63,7 +63,7 @@ async def ping(evt: CommandEvent) -> None:
|
|||
if not await evt.sender.is_logged_in():
|
||||
await evt.reply("You are **logged out** of KakaoTalk.")
|
||||
else:
|
||||
is_connected = evt.sender.is_connected and await evt.sender.client.is_connected()
|
||||
is_connected = await evt.sender.is_connected_now()
|
||||
await evt.reply(
|
||||
"You are logged into KakaoTalk.\n\n"
|
||||
f"You are {'connected to' if is_connected else '**disconnected** from'} KakaoTalk chats."
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
||||
@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(
|
||||
needs_auth=True,
|
||||
management_only=False,
|
||||
help_section=SECTION_CHANNELS,
|
||||
help_text="Leave this KakaoTalk channel",
|
||||
help_text="Leave the current KakaoTalk channel",
|
||||
)
|
||||
async def leave(evt: CommandEvent) -> None:
|
||||
if not evt.sender.is_connected:
|
||||
|
|
|
@ -117,7 +117,6 @@ class Config(BaseBridgeConfig):
|
|||
else:
|
||||
copy("rpc.connection.host")
|
||||
copy("rpc.connection.port")
|
||||
copy("rpc.logging_keys")
|
||||
|
||||
def _get_permissions(self, key: str) -> tuple[bool, bool, bool, str]:
|
||||
level = self["bridge.permissions"].get(key, "")
|
||||
|
|
|
@ -45,6 +45,8 @@ class Portal:
|
|||
name_set: bool
|
||||
topic_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)
|
||||
relay_user_id: UserID | None
|
||||
|
||||
|
@ -58,7 +60,7 @@ class Portal:
|
|||
|
||||
_columns = (
|
||||
"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
|
||||
|
@ -99,6 +101,8 @@ class Portal:
|
|||
self.encrypted,
|
||||
self.name_set,
|
||||
self.avatar_set,
|
||||
self.link_id,
|
||||
self.link_url,
|
||||
self.fully_read_kt_chat,
|
||||
self.relay_user_id,
|
||||
)
|
||||
|
|
|
@ -21,3 +21,4 @@ from . import v01_initial_revision
|
|||
from . import v02_channel_meta
|
||||
from . import v03_user_connection
|
||||
from . import v04_read_receipt_sync
|
||||
from . import v05_open_link
|
||||
|
|
|
@ -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")
|
|
@ -252,11 +252,6 @@ rpc:
|
|||
# Only for type: tcp
|
||||
host: localhost
|
||||
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.
|
||||
#
|
||||
|
|
|
@ -19,7 +19,6 @@ Currently a wrapper around a Node backend, but
|
|||
the abstraction used here should be compatible
|
||||
with any other potential backend.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, cast, Awaitable, Type, Optional, Union
|
||||
|
@ -518,6 +517,26 @@ class Client:
|
|||
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(
|
||||
self,
|
||||
channel_props: ChannelProps,
|
||||
|
@ -687,6 +706,9 @@ class Client:
|
|||
def _on_error(self, data: dict[str, JSON]) -> Awaitable[None]:
|
||||
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:
|
||||
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("switch_server", self._on_switch_server)
|
||||
self._add_event_handler("error", self._on_error)
|
||||
self._add_event_handler("unexpected_disconnect", self._on_unexpected_disconnect)
|
||||
|
||||
def _stop_listen(self) -> None:
|
||||
for method in self._handler_methods:
|
||||
|
|
|
@ -81,6 +81,8 @@ class PortalChannelInfo(SerializableAttrs):
|
|||
name: str
|
||||
description: 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
|
||||
channel_info: Optional[ChannelInfoUnion] = None # Should be set manually by caller
|
||||
|
||||
|
@ -89,6 +91,7 @@ class PortalChannelInfo(SerializableAttrs):
|
|||
class ChannelProps(SerializableAttrs):
|
||||
id: Long
|
||||
type: ChannelType
|
||||
link_id: Optional[Long]
|
||||
|
||||
|
||||
# TODO Add non-media types, like polls & maps
|
||||
|
|
|
@ -28,7 +28,7 @@ class KnownChannelType(str, Enum):
|
|||
|
||||
@classmethod
|
||||
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
|
||||
def is_open(cls, value: Union["KnownChannelType", str]) -> bool:
|
||||
|
|
|
@ -96,17 +96,16 @@ class MatrixHandler(BaseMatrixHandler):
|
|||
)
|
||||
return
|
||||
elif (
|
||||
not await user.is_logged_in()
|
||||
not user.is_connected
|
||||
and not portal.has_relay
|
||||
and not self.config["bridge.allow_invites"]
|
||||
):
|
||||
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
|
||||
|
||||
self.log.debug(f"{user.mxid} joined {room_id}")
|
||||
# await portal.join_matrix(user, event_id)
|
||||
await portal.handle_matrix_join(user)
|
||||
|
||||
async def handle_leave(self, room_id: RoomID, user_id: UserID, event_id: EventID) -> None:
|
||||
portal = await po.Portal.get_by_mxid(room_id)
|
||||
|
|
|
@ -166,6 +166,8 @@ class Portal(DBPortal, BasePortal):
|
|||
_CHAT_TYPE_HANDLER_MAP: dict[ChatType, Callable[..., ACallable[list[EventID]]]]
|
||||
_STATE_EVENT_HANDLER_MAP: dict[EventType, StateEventHandler]
|
||||
|
||||
OPEN_LINK_URL_PREFIX = "https://open.kakao.com/o/"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ktid: Long,
|
||||
|
@ -180,6 +182,8 @@ class Portal(DBPortal, BasePortal):
|
|||
name_set: bool = False,
|
||||
topic_set: bool = False,
|
||||
avatar_set: bool = False,
|
||||
link_id: Long | None = None,
|
||||
link_url: str | None = None,
|
||||
fully_read_kt_chat: Long | None = None,
|
||||
relay_user_id: UserID | None = None,
|
||||
) -> None:
|
||||
|
@ -196,6 +200,8 @@ class Portal(DBPortal, BasePortal):
|
|||
name_set,
|
||||
topic_set,
|
||||
avatar_set,
|
||||
link_id,
|
||||
link_url,
|
||||
fully_read_kt_chat,
|
||||
relay_user_id,
|
||||
)
|
||||
|
@ -315,9 +321,14 @@ class Portal(DBPortal, BasePortal):
|
|||
def channel_props(self) -> ChannelProps:
|
||||
return ChannelProps(
|
||||
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
|
||||
def main_intent(self) -> IntentAPI:
|
||||
if not self._main_intent:
|
||||
|
@ -378,6 +389,7 @@ class Portal(DBPortal, BasePortal):
|
|||
self._update_name(info.name),
|
||||
self._update_description(info.description),
|
||||
self._update_photo(source, info.photoURL),
|
||||
self._update_open_link(info.linkId, info.linkURL),
|
||||
)
|
||||
)
|
||||
if info.participantInfo:
|
||||
|
@ -550,6 +562,24 @@ class Portal(DBPortal, BasePortal):
|
|||
return True
|
||||
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:
|
||||
if self.is_direct and not self.encrypted:
|
||||
return False
|
||||
|
@ -1219,9 +1249,6 @@ class Portal(DBPortal, BasePortal):
|
|||
# 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}")
|
||||
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:
|
||||
effective_sender, _ = await self.get_relay_sender(sender, f"{handler.action_name} {evt.event_id}")
|
||||
if effective_sender:
|
||||
|
@ -1338,6 +1365,9 @@ class Portal(DBPortal, BasePortal):
|
|||
) -> None:
|
||||
if content.topic == prev_content.topic:
|
||||
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):
|
||||
raise Exception(
|
||||
"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:
|
||||
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:
|
||||
if self.is_direct:
|
||||
self.log.info(f"{user.mxid} left private chat portal with {self.ktid}")
|
||||
|
|
|
@ -86,7 +86,6 @@ class RPCClient:
|
|||
_is_connected: CancelableEvent
|
||||
_is_disconnected: CancelableEvent
|
||||
_connection_lock: asyncio.Lock
|
||||
_logging_keys: list[str]
|
||||
|
||||
def __init__(self, config: Config, register_config_key: str) -> None:
|
||||
self.config = config
|
||||
|
@ -106,7 +105,6 @@ class RPCClient:
|
|||
self._is_disconnected = CancelableEvent(self.loop)
|
||||
self._is_disconnected.set()
|
||||
self._connection_lock = asyncio.Lock()
|
||||
self._logging_keys = config["rpc.logging_keys"]
|
||||
|
||||
async def connect(self) -> None:
|
||||
async with self._connection_lock:
|
||||
|
@ -149,8 +147,7 @@ class RPCClient:
|
|||
self._read_task = self.loop.create_task(self._try_read_loop())
|
||||
await self._raw_request("register",
|
||||
peer_id=self.config["appservice.address"],
|
||||
register_config=self.config[self.register_config_key],
|
||||
logging_keys=self._logging_keys)
|
||||
register_config=self.config[self.register_config_key])
|
||||
self._is_connected.set()
|
||||
self._is_disconnected.clear()
|
||||
|
||||
|
@ -250,6 +247,7 @@ class RPCClient:
|
|||
self.log.debug(f"Nobody waiting for response to {req_id}")
|
||||
return
|
||||
if command == "response":
|
||||
self.log.debug("Received response %d", req_id)
|
||||
waiter.set_result(req.get("response"))
|
||||
elif command == "error":
|
||||
waiter.set_exception(RPCError(req.get("error", line)))
|
||||
|
@ -305,10 +303,7 @@ class RPCClient:
|
|||
req_id = self._next_req_id
|
||||
future = self._response_waiters[req_id] = self.loop.create_future()
|
||||
req = {"id": req_id, "command": command, **data}
|
||||
self.log.debug("Request %d: %s", req_id,
|
||||
', '.join(
|
||||
[command] +
|
||||
[f"{k}: {data[k]}" for k in self._logging_keys if k in data]))
|
||||
self.log.debug("Request %d: %s", req_id, command)
|
||||
assert self._writer is not None
|
||||
self._writer.write(json.dumps(req).encode("utf-8"))
|
||||
self._writer.write(b"\n")
|
||||
|
|
|
@ -177,6 +177,9 @@ class User(DBUser, BaseUser):
|
|||
self._is_connected = val
|
||||
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
|
||||
def connection_time(self) -> float:
|
||||
return self._connection_time
|
||||
|
@ -704,6 +707,10 @@ class User(DBUser, BaseUser):
|
|||
|
||||
# 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:
|
||||
await self.client.leave_channel(portal.channel_props)
|
||||
await self.on_channel_left(portal.ktid, portal.kt_type)
|
||||
|
@ -770,6 +777,22 @@ class User(DBUser, BaseUser):
|
|||
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:
|
||||
self.is_connected = False
|
||||
self._track_metric(METRIC_CONNECTED, False)
|
||||
|
|
|
@ -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.
|
||||
|
||||
### 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.
|
||||
|
||||
### 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.
|
||||
|
|
|
@ -3,5 +3,10 @@
|
|||
"type": "unix",
|
||||
"path": "/data/rpc.sock",
|
||||
"force": true
|
||||
},
|
||||
"register_timeout": 3000,
|
||||
"logging_keys": {
|
||||
"request": ["mxid"],
|
||||
"response": ["status"]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,5 +4,9 @@
|
|||
"path": "/var/run/matrix-appservice-kakaotalk/rpc.sock",
|
||||
"force": false
|
||||
},
|
||||
"register_timeout": 3000
|
||||
"register_timeout": 3000,
|
||||
"logging_keys": {
|
||||
"request": ["mxid"],
|
||||
"response": ["status"]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -30,6 +30,7 @@ import { ReadStreamUtil } from "node-kakao/stream"
|
|||
/** @typedef {import("node-kakao").MentionStruct} MentionStruct */
|
||||
/** @typedef {import("node-kakao").TalkNormalChannel} TalkNormalChannel */
|
||||
/** @typedef {import("node-kakao").TalkOpenChannel} TalkOpenChannel */
|
||||
/** @typedef {import("node-kakao").OpenLink} OpenLink */
|
||||
/** @typedef {import("node-kakao/dist/talk").TalkChannelList} TalkChannelList */
|
||||
// TODO Remove once/if some helper type hints are upstreamed
|
||||
/** @typedef {import("node-kakao").OpenChannelUserInfo} OpenChannelUserInfo */
|
||||
|
@ -43,9 +44,10 @@ const { KnownChatType } = chat
|
|||
import { emitLines, promisify } from "./util.js"
|
||||
|
||||
/**
|
||||
* @typedef {Object} ChannelProps
|
||||
* @typedef {object} ChannelProps
|
||||
* @property {Long} id
|
||||
* @property {ChannelType} type
|
||||
* @property {?Long} link_id
|
||||
*/
|
||||
|
||||
|
||||
|
@ -80,6 +82,7 @@ class PermError extends ProtocolError {
|
|||
/**
|
||||
* @param {?OpenChannelUserPerm[]} permNeeded
|
||||
* @param {?OpenChannelUserPerm} permActual
|
||||
* @param {string} action
|
||||
*/
|
||||
constructor(permNeeded, permActual, action) {
|
||||
const who =
|
||||
|
@ -97,6 +100,7 @@ class PermError extends ProtocolError {
|
|||
class UserClient {
|
||||
static #initializing = false
|
||||
|
||||
#connected = false
|
||||
#talkClient = new TalkClient()
|
||||
get talkClient() { return this.#talkClient }
|
||||
|
||||
|
@ -295,8 +299,10 @@ class UserClient {
|
|||
this.write("channel_meta_change", {
|
||||
info: {
|
||||
name: data.ol?.ln,
|
||||
description: data.ol?.desc || null,
|
||||
description: data.ol?.desc,
|
||||
photoURL: data.ol?.liu || null,
|
||||
linkId: data.ol?.linkId,
|
||||
linkURL: data.ol?.linkURL,
|
||||
},
|
||||
channelId: channel.channelId,
|
||||
channelType: channel.info.type,
|
||||
|
@ -364,20 +370,43 @@ class UserClient {
|
|||
* @param {ChannelProps} channelProps
|
||||
*/
|
||||
async getChannel(channelProps) {
|
||||
let channel = this.#talkClient.channelList.get(channelProps.id)
|
||||
if (channel) {
|
||||
return channel
|
||||
} 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 talkChannel = this.#talkClient.channelList.get(channelProps.id)
|
||||
if (talkChannel) {
|
||||
return talkChannel
|
||||
}
|
||||
|
||||
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) {
|
||||
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() {
|
||||
if (this.#talkClient.logon) {
|
||||
if (this.isConnected()) {
|
||||
this.#talkClient.close()
|
||||
}
|
||||
this.#connected = false
|
||||
}
|
||||
|
||||
isConnected() {
|
||||
return this.#talkClient?.logon || false
|
||||
}
|
||||
|
||||
isUnexpectedlyDisconnected() {
|
||||
return this.#connected && !this.isConnected()
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a user-specific command with (optional) data to the socket.
|
||||
*
|
||||
|
@ -425,6 +461,9 @@ export default class PeerClient {
|
|||
*/
|
||||
constructor(manager, socket, connID) {
|
||||
this.manager = manager
|
||||
this.registerTimeout = manager.registerTimeout
|
||||
this.loggingKeys = manager.loggingKeys
|
||||
|
||||
this.socket = socket
|
||||
this.connID = connID
|
||||
|
||||
|
@ -433,8 +472,6 @@ export default class PeerClient {
|
|||
this.maxCommandID = 0
|
||||
this.peerID = ""
|
||||
this.deviceName = "KakaoTalk Bridge"
|
||||
/** @type {[string]} */
|
||||
this.loggingKeys = []
|
||||
/** @type {Map<string, UserClient>} */
|
||||
this.userClients = new Map()
|
||||
}
|
||||
|
@ -465,10 +502,10 @@ export default class PeerClient {
|
|||
|
||||
setTimeout(() => {
|
||||
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.manager.registerTimeout)
|
||||
}, this.registerTimeout)
|
||||
}
|
||||
|
||||
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.uuid
|
||||
* @param {Object} req.form
|
||||
* @param {object} req.form
|
||||
*/
|
||||
registerDevice = async (req) => {
|
||||
// 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.
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.uuid
|
||||
* @param {Object} req.form
|
||||
* @param {object} req.form
|
||||
* @param {boolean} req.forced
|
||||
* @returns The response of the login attempt, including obtained
|
||||
* credentials for subsequent token-based login. If a required device passcode
|
||||
|
@ -583,23 +620,41 @@ export default class PeerClient {
|
|||
* @param {string} mxid
|
||||
* @param {ChannelProps} channelProps
|
||||
* @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.
|
||||
*/
|
||||
async #getUserChannel(mxid, channelProps, permNeeded, action) {
|
||||
const userClient = this.#getUser(mxid)
|
||||
const talkChannel = await userClient.getChannel(channelProps)
|
||||
if (permNeeded) {
|
||||
const permActual = talkChannel.getUserInfo({ userId: userClient.userId }).perm
|
||||
if (permNeeded.indexOf(permActual) == -1) {
|
||||
throw new PermError(permNeeded, permActual, action)
|
||||
}
|
||||
await this.#requireChannelPerm(talkChannel, permNeeded, action)
|
||||
}
|
||||
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 {OAuthCredential} req.oauth_credential
|
||||
*/
|
||||
|
@ -614,7 +669,7 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
* @param {OAuthCredential} req.oauth_credential
|
||||
*/
|
||||
|
@ -633,7 +688,7 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
*/
|
||||
userStop = async (req) => {
|
||||
|
@ -642,7 +697,7 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
* @param {?OAuthCredential} req.oauth_credential
|
||||
*/
|
||||
|
@ -651,7 +706,7 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
*/
|
||||
handleDisconnect = (req) => {
|
||||
|
@ -659,7 +714,7 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
*/
|
||||
isConnected = (req) => {
|
||||
|
@ -667,7 +722,7 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
*/
|
||||
getSettings = async (req) => {
|
||||
|
@ -691,7 +746,7 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
*/
|
||||
getOwnProfile = async (req) => {
|
||||
|
@ -699,7 +754,7 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
* @param {Long} req.user_id
|
||||
*/
|
||||
|
@ -708,7 +763,7 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
* @param {ChannelProps} req.channel_props
|
||||
*/
|
||||
|
@ -718,11 +773,16 @@ export default class PeerClient {
|
|||
const res = await talkChannel.updateAll()
|
||||
if (!res.success) return res
|
||||
|
||||
/** @type {?OpenLink} */
|
||||
const openLink = talkChannel.info.openLink
|
||||
|
||||
return makeCommandResult({
|
||||
name: talkChannel.getDisplayName(),
|
||||
description: talkChannel.info.openLink?.description,
|
||||
description: openLink?.description,
|
||||
// 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: {
|
||||
// TODO Get members from chatON?
|
||||
participants: Array.from(talkChannel.getAllUserInfo()),
|
||||
|
@ -732,7 +792,7 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
* @param {ChannelProps} req.channel_props
|
||||
*/
|
||||
|
@ -766,7 +826,7 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
* @param {ChannelProps} req.channel_props
|
||||
*/
|
||||
|
@ -776,7 +836,7 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
* @param {ChannelProps} req.channel_props
|
||||
* @param {?Long} req.sync_from
|
||||
|
@ -803,7 +863,7 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
* @param {ChannelProps} req.channel_props
|
||||
* @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} chatId
|
||||
*/
|
||||
|
@ -844,7 +904,7 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
* @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.uuid
|
||||
*/
|
||||
|
@ -867,7 +927,7 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
* @param {boolean} req.searchable
|
||||
*/
|
||||
|
@ -889,7 +949,7 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
*/
|
||||
listFriends = async (req) => {
|
||||
|
@ -897,7 +957,7 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
* @param {Long} req.user_id
|
||||
* @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.uuid
|
||||
* @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.friend_id The friend to search for.
|
||||
* @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
|
||||
*/
|
||||
getMemoIds = (req) => {
|
||||
|
@ -978,7 +1038,7 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
* @param {ChannelProps} req.channel_props
|
||||
* @param {string} req.key
|
||||
|
@ -993,7 +1053,7 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
* @param {ChannelProps} req.channel_props
|
||||
* @param {string} req.text
|
||||
|
@ -1012,7 +1072,7 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
* @param {ChannelProps} req.channel_props
|
||||
* @param {int} req.type
|
||||
|
@ -1036,7 +1096,7 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
* @param {ChannelProps} req.channel_props
|
||||
* @param {Long} req.chat_id
|
||||
|
@ -1050,7 +1110,7 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
* @param {ChannelProps} req.channel_props
|
||||
* @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 {ChannelProps} req.channel_props
|
||||
* @param {Long} req.user_id
|
||||
|
@ -1088,40 +1148,59 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
* @param {ChannelProps} req.channel_props
|
||||
* @param {string} req.name
|
||||
*/
|
||||
setChannelName = async (req) => {
|
||||
const talkChannel = await this.#getUserChannel(
|
||||
req.mxid,
|
||||
req.channel_props,
|
||||
[OpenChannelUserPerm.OWNER],
|
||||
"change channel name"
|
||||
)
|
||||
return await talkChannel.setTitleMeta(req.name)
|
||||
if (!isChannelTypeOpen(req.channel_props.type)) {
|
||||
const talkChannel = await this.#getUserNormalChannel(req.mxid, req.channel_props.id)
|
||||
return await talkChannel.setTitleMeta(req.name)
|
||||
} else {
|
||||
return await this.#setOpenChannelProperty(req.mxid, req.channel_props, "linkName", req.name)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
* @param {ChannelProps} req.channel_props
|
||||
* @param {string} req.description
|
||||
*/
|
||||
setChannelDescription = async (req) => {
|
||||
const talkChannel = await this.#getUserChannel(
|
||||
req.mxid,
|
||||
req.channel_props,
|
||||
[OpenChannelUserPerm.OWNER],
|
||||
"change channel description"
|
||||
return await this.#setOpenChannelProperty(req.mxid, req.channel_props, "description", req.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
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
* @param {ChannelProps} req.channel_props
|
||||
* @param {string} req.photo_url
|
||||
|
@ -1140,14 +1219,15 @@ export default class PeerClient {
|
|||
*/
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
* @param {Long} req.user_id
|
||||
*/
|
||||
createDirectChat = async (req) => {
|
||||
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 =
|
||||
!req.user_id.equals(userClient.userId)
|
||||
? 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 {ChannelProps} req.channel_props
|
||||
*/
|
||||
|
@ -1190,16 +1340,14 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @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.logging_keys
|
||||
*/
|
||||
handleRegister = async (req) => {
|
||||
this.peerID = req.peer_id
|
||||
this.deviceName = req.register_config.device_name || this.deviceName
|
||||
this.loggingKeys = req.logging_keys || this.loggingKeys
|
||||
this.log(`Registered socket ${this.connID} -> ${this.peerID}`)
|
||||
if (this.manager.clients.has(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)
|
||||
return
|
||||
}
|
||||
this.log(
|
||||
`Request ${req.id}:`,
|
||||
[req.command].concat(
|
||||
this.loggingKeys.filter(k => k in req).map(k => `${k}: ${JSON.stringify(req[k], this.#writeReplacer)}`))
|
||||
.join(', ')
|
||||
)
|
||||
this.log(`Request ${req.id}:`, this.#logObj(req, req.command, this.loggingKeys.request))
|
||||
this.maxCommandID = req.id
|
||||
let handler
|
||||
if (!this.peerID) {
|
||||
|
@ -1287,6 +1430,8 @@ export default class PeerClient {
|
|||
set_channel_description: this.setChannelDescription,
|
||||
//set_channel_photo: this.setChannelPhoto,
|
||||
create_direct_chat: this.createDirectChat,
|
||||
join_channel_by_url: this.joinChannelByURL,
|
||||
join_channel: this.joinChannel,
|
||||
leave_channel: this.leaveChannel,
|
||||
}[req.command] || this.handleUnknownCommand
|
||||
}
|
||||
|
@ -1302,21 +1447,59 @@ export default class PeerClient {
|
|||
success: false,
|
||||
status: err.response.status,
|
||||
}
|
||||
} else if ("status" in err) {
|
||||
resp.response = err
|
||||
} else {
|
||||
resp.command = "error"
|
||||
let errorDetails
|
||||
if (err instanceof ProtocolError) {
|
||||
resp.error = err.message
|
||||
} else {
|
||||
errorDetails = err.message
|
||||
} else if (err instanceof Error) {
|
||||
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)
|
||||
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) {
|
||||
return value.toString()
|
||||
} else {
|
||||
|
@ -1324,7 +1507,7 @@ export default class PeerClient {
|
|||
}
|
||||
}
|
||||
|
||||
#readReviver = function(key, value) {
|
||||
#readReviver(key, value) {
|
||||
if (value instanceof Object) {
|
||||
// TODO Use a type map if there will be many possible types
|
||||
if (value.__type__ == "Long") {
|
||||
|
|
|
@ -20,11 +20,33 @@ import path from "path"
|
|||
import PeerClient from "./client.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 {
|
||||
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.registerTimeout = registerTimeout || 3000
|
||||
this.loggingKeys = loggingKeys || {request: [], response: []}
|
||||
this.server = net.createServer(this.acceptConnection)
|
||||
this.connections = []
|
||||
this.clients = new Map()
|
||||
|
@ -43,7 +65,7 @@ export default class ClientManager {
|
|||
} else {
|
||||
const connID = this.connIDSequence++
|
||||
this.connections[connID] = sock
|
||||
new PeerClient(this, sock, connID).start()
|
||||
new PeerClient(this, sock, connID, this.loggingKeys).start()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -32,7 +32,11 @@ const configPath = args["--config"] || "config.json"
|
|||
console.log("[Main] Reading config from", configPath)
|
||||
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() {
|
||||
manager.stop().then(() => {
|
||||
|
|
Loading…
Reference in New Issue