Compare commits

..

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

16 changed files with 160 additions and 430 deletions

View File

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

View File

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

View File

@ -63,7 +63,7 @@ async def ping(evt: CommandEvent) -> None:
if not await evt.sender.is_logged_in():
await evt.reply("You are **logged out** of KakaoTalk.")
else:
is_connected = await evt.sender.is_connected_now()
is_connected = evt.sender.is_connected and await evt.sender.client.is_connected()
await evt.reply(
"You are logged into KakaoTalk.\n\n"
f"You are {'connected to' if is_connected else '**disconnected** from'} KakaoTalk chats."
@ -76,7 +76,6 @@ async def ping(evt: CommandEvent) -> None:
help_section=SECTION_CONNECTION,
help_text="(Re)connect to KakaoTalk chats & sync any missed chat updates",
help_args="[_number of channels to sync_]",
aliases=["connect"],
)
async def sync(evt: CommandEvent) -> None:
try:

View File

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

View File

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

View File

@ -109,10 +109,6 @@ class Client:
await cls._rpc_client.connect()
await cls._rpc_client.wait_for_disconnection()
@classmethod
def wait_for_connection(cls) -> Awaitable[None]:
return cls._rpc_client.wait_for_connection()
@classmethod
def stop_cls(cls) -> None:
"""Stop and disconnect from the Node backend."""
@ -498,7 +494,6 @@ class Client:
description=description,
)
""" TODO
def set_channel_photo(
self,
channel_props: ChannelProps,
@ -509,7 +504,6 @@ class Client:
channel_props=channel_props.serialize(),
photo_url=photo_url,
)
"""
def create_direct_chat(self, ktid: Long) -> Awaitable[Long]:
return self._api_user_request_result(
@ -687,9 +681,6 @@ 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)
@ -707,7 +698,6 @@ 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

@ -96,12 +96,12 @@ class MatrixHandler(BaseMatrixHandler):
)
return
elif (
not user.is_connected
not await user.is_logged_in()
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 connected to this KakaoTalk bridge."
room_id, user.mxid, "You are not logged in to this KakaoTalk bridge."
)
return

View File

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

View File

@ -247,7 +247,6 @@ 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)))

View File

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

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

View File

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

View File

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

View File

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

View File

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