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>
* [ ] 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

View File

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

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)
@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:

View File

@ -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, "")

View File

@ -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,
)

View File

@ -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

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

View File

@ -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:

View File

@ -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

View File

@ -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:

View File

@ -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)

View File

@ -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}")

View File

@ -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")

View File

@ -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)

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

View File

@ -3,5 +3,10 @@
"type": "unix",
"path": "/data/rpc.sock",
"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",
"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").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") {

View File

@ -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()
}
}

View File

@ -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(() => {