Compare commits

...

33 Commits

Author SHA1 Message Date
Andrew Ferrazzutti 0dc75b8f1c Update roadmap with as-of-yet unsupported features 2022-05-16 03:10:33 -04:00
Andrew Ferrazzutti 46ace01fea Check for connectivity, not login status, on join from Matrix
Because connectivity is a cached & more specific than login status
2022-05-16 03:01:12 -04:00
Andrew Ferrazzutti 82d64b9b37 Support a more obvious non-cached connection check 2022-05-16 03:01:12 -04:00
Andrew Ferrazzutti 47b9623446 Fast-fail when trying to create a DM with a non-friend user 2022-05-16 03:01:12 -04:00
Andrew Ferrazzutti 1541732d0b Fixes to outbound channel name & topic setting
- Use the correct API for open channels
- Allow changing the name (title) of normal channels
2022-05-16 03:01:12 -04:00
Andrew Ferrazzutti bccd0ed4e0 Allow Node command handlers to throw responses and non-Error objects 2022-05-16 00:11:53 -04:00
Andrew Ferrazzutti bb9cdbd15e Log primitive response values 2022-05-16 00:10:08 -04:00
Andrew Ferrazzutti 1897c1e494 Try to catch unexpected TalkClient disconnects 2022-05-16 00:08:00 -04:00
Andrew Ferrazzutti 2cd7697aa5 Object -> object in JS type hints
And other minor JS comment fixes
2022-05-15 22:21:49 -04:00
Andrew Ferrazzutti 27b2c15ad3 Changes to RPC object logging and Node config
- Move config from Python to Node
- Also log responses
- Only log request/response object properties in Node, as logging them
in both Node and Python is redundant
- Error-out if Node listen config is missing
- For convenience, make PeerClient copy setting properties from
ClientManager instead of referencing them
2022-05-15 22:17:28 -04:00
Andrew Ferrazzutti 454d1b72cc Don't document that outbound portal avatars are supported
Also comment-out its unused code
2022-05-12 03:50:58 -04:00
Andrew Ferrazzutti 79fe8748b1 Minor style changes 2022-05-10 21:43:26 -04:00
Andrew Ferrazzutti d7d8cbbbb6 Use proper equality operator for Long 2022-05-10 21:43:26 -04:00
Andrew Ferrazzutti d4d02e8aba Add missing null check for inbound read receipts 2022-05-10 21:43:26 -04:00
Andrew Ferrazzutti fbd3d514e3 Reformat output of "whoami" command
Also print user pill for easily creating a MemoChat
2022-05-10 21:43:26 -04:00
Andrew Ferrazzutti 53d3170c04 Fixes to leaves and DM creation
Fixes -203 error for MemoChats, and automatically retries channel
creation when it fails the first time
2022-05-10 21:43:26 -04:00
Andrew Ferrazzutti 45fdd5ca29 Allow creating a MemoChat from Matrix by inviting own puppet to DM
WARNING: Seems to always fail with -203 (Invalid body)
2022-05-10 21:42:06 -04:00
Andrew Ferrazzutti 36598c34f6 Set default register timeout
Otherwise, bad things can happen if it's missing from config.json
2022-05-10 01:28:26 -04:00
Andrew Ferrazzutti 3f8660a3c4 Print "unknown peer" instead of null, if no RPC peer is known 2022-05-10 01:17:05 -04:00
Andrew Ferrazzutti 652aa22048 Update some comments
- TalkAPIClient#close is the "logout" command that was wanted
- Direct Chats do share the same ID for every member
2022-05-10 01:17:05 -04:00
Andrew Ferrazzutti c691372c6a Don't delete saved creds if a login is blocked & forced login is off 2022-05-10 01:17:05 -04:00
Andrew Ferrazzutti 76d0ead99f Make disconnections an important notice 2022-05-10 01:17:05 -04:00
Andrew Ferrazzutti 8da7f1efbd Wait for RPC connection before sending notice to log back in 2022-05-10 01:17:05 -04:00
Andrew Ferrazzutti 20bdbf9cd1 Add config for logging arguments of RPC commands 2022-05-10 01:17:05 -04:00
Andrew Ferrazzutti 3c0d890577 Add Node config setting for RPC registration timeout 2022-05-10 01:17:05 -04:00
Andrew Ferrazzutti 54c772d3ac "login" command: Don't delete saved password when using it 2022-05-09 03:31:14 -04:00
Andrew Ferrazzutti b8b451b751 Validate arguments passed to "login" command 2022-05-09 03:31:14 -04:00
Andrew Ferrazzutti d594fb98d1 Roadmap corrections 2022-05-09 03:31:14 -04:00
Andrew Ferrazzutti c90f86849e Move core of "leave" command into Portal method 2022-05-09 03:31:14 -04:00
Andrew Ferrazzutti 5b16694f78 Add some command aliases 2022-05-09 03:31:14 -04:00
Andrew Ferrazzutti 37101f42c1 Friend cmds: Use same mention formatting as outbound message formatter 2022-05-09 03:31:14 -04:00
Andrew Ferrazzutti a49e2768a3 Don't send outbound mentions for direct chats
because KT doesn't allow them, and errors when the bridge sends them
2022-05-09 03:31:14 -04:00
Andrew Ferrazzutti 128979e06b Add missing await 2022-05-09 03:31:14 -04:00
16 changed files with 430 additions and 160 deletions

View File

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

View File

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

View File

@ -63,7 +63,7 @@ async def ping(evt: CommandEvent) -> None:
if not await evt.sender.is_logged_in():
await evt.reply("You are **logged out** of KakaoTalk.")
else:
is_connected = evt.sender.is_connected and await evt.sender.client.is_connected()
is_connected = await evt.sender.is_connected_now()
await evt.reply(
"You are logged into KakaoTalk.\n\n"
f"You are {'connected to' if is_connected else '**disconnected** from'} KakaoTalk chats."
@ -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:

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

View File

@ -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] = {}

View File

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

View File

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

View File

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

View File

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

View File

@ -177,6 +177,9 @@ class User(DBUser, BaseUser):
self._is_connected = val
self._connection_time = time.monotonic()
async def is_connected_now(self) -> bool:
return self._client is not None and await self._client.is_connected()
@property
def connection_time(self) -> float:
return self._connection_time
@ -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)

View File

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

View File

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

View File

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

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

View File

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

View File

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