Compare commits
33 Commits
095641fe08
...
0dc75b8f1c
Author | SHA1 | Date |
---|---|---|
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 | |
Andrew Ferrazzutti | 454d1b72cc | |
Andrew Ferrazzutti | 79fe8748b1 | |
Andrew Ferrazzutti | d7d8cbbbb6 | |
Andrew Ferrazzutti | d4d02e8aba | |
Andrew Ferrazzutti | fbd3d514e3 | |
Andrew Ferrazzutti | 53d3170c04 | |
Andrew Ferrazzutti | 45fdd5ca29 | |
Andrew Ferrazzutti | 36598c34f6 | |
Andrew Ferrazzutti | 3f8660a3c4 | |
Andrew Ferrazzutti | 652aa22048 | |
Andrew Ferrazzutti | c691372c6a | |
Andrew Ferrazzutti | 76d0ead99f | |
Andrew Ferrazzutti | 8da7f1efbd | |
Andrew Ferrazzutti | 20bdbf9cd1 | |
Andrew Ferrazzutti | 3c0d890577 | |
Andrew Ferrazzutti | 54c772d3ac | |
Andrew Ferrazzutti | b8b451b751 | |
Andrew Ferrazzutti | d594fb98d1 | |
Andrew Ferrazzutti | c90f86849e | |
Andrew Ferrazzutti | 5b16694f78 | |
Andrew Ferrazzutti | 37101f42c1 | |
Andrew Ferrazzutti | a49e2768a3 | |
Andrew Ferrazzutti | 128979e06b |
20
ROADMAP.md
20
ROADMAP.md
|
@ -24,10 +24,10 @@
|
|||
* [x] Leave<sup>[3]</sup>
|
||||
* [ ] Ban<sup>[4]</sup>
|
||||
* [ ] Unban<sup>[4]</sup>
|
||||
* [ ] Room metadata changes<sup>[1]</sup>
|
||||
* [ ] Room metadata changes
|
||||
* [x] Name
|
||||
* [x] Topic
|
||||
* [x] Avatar
|
||||
* [ ] 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>
|
||||
* [ ] Channel metadata
|
||||
* [x] Channel metadata
|
||||
* [x] Name
|
||||
* [x] Description
|
||||
* [x] Cover photo<sup>[5]</sup>
|
||||
|
@ -74,6 +74,13 @@
|
|||
* [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
|
||||
|
@ -82,8 +89,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
|
||||
|
@ -92,4 +100,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 only get synced on backfill, or on changing channel name/description
|
||||
<sup>[5]</sup> Might not get synced on backfill or initial portal creation. Might get synced on live update to channel name/description
|
||||
|
|
|
@ -69,10 +69,30 @@ async def login(evt: CommandEvent) -> None:
|
|||
await evt.reply("You're already logged in")
|
||||
return
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
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,
|
||||
|
@ -81,20 +101,6 @@ 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:
|
||||
|
@ -277,7 +283,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)
|
||||
|
@ -337,7 +343,8 @@ 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"
|
||||
help_text="When logging in, automatically log out of any other existing KakaoTalk session",
|
||||
aliases=["enable-force-login"],
|
||||
)
|
||||
async def enable_forced_login(evt: CommandEvent) -> None:
|
||||
if evt.sender.force_login:
|
||||
|
@ -350,7 +357,8 @@ 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"
|
||||
help_text="When logging in, ask before logging out of another existing KakaoTalk session, if one exists",
|
||||
aliases=["disable-force-login"],
|
||||
)
|
||||
async def disable_forced_login(evt: CommandEvent) -> None:
|
||||
if not evt.sender.force_login:
|
||||
|
|
|
@ -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."
|
||||
|
@ -76,6 +76,7 @@ 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:
|
||||
|
|
|
@ -19,7 +19,7 @@ from typing import TYPE_CHECKING, Awaitable
|
|||
import asyncio
|
||||
|
||||
from mautrix.bridge.commands import HelpSection, command_handler
|
||||
from mautrix.types import SerializerError
|
||||
from mautrix.types import Format, SerializerError
|
||||
from mautrix.util import utf16_surrogate
|
||||
from mautrix.util.formatter import (
|
||||
EntityString,
|
||||
|
@ -65,9 +65,13 @@ 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}** (KakaoTalk ID: {uuid}, internal ID: `{evt.sender.ktid}`)"
|
||||
f"You're logged in as **{own_info.more.nickName}**"
|
||||
f"\n* KakaoTalk ID: {uuid}"
|
||||
f"\n* Internal ID: `{evt.sender.ktid}`"
|
||||
f"\n* Matrix user: [{puppet.name}](https://matrix.to/#/{puppet.default_mxid})"
|
||||
)
|
||||
else:
|
||||
await evt.reply(
|
||||
|
@ -153,13 +157,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,
|
||||
|
@ -237,10 +241,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
|
||||
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 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()
|
||||
))
|
||||
if not parsed.entities:
|
||||
await evt.reply("No user found")
|
||||
return
|
||||
|
@ -311,5 +315,7 @@ async def leave(evt: CommandEvent) -> None:
|
|||
await evt.reply("This command may only be used in a KakaoTalk channel portal room")
|
||||
return
|
||||
await evt.mark_read()
|
||||
await evt.sender.client.leave_channel(evt.portal.channel_props)
|
||||
await evt.sender.on_channel_left(evt.portal.ktid, evt.portal.kt_type)
|
||||
try:
|
||||
await evt.sender.leave_channel(evt.portal)
|
||||
except CommandException as e:
|
||||
await evt.reply(f"Error from KakaoTalk: {e}")
|
||||
|
|
|
@ -154,7 +154,11 @@ 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:
|
||||
if (
|
||||
content.get("format", None) == Format.HTML and content["formatted_body"] and
|
||||
content.msgtype.is_text and
|
||||
not portal.is_direct
|
||||
):
|
||||
parsed = await ToKakaoTalkParser().parse(utf16_surrogate.add(content["formatted_body"]))
|
||||
text = utf16_surrogate.remove(parsed.text)
|
||||
mentions_by_user: dict[Long, MentionStruct] = {}
|
||||
|
|
|
@ -109,6 +109,10 @@ 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."""
|
||||
|
@ -494,6 +498,7 @@ class Client:
|
|||
description=description,
|
||||
)
|
||||
|
||||
""" TODO
|
||||
def set_channel_photo(
|
||||
self,
|
||||
channel_props: ChannelProps,
|
||||
|
@ -504,6 +509,7 @@ 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(
|
||||
|
@ -681,6 +687,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)
|
||||
|
@ -698,6 +707,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:
|
||||
|
|
|
@ -96,12 +96,12 @@ 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
|
||||
|
||||
|
|
|
@ -1219,9 +1219,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 +1335,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"
|
||||
|
@ -1758,19 +1758,20 @@ 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:
|
||||
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")
|
||||
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)
|
||||
else:
|
||||
for removed_mxid in (r.mxid for r in (
|
||||
removed,
|
||||
await u.User.get_by_ktid(removed.ktid) if not removed.is_real_user else None
|
||||
removed_user if not removed.is_real_user else None
|
||||
) if r):
|
||||
# NOTE KakaoTalk kick = Matrix ban
|
||||
try:
|
||||
await sender_intent.ban_user(
|
||||
self.mxid, removed_mxid, None if sender else "Kicked by channel admin"
|
||||
|
@ -1781,7 +1782,11 @@ class Portal(DBPortal, BasePortal):
|
|||
await self.main_intent.ban_user(
|
||||
self.mxid, removed_mxid, reason=f"Kicked by {sender.name}"
|
||||
)
|
||||
# TODO Clean and delete if removed is real user and portal is direct / not open
|
||||
if self.is_direct and removed.ktid == self.kt_receiver:
|
||||
self.log.info(
|
||||
f"{removed.ktid} was the recipient puppet of this portal. Cleaning up and deleting..."
|
||||
)
|
||||
await self.cleanup_and_delete()
|
||||
|
||||
# TODO Find when or if there is a listener for this
|
||||
# TODO Confirm whether this can refer to any user that was kicked, or only to the current user
|
||||
|
@ -1866,7 +1871,7 @@ class Portal(DBPortal, BasePortal):
|
|||
self.log.trace("Leaving room with %s post-backfill", intent.mxid)
|
||||
await intent.leave_room(self.mxid)
|
||||
self.log.info("Backfilled %d messages through %s", len(chats), source.mxid)
|
||||
self._sync_read_receipts(source)
|
||||
await self._sync_read_receipts(source)
|
||||
|
||||
# region Database getters
|
||||
|
||||
|
@ -1877,7 +1882,7 @@ class Portal(DBPortal, BasePortal):
|
|||
if not self.is_direct:
|
||||
self._main_intent = self.az.intent
|
||||
else:
|
||||
# TODO Save kt_sender in DB instead? Depends on if DM channels are shared...
|
||||
# TODO Save kt_sender in DB instead? Only do that if keeping a unique DM portal for each receiver
|
||||
user = await u.User.get_by_ktid(self.kt_receiver)
|
||||
assert user, f"Found no user for this portal's receiver of {self.kt_receiver}"
|
||||
if self.kt_type == KnownChannelType.MemoChat:
|
||||
|
@ -1916,7 +1921,7 @@ class Portal(DBPortal, BasePortal):
|
|||
create: bool = True,
|
||||
kt_type: ChannelType | None = None,
|
||||
) -> Portal | None:
|
||||
# TODO Find out if direct channels are shared. If so, don't need kt_receiver!
|
||||
# TODO Direct chats are shared, so can remove kt_receiver if DM portals should be shared
|
||||
if kt_type:
|
||||
kt_receiver = kt_receiver if KnownChannelType.is_direct(kt_type) else 0
|
||||
ktid_full = (ktid, kt_receiver)
|
||||
|
|
|
@ -247,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)))
|
||||
|
|
|
@ -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
|
||||
|
@ -317,6 +320,8 @@ 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()
|
||||
|
@ -388,6 +393,9 @@ 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,
|
||||
|
@ -429,7 +437,6 @@ 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)
|
||||
|
@ -681,7 +688,7 @@ class User(DBUser, BaseUser):
|
|||
kt_type = KnownChannelType.MemoChat
|
||||
memo_ids = await self.client.get_memo_ids()
|
||||
if not memo_ids:
|
||||
ktid = Long(0)
|
||||
ktid = None
|
||||
else:
|
||||
ktid = memo_ids[0]
|
||||
if len(memo_ids) > 1:
|
||||
|
@ -698,6 +705,13 @@ 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:
|
||||
|
@ -746,7 +760,10 @@ 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}")
|
||||
await self.send_bridge_notice(
|
||||
f"Disconnected from KakaoTalk: {reason_str} {reason_suffix}",
|
||||
important=True,
|
||||
)
|
||||
|
||||
async def on_error(self, error: JSON) -> None:
|
||||
await self.send_bridge_notice(
|
||||
|
@ -756,6 +773,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)
|
||||
|
|
|
@ -2,3 +2,11 @@
|
|||
If `type` is `unix`, `path` is the path where to create the socket, and `force` is whether to overwrite the socket file if it already exists.
|
||||
|
||||
If `type` is `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.
|
||||
|
|
|
@ -3,5 +3,10 @@
|
|||
"type": "unix",
|
||||
"path": "/data/rpc.sock",
|
||||
"force": true
|
||||
},
|
||||
"register_timeout": 3000,
|
||||
"logging_keys": {
|
||||
"request": ["mxid"],
|
||||
"response": ["status"]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,5 +3,10 @@
|
|||
"type": "unix",
|
||||
"path": "/var/run/matrix-appservice-kakaotalk/rpc.sock",
|
||||
"force": false
|
||||
},
|
||||
"register_timeout": 3000,
|
||||
"logging_keys": {
|
||||
"request": ["mxid"],
|
||||
"response": ["status"]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,6 +80,7 @@ class PermError extends ProtocolError {
|
|||
/**
|
||||
* @param {?OpenChannelUserPerm[]} permNeeded
|
||||
* @param {?OpenChannelUserPerm} permActual
|
||||
* @param {string} action
|
||||
*/
|
||||
constructor(permNeeded, permActual, action) {
|
||||
const who =
|
||||
|
@ -97,6 +98,7 @@ class PermError extends ProtocolError {
|
|||
class UserClient {
|
||||
static #initializing = false
|
||||
|
||||
#connected = false
|
||||
#talkClient = new TalkClient()
|
||||
get talkClient() { return this.#talkClient }
|
||||
|
||||
|
@ -155,10 +157,19 @@ class UserClient {
|
|||
})
|
||||
|
||||
this.#talkClient.on("chat_read", (chat, channel, reader) => {
|
||||
this.log(`Chat ${chat.logId} read in channel ${channel.channelId} by ${reader.userId}`)
|
||||
let senderId
|
||||
if (reader) {
|
||||
senderId = reader.userId
|
||||
} else if (channel.info.type == "MemoChat") {
|
||||
senderId = channel.clientUser.userId
|
||||
} else {
|
||||
this.error(`Chat ${chat.logId} read in channel ${channel.channelId} by unknown reader (channel type: ${channel.info.type || "none"})`)
|
||||
return
|
||||
}
|
||||
this.log(`Chat ${chat.logId} read in channel ${channel.channelId} by ${senderId}`)
|
||||
this.write("chat_read", {
|
||||
chatId: chat.logId,
|
||||
senderId: reader.userId,
|
||||
senderId: senderId,
|
||||
channelId: channel.channelId,
|
||||
channelType: channel.info.type,
|
||||
})
|
||||
|
@ -298,7 +309,7 @@ class UserClient {
|
|||
}
|
||||
})
|
||||
|
||||
this.#talkClient.on("disconnected", (reason) => {
|
||||
this.#talkClient.on("disconnected", reason => {
|
||||
this.log(`Disconnected (reason=${reason})`)
|
||||
this.disconnect()
|
||||
this.write("disconnected", {
|
||||
|
@ -313,7 +324,7 @@ class UserClient {
|
|||
})
|
||||
})
|
||||
|
||||
this.#talkClient.on("error", (err) => {
|
||||
this.#talkClient.on("error", err => {
|
||||
this.log(`Client error: ${err}`)
|
||||
this.write("error", {
|
||||
error: err,
|
||||
|
@ -355,20 +366,39 @@ 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
|
||||
)
|
||||
const res = await channelList.addChannel({ channelId: channelProps.id })
|
||||
if (!res.success) {
|
||||
this.error(`Unable to add ${channelProps.type} channel ${channelProps.id}`)
|
||||
throw res
|
||||
}
|
||||
return res.result
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Long} channelId
|
||||
*/
|
||||
async getNormalChannel(channelId) {
|
||||
const channelList = this.#talkClient.channelList.normal
|
||||
const talkChannel = channelList.get(channelId)
|
||||
if (talkChannel) {
|
||||
return talkChannel
|
||||
}
|
||||
|
||||
const res = await channelList.addChannel({ channelId: channelId })
|
||||
if (!res.success) {
|
||||
this.error(`Unable to add normal channel ${channelProps.id}`)
|
||||
throw res
|
||||
}
|
||||
return res.result
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -380,19 +410,26 @@ class UserClient {
|
|||
if (credential && this.#credential != credential) {
|
||||
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.
|
||||
*
|
||||
|
@ -416,15 +453,17 @@ 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 = null
|
||||
this.peerID = ""
|
||||
this.deviceName = "KakaoTalk Bridge"
|
||||
|
||||
/** @type {Map<string, UserClient>} */
|
||||
this.userClients = new Map()
|
||||
}
|
||||
|
@ -455,10 +494,10 @@ export default class PeerClient {
|
|||
|
||||
setTimeout(() => {
|
||||
if (!this.peerID && !this.stopped) {
|
||||
this.log("Didn't receive register request within 3 seconds, terminating")
|
||||
this.log(`Didn't receive register request within ${this.registerTimeout/1000} seconds, terminating`)
|
||||
this.stop("Register request timeout")
|
||||
}
|
||||
}, 3000)
|
||||
}, this.registerTimeout)
|
||||
}
|
||||
|
||||
async stop(error = null) {
|
||||
|
@ -482,11 +521,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})`)
|
||||
this.log(`Connection closed (peer: ${this.peerID || "unknown peer"})`)
|
||||
}
|
||||
|
||||
#closeUsers() {
|
||||
this.log("Closing all API clients for", this.peerID)
|
||||
this.log(`Closing all API clients for ${this.peerID || "unknown peer"}`)
|
||||
for (const userClient of this.userClients.values()) {
|
||||
userClient.disconnect()
|
||||
}
|
||||
|
@ -504,10 +543,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
|
||||
|
@ -517,16 +556,15 @@ export default class PeerClient {
|
|||
|
||||
/**
|
||||
* Obtain login tokens. If this fails due to not having a device, also request a device passcode.
|
||||
* @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) {
|
||||
|
@ -574,23 +612,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
|
||||
*/
|
||||
|
@ -605,7 +661,7 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
* @param {OAuthCredential} req.oauth_credential
|
||||
*/
|
||||
|
@ -624,7 +680,7 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
*/
|
||||
userStop = async (req) => {
|
||||
|
@ -633,7 +689,7 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
* @param {?OAuthCredential} req.oauth_credential
|
||||
*/
|
||||
|
@ -642,7 +698,7 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
*/
|
||||
handleDisconnect = (req) => {
|
||||
|
@ -650,7 +706,7 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
*/
|
||||
isConnected = (req) => {
|
||||
|
@ -658,7 +714,7 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
*/
|
||||
getSettings = async (req) => {
|
||||
|
@ -682,7 +738,7 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
*/
|
||||
getOwnProfile = async (req) => {
|
||||
|
@ -690,7 +746,7 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
* @param {Long} req.user_id
|
||||
*/
|
||||
|
@ -699,7 +755,7 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
* @param {ChannelProps} req.channel_props
|
||||
*/
|
||||
|
@ -723,7 +779,7 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
* @param {ChannelProps} req.channel_props
|
||||
*/
|
||||
|
@ -757,7 +813,7 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
* @param {ChannelProps} req.channel_props
|
||||
*/
|
||||
|
@ -767,7 +823,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
|
||||
|
@ -794,7 +850,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
|
||||
|
@ -824,7 +880,7 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} Receipt
|
||||
* @typedef {object} Receipt
|
||||
* @property {Long} userId
|
||||
* @property {Long} chatId
|
||||
*/
|
||||
|
@ -835,7 +891,7 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
* @param {string} req.uuid
|
||||
*/
|
||||
|
@ -844,7 +900,7 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
* @param {string} req.uuid
|
||||
*/
|
||||
|
@ -858,7 +914,7 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
* @param {boolean} req.searchable
|
||||
*/
|
||||
|
@ -880,7 +936,7 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
*/
|
||||
listFriends = async (req) => {
|
||||
|
@ -888,7 +944,7 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
* @param {Long} req.user_id
|
||||
* @param {boolean} req.add
|
||||
|
@ -902,7 +958,7 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
* @param {string} req.uuid
|
||||
* @param {boolean} req.add
|
||||
|
@ -939,7 +995,7 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid The user whose friend is being looked up.
|
||||
* @param {string} req.friend_id The friend to search for.
|
||||
* @param {string} propertyName The property to retrieve from the specified friend.
|
||||
|
@ -952,7 +1008,7 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
*/
|
||||
getMemoIds = (req) => {
|
||||
|
@ -969,7 +1025,7 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
* @param {ChannelProps} req.channel_props
|
||||
* @param {string} req.key
|
||||
|
@ -984,7 +1040,7 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
* @param {ChannelProps} req.channel_props
|
||||
* @param {string} req.text
|
||||
|
@ -1003,7 +1059,7 @@ export default class PeerClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
* @param {ChannelProps} req.channel_props
|
||||
* @param {int} req.type
|
||||
|
@ -1027,7 +1083,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
|
||||
|
@ -1041,7 +1097,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
|
||||
|
@ -1055,7 +1111,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
|
||||
|
@ -1072,50 +1128,69 @@ 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 == talkChannel.clientUser.userId) {
|
||||
if (req.user_id.equals(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) => {
|
||||
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 talkChannel.setNoticeMeta(req.description)
|
||||
return await this.#setOpenChannelProperty(req.mxid, req.channel_props, "description", req.description)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {string} mxid
|
||||
* @param {ChannelProps} channelProps
|
||||
* @param {string} propertyName
|
||||
* @param {any} propertyValue
|
||||
*/
|
||||
async #setOpenChannelProperty(mxid, channelProps, propertyName, propertyValue) {
|
||||
if (isChannelTypeOpen(channelProps)) {
|
||||
throw ProtocolError(`Cannot set ${propertyName} of non-open channel ${channelProps.id} (type = ${channelProps.type})`)
|
||||
}
|
||||
|
||||
const userClient = this.#getUser(mxid)
|
||||
/** @type {TalkOpenChannel} */
|
||||
const talkChannel = await userClient.getChannel(channelProps)
|
||||
this.#requireChannelPerm(talkChannel, [OpenChannelUserPerm.OWNER], `change channel ${propertyName}`)
|
||||
|
||||
const linkRes = await talkChannel.getLatestOpenLink()
|
||||
if (!linkRes.success) throw linkRes
|
||||
|
||||
const link = linkRes.result
|
||||
link[propertyName] = propertyValue
|
||||
return await userClient.talkClient.channelList.open.updateOpenLink(
|
||||
{ linkId: link.linkId }, link
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
* TODO
|
||||
* @param {object} req
|
||||
* @param {string} req.mxid
|
||||
* @param {ChannelProps} req.channel_props
|
||||
* @param {string} req.photo_url
|
||||
*/
|
||||
setChannelPhoto = async (req) => {
|
||||
const talkChannel = await this.#getUserChannel(
|
||||
req.mxid,
|
||||
|
@ -1128,23 +1203,59 @@ 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 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)
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 {ChannelProps} req.channel_props
|
||||
*/
|
||||
|
@ -1162,9 +1273,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) => {
|
||||
|
@ -1200,7 +1311,7 @@ export default class PeerClient {
|
|||
this.log("Ignoring old request", req.id)
|
||||
return
|
||||
}
|
||||
this.log("Received request", req.id, "with command", req.command)
|
||||
this.log(`Request ${req.id}:`, this.#logObj(req, req.command, this.loggingKeys.request))
|
||||
this.maxCommandID = req.id
|
||||
let handler
|
||||
if (!this.peerID) {
|
||||
|
@ -1250,7 +1361,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
|
||||
|
@ -1267,21 +1378,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 {
|
||||
|
@ -1289,7 +1438,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,10 +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) {
|
||||
/**
|
||||
* @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()
|
||||
|
@ -42,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)
|
||||
const manager = new ClientManager(
|
||||
config.listen,
|
||||
config.register_timeout,
|
||||
config.logging_keys
|
||||
)
|
||||
|
||||
function stop() {
|
||||
manager.stop().then(() => {
|
||||
|
|
Loading…
Reference in New Issue