Compare commits

..

7 Commits

39 changed files with 347 additions and 1135 deletions

View File

@ -8,7 +8,7 @@
These instructions describe how to install and run the bridge manually from a clone of this repository. These instructions describe how to install and run the bridge manually from a clone of this repository.
## Minimum requirements ## Minimum requirements
* Python 3.7 * Python 3.8
* Node 16.13 (not yet tested with earlier versions) * Node 16.13 (not yet tested with earlier versions)
* postgresql 11 or sqlite3 * postgresql 11 or sqlite3
* A KakaoTalk account on a smartphone (Android or iOS) * A KakaoTalk account on a smartphone (Android or iOS)
@ -41,7 +41,26 @@ These instructions describe how to install and run the bridge manually from a cl
* Note that on first use, you must enter a verification code on a smartphone version of KakaoTalk in order for the login to complete * Note that on first use, you must enter a verification code on a smartphone version of KakaoTalk in order for the login to complete
## systemd ## systemd
Coming soon! The [systemd](systemd) directory provides sample service unit configuration files for running the bridge & node-kakao modules:
* `matrix-appservice-kakao.service` for the bridge module
* `matrix-appservice-kakao-node.service` for the node-kakao module
To use them as-is, follow these steps after [initial setup](#initial-setup):
1. Place/link your clone of this repository in `/opt/matrix-appservice-kakaotalk`
* If moving your repo directory after having already created a Python virtual environment for the bridge module, re-create the virtual environment after moving to ensure its paths are up-to-date
* Alternatively, clone it to `/opt/matrix-appservice-kakaotalk` in the first place
1. Install the services as either system or user units
* To install as system units:
1. Copy/link the service files to a directory in the system unit search path, such as `/etc/systemd/system/`
1. Create the services' configuration directory with `sudo mkdir /etc/matrix-appservice-kakaotalk`
1. RECOMMENDED: Create the `matrix-appservice-kakaotalk` user on your system with `adduser` or an equivalent command, then uncomment the `User` and `Group` lines in the service files
* To install as user units:
1. Copy/link the service files to a directory in the user unit search path, such as `~/.config/systemd/user`
1. Create the services' configuration directory with `mkdir $XDG_CONFIG_HOME/matrix-appservice-kakaotalk`
1. Copy the bridge & node-kakao module configuration files to the services' configuration directory as `config.yaml` and `node-config.json`, respectively
1. Start the services now and on every boot boot with `[sudo] systemd [--user] enable --now matrix-appservice-kakaotalk{,-node}`
## Upgrading ## Upgrading
Simply `git pull` or `git rebase` the latest changes and rerun any installation commands (`npm install`, `pip install -Ur ...`). Simply `git pull` or `git rebase` the latest changes and rerun any installation commands (`npm install`, `pip install -Ur ...`).

View File

@ -1,2 +1,2 @@
__version__ = "0.0.1" __version__ = "0.1.0"
__author__ = "Andrew Ferrazzutti <fair@miscworks.net>" __author__ = "Andrew Ferrazzutti <fair@miscworks.net>"

View File

@ -28,7 +28,7 @@ from .puppet import Puppet
from .user import User from .user import User
from .kt.client import Client as KakaoTalkClient from .kt.client import Client as KakaoTalkClient
from .version import linkified_version, version from .version import linkified_version, version
from .web import PublicBridgeWebsite #from .web import PublicBridgeWebsite
from . import commands as _ from . import commands as _
@ -46,7 +46,7 @@ class KakaoTalkBridge(Bridge):
config: Config config: Config
matrix: MatrixHandler matrix: MatrixHandler
public_website: PublicBridgeWebsite | None #public_website: PublicBridgeWebsite | None
def prepare_config(self)->None: def prepare_config(self)->None:
super().prepare_config() super().prepare_config()
@ -55,9 +55,9 @@ class KakaoTalkBridge(Bridge):
super().prepare_db() super().prepare_db()
init_db(self.db) init_db(self.db)
""" TODO Implement web login
def prepare_bridge(self) -> None: def prepare_bridge(self) -> None:
super().prepare_bridge() super().prepare_bridge()
""" TODO Implement web login
if self.config["appservice.public.enabled"]: if self.config["appservice.public.enabled"]:
secret = self.config["appservice.public.shared_secret"] secret = self.config["appservice.public.shared_secret"]
self.public_website = PublicBridgeWebsite(loop=self.loop, shared_secret=secret) self.public_website = PublicBridgeWebsite(loop=self.loop, shared_secret=secret)
@ -66,8 +66,7 @@ class KakaoTalkBridge(Bridge):
) )
else: else:
self.public_website = None self.public_website = None
""" """
self.public_website = None
def prepare_stop(self) -> None: def prepare_stop(self) -> None:
self.log.debug("Stopping RPC connection") self.log.debug("Stopping RPC connection")
@ -87,8 +86,10 @@ class KakaoTalkBridge(Bridge):
if self.config["bridge.resend_bridge_info"]: if self.config["bridge.resend_bridge_info"]:
self.add_startup_actions(self.resend_bridge_info()) self.add_startup_actions(self.resend_bridge_info())
await super().start() await super().start()
""" TODO Implement web login
if self.public_website: if self.public_website:
self.public_website.ready_wait.set_result(None) self.public_website.ready_wait.set_result(None)
"""
async def resend_bridge_info(self) -> None: async def resend_bridge_info(self) -> None:
self.config["bridge.resend_bridge_info"] = False self.config["bridge.resend_bridge_info"] = False

View File

@ -13,13 +13,13 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
import time #import time
from yarl import URL #from yarl import URL
from mautrix.bridge.commands import HelpSection, command_handler from mautrix.bridge.commands import HelpSection, command_handler
from mautrix.errors import MForbidden from mautrix.errors import MForbidden
from mautrix.util.signed_token import sign_token #from mautrix.util.signed_token import sign_token
from ..kt.client import Client as KakaoTalkClient from ..kt.client import Client as KakaoTalkClient
from ..kt.client.errors import DeviceVerificationRequired, IncorrectPasscode, IncorrectPassword, CommandException from ..kt.client.errors import DeviceVerificationRequired, IncorrectPasscode, IncorrectPassword, CommandException
@ -29,6 +29,7 @@ from .typehint import CommandEvent
SECTION_AUTH = HelpSection("Authentication", 10, "") SECTION_AUTH = HelpSection("Authentication", 10, "")
""" TODO Implement web login
web_unsupported = ( web_unsupported = (
"This instance of the KakaoTalk bridge does not support the web-based login interface" "This instance of the KakaoTalk bridge does not support the web-based login interface"
) )
@ -40,6 +41,7 @@ forced_web_login = (
"This instance of the KakaoTalk bridge does not allow in-Matrix login. " "This instance of the KakaoTalk bridge does not allow in-Matrix login. "
"Please use [the web-based login interface]({url})." "Please use [the web-based login interface]({url})."
) )
"""
send_password = "Please send your password here to log in" send_password = "Please send your password here to log in"
missing_email = "Please use `$cmdprefix+sp login <email>` to log in here" missing_email = "Please use `$cmdprefix+sp login <email>` to log in here"
try_again_or_cancel = "Try again, or say `$cmdprefix+sp cancel` to give up." try_again_or_cancel = "Try again, or say `$cmdprefix+sp cancel` to give up."
@ -67,6 +69,7 @@ async def login(evt: CommandEvent) -> None:
"email": evt.args[0], "email": evt.args[0],
} }
""" TODO Implement web login
if evt.bridge.public_website: if evt.bridge.public_website:
external_url = URL(evt.config["appservice.public.external"]) external_url = URL(evt.config["appservice.public.external"])
token = sign_token( token = sign_token(
@ -87,6 +90,11 @@ async def login(evt: CommandEvent) -> None:
await evt.reply(f"{missing_email}. {web_unsupported}.") await evt.reply(f"{missing_email}. {web_unsupported}.")
else: else:
await evt.reply(f"{send_password}. {web_unsupported}.") await evt.reply(f"{send_password}. {web_unsupported}.")
"""
if not email:
await evt.reply(f"{missing_email}.")
else:
await evt.reply(f"{send_password}.")
async def enter_password(evt: CommandEvent) -> None: async def enter_password(evt: CommandEvent) -> None:

View File

@ -14,6 +14,7 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from mautrix.bridge.commands import HelpSection, command_handler from mautrix.bridge.commands import HelpSection, command_handler
from mautrix.types import SerializerError
from .typehint import CommandEvent from .typehint import CommandEvent
@ -53,24 +54,36 @@ async def disconnect(evt: CommandEvent) -> None:
needs_auth=True, needs_auth=True,
management_only=True, management_only=True,
help_section=SECTION_CONNECTION, help_section=SECTION_CONNECTION,
help_text="Check if you're logged into KakaoTalk & connected to chats", help_text="Check if you're logged into KakaoTalk and retrieve your account information",
) )
async def ping(evt: CommandEvent) -> None: async def whoami(evt: CommandEvent) -> None:
if not await evt.sender.is_logged_in():
await evt.reply("You're not logged into KakaoTalk")
return
await evt.mark_read() await evt.mark_read()
try: try:
own_info = await evt.sender.get_own_info() own_info = await evt.sender.get_own_info()
await evt.reply( await evt.reply(
f"You're logged in as {own_info.nickname} (user ID {evt.sender.ktid})." f"You're logged in as `{own_info.more.uuid}` (nickname: {own_info.more.nickName}, user ID: {evt.sender.ktid})."
"\n\n" )
f"You are {'connected to' if evt.sender.is_connected else '**disconnected** from'} KakaoTalk chats.\n\n" except SerializerError:
evt.sender.log.exception("Failed to deserialize settings struct")
await evt.reply(
f"You're logged in, but the bridge is unable to retrieve your profile information (user ID: {evt.sender.ktid})."
) )
except CommandException as e: except CommandException as e:
await evt.reply(f"Error from KakaoTalk: {e}") await evt.reply(f"Error from KakaoTalk: {e}")
@command_handler(
needs_auth=True,
management_only=True,
help_section=SECTION_CONNECTION,
help_text="Check if you're connected to KakaoTalk chats",
)
async def ping(evt: CommandEvent) -> None:
await evt.reply(
f"You are {'connected to' if evt.sender.is_connected else '**disconnected** from'} KakaoTalk chats."
)
@command_handler( @command_handler(
needs_auth=True, needs_auth=True,
management_only=True, management_only=True,

View File

@ -35,11 +35,13 @@ class Config(BaseBridgeConfig):
return [ return [
*super().forbidden_defaults, *super().forbidden_defaults,
ForbiddenDefault("appservice.database", "postgres://username:password@hostname/db"), ForbiddenDefault("appservice.database", "postgres://username:password@hostname/db"),
ForbiddenDefault( # TODO
"appservice.public.external", #ForbiddenDefault(
"https://example.com/public", # "appservice.public.external",
condition="appservice.public.enabled", # "https://example.com/public",
), # condition="appservice.public.enabled",
#),
#
ForbiddenDefault("bridge.permissions", ForbiddenKey("example.com")), ForbiddenDefault("bridge.permissions", ForbiddenKey("example.com")),
] ]
@ -50,6 +52,7 @@ class Config(BaseBridgeConfig):
copy("homeserver.asmux") copy("homeserver.asmux")
""" TODO
copy("appservice.public.enabled") copy("appservice.public.enabled")
copy("appservice.public.prefix") copy("appservice.public.prefix")
copy("appservice.public.external") copy("appservice.public.external")
@ -59,6 +62,7 @@ class Config(BaseBridgeConfig):
copy("appservice.public.shared_secret") copy("appservice.public.shared_secret")
copy("appservice.public.allow_matrix_login") copy("appservice.public.allow_matrix_login")
copy("appservice.public.segment_key") copy("appservice.public.segment_key")
"""
copy("metrics.enabled") copy("metrics.enabled")
copy("metrics.listen_port") copy("metrics.listen_port")

View File

@ -3,13 +3,12 @@ from mautrix.util.async_db import Database
from .message import Message from .message import Message
from .portal import Portal from .portal import Portal
from .puppet import Puppet from .puppet import Puppet
from .reaction import Reaction
from .upgrade import upgrade_table from .upgrade import upgrade_table
from .user import User from .user import User
def init(db: Database) -> None: def init(db: Database) -> None:
for table in (Portal, Message, Reaction, User, Puppet): for table in (Portal, Message, User, Puppet):
table.db = db table.db = db
@ -17,7 +16,6 @@ __all__ = [
"upgrade_table", "upgrade_table",
"init", "init",
"Message", "Message",
"Reaction",
"Portal", "Portal",
"ThreadType", "ThreadType",
"Puppet", "Puppet",

View File

@ -1,101 +0,0 @@
# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge.
# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar
from asyncpg import Record
from attr import dataclass
from mautrix.types import EventID, RoomID
from mautrix.util.async_db import Database
fake_db = Database.create("") if TYPE_CHECKING else None
@dataclass
class Reaction:
db: ClassVar[Database] = fake_db
mxid: EventID
mx_room: RoomID
kt_msgid: str
kt_receiver: int
kt_sender: int
reaction: str
@classmethod
def _from_row(cls, row: Record | None) -> Reaction | None:
if row is None:
return None
return cls(**row)
@classmethod
async def get_by_message_ktid(cls, kt_msgid: str, kt_receiver: int) -> dict[int, Reaction]:
q = (
"SELECT mxid, mx_room, kt_msgid, kt_receiver, kt_sender, reaction "
"FROM reaction WHERE kt_msgid=$1 AND kt_receiver=$2"
)
rows = await cls.db.fetch(q, kt_msgid, kt_receiver)
row_gen = (cls._from_row(row) for row in rows)
return {react.kt_sender: react for react in row_gen}
@classmethod
async def get_by_ktid(cls, kt_msgid: str, kt_receiver: int, kt_sender: int) -> Reaction | None:
q = (
"SELECT mxid, mx_room, kt_msgid, kt_receiver, kt_sender, reaction "
"FROM reaction WHERE kt_msgid=$1 AND kt_receiver=$2 AND kt_sender=$3"
)
row = await cls.db.fetchrow(q, kt_msgid, kt_receiver, kt_sender)
return cls._from_row(row)
@classmethod
async def get_by_mxid(cls, mxid: EventID, mx_room: RoomID) -> Reaction | None:
q = (
"SELECT mxid, mx_room, kt_msgid, kt_receiver, kt_sender, reaction "
"FROM reaction WHERE mxid=$1 AND mx_room=$2"
)
row = await cls.db.fetchrow(q, mxid, mx_room)
return cls._from_row(row)
@property
def _values(self):
return (
self.mxid,
self.mx_room,
self.kt_msgid,
self.kt_receiver,
self.kt_sender,
self.reaction,
)
async def insert(self) -> None:
q = (
"INSERT INTO reaction (mxid, mx_room, kt_msgid, kt_receiver, kt_sender, reaction) "
"VALUES ($1, $2, $3, $4, $5, $6)"
)
await self.db.execute(q, *self._values)
async def delete(self) -> None:
q = "DELETE FROM reaction WHERE kt_msgid=$1 AND kt_receiver=$2 AND kt_sender=$3"
await self.db.execute(q, self.kt_msgid, self.kt_receiver, self.kt_sender)
async def save(self) -> None:
q = (
"UPDATE reaction SET mxid=$1, mx_room=$2, reaction=$6 "
"WHERE kt_msgid=$3 AND kt_receiver=$4 AND kt_sender=$5"
)
await self.db.execute(q, *self._values)

View File

@ -85,15 +85,3 @@ async def create_v1_tables(conn: Connection) -> None:
UNIQUE (mxid, mx_room) UNIQUE (mxid, mx_room)
)""" )"""
) )
await conn.execute(
"""CREATE TABLE reaction (
mxid TEXT,
mx_room TEXT,
kt_msgid TEXT,
kt_receiver BIGINT,
kt_sender BIGINT,
reaction TEXT,
PRIMARY KEY (kt_msgid, kt_receiver, kt_sender),
UNIQUE (mxid, mx_room)
)"""
)

View File

@ -49,26 +49,6 @@ appservice:
min_size: 5 min_size: 5
max_size: 10 max_size: 10
# Public part of web server for out-of-Matrix interaction with the bridge.
# TODO Implement web login
public:
# Whether or not the public-facing endpoints should be enabled.
enabled: false
# The prefix to use in the public-facing endpoints.
prefix: /public
# The base URL where the public-facing endpoints are available. The prefix is not added
# implicitly.
external: https://example.com/public
# Shared secret for integration managers such as mautrix-manager.
# If set to "generate", a random string will be generated on the next startup.
# If null, integration manager access to the API will not be possible.
shared_secret: generate
# Allow logging in within Matrix. If false, users can only log in using the web interface.
allow_matrix_login: true
# Segment API key to enable analytics tracking for web server endpoints. Set to null to disable.
# Currently the only events are login start, success and fail.
segment_key: null
# The unique ID of this appservice. # The unique ID of this appservice.
id: kakaotalk id: kakaotalk
# Username of the appservice bot. # Username of the appservice bot.

View File

@ -23,7 +23,7 @@ if os.path.exists(".git") and shutil.which("git"):
git_revision = git_revision[:8] git_revision = git_revision[:8]
except (subprocess.SubprocessError, OSError): except (subprocess.SubprocessError, OSError):
git_revision = "unknown" git_revision = "unknown"
git_revision_url = "" git_revision_url = None
try: try:
git_tag = run(["git", "describe", "--exact-match", "--tags"]).strip().decode("ascii") git_tag = run(["git", "describe", "--exact-match", "--tags"]).strip().decode("ascii")
@ -31,7 +31,7 @@ if os.path.exists(".git") and shutil.which("git"):
git_tag = None git_tag = None
else: else:
git_revision = "unknown" git_revision = "unknown"
git_revision_url = "" git_revision_url = None
git_tag = None git_tag = None
git_tag_url = f"https://github.com/mautrix/facebook/releases/tag/{git_tag}" if git_tag else None git_tag_url = f"https://github.com/mautrix/facebook/releases/tag/{git_tag}" if git_tag else None

View File

@ -32,6 +32,7 @@ from aiohttp import ClientSession
from aiohttp.client import _RequestContextManager from aiohttp.client import _RequestContextManager
from yarl import URL from yarl import URL
from mautrix.types import SerializerError
from mautrix.util.logging import TraceLogger from mautrix.util.logging import TraceLogger
from ...config import Config from ...config import Config
@ -55,7 +56,12 @@ from ..types.request import (
CommandResultDoneValue CommandResultDoneValue
) )
from .types import PortalChannelInfo, UserInfoUnion, ChannelProps from .types import (
ChannelProps,
PortalChannelInfo,
SettingsStruct,
UserInfoUnion,
)
from .errors import InvalidAccessToken, CommandException from .errors import InvalidAccessToken, CommandException
from .error_helper import raise_unsuccessful_response from .error_helper import raise_unsuccessful_response
@ -197,17 +203,21 @@ class Client:
# region post-token commands # region post-token commands
async def start(self) -> ProfileStruct: async def start(self) -> SettingsStruct | None:
""" """
Initialize user-specific bridging & state by providing a token obtained from a prior login. Initialize user-specific bridging & state by providing a token obtained from a prior login.
Receive the user's profile info in response. Receive the user's profile info in response.
""" """
profile_req_struct = await self._api_user_request_result(ProfileReqStruct, "start") try:
settings_struct = await self._api_user_request_result(SettingsStruct, "start")
except SerializerError:
self.log.exception("Unable to deserialize settings struct, but starting client anyways")
settings_struct = None
if not self._rpc_disconnection_task: if not self._rpc_disconnection_task:
self._rpc_disconnection_task = asyncio.create_task(self._rpc_disconnection_handler()) self._rpc_disconnection_task = asyncio.create_task(self._rpc_disconnection_handler())
else: else:
self.log.warning("Called \"start\" on an already-started client") self.log.warning("Called \"start\" on an already-started client")
return profile_req_struct.profile return settings_struct
async def stop(self) -> None: async def stop(self) -> None:
"""Immediately stop bridging this user.""" """Immediately stop bridging this user."""
@ -246,6 +256,9 @@ class Client:
await self._rpc_client.request("disconnect", mxid=self.user.mxid) await self._rpc_client.request("disconnect", mxid=self.user.mxid)
await self._on_disconnect(None) await self._on_disconnect(None)
async def get_settings(self) -> SettingsStruct:
return await self._api_user_request_result(SettingsStruct, "get_settings")
async def get_own_profile(self) -> ProfileStruct: async def get_own_profile(self) -> ProfileStruct:
profile_req_struct = await self._api_user_request_result(ProfileReqStruct, "get_own_profile") profile_req_struct = await self._api_user_request_result(ProfileReqStruct, "get_own_profile")
return profile_req_struct.profile return profile_req_struct.profile

View File

@ -26,6 +26,7 @@ from mautrix.types import (
MessageType, MessageType,
) )
from ..types.api.struct import MoreSettingsStruct, LessSettingsStruct
from ..types.bson import Long from ..types.bson import Long
from ..types.channel.channel_info import NormalChannelInfo from ..types.channel.channel_info import NormalChannelInfo
from ..types.channel.channel_type import ChannelType from ..types.channel.channel_type import ChannelType
@ -34,6 +35,12 @@ from ..types.openlink.open_channel_info import OpenChannelInfo
from ..types.user.channel_user_info import NormalChannelUserInfo, OpenChannelUserInfo from ..types.user.channel_user_info import NormalChannelUserInfo, OpenChannelUserInfo
@dataclass
class SettingsStruct(SerializableAttrs):
more: MoreSettingsStruct
less: LessSettingsStruct
ChannelInfoUnion = NewType("ChannelInfoUnion", Union[NormalChannelInfo, OpenChannelInfo]) ChannelInfoUnion = NewType("ChannelInfoUnion", Union[NormalChannelInfo, OpenChannelInfo])
@deserializer(ChannelInfoUnion) @deserializer(ChannelInfoUnion)

View File

@ -14,7 +14,7 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
##from .login import * ##from .login import *
##from .account import * from .account import *
from .profile import * from .profile import *
from .friends import * from .friends import *
##from .openlink import * ##from .openlink import *

View File

@ -0,0 +1,141 @@
# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge.
# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Optional, Union
from attr import dataclass
from mautrix.types import SerializableAttrs
from ...bson import Long
@dataclass
class OpenChatSettingsStruct(SerializableAttrs):
chatMemberMaxJoin: int
chatRoomMaxJoin: int
createLinkLimit: 10;
createCardLinkLimit: 3;
numOfStaffLimit: 5;
rewritable: bool
handoverEnabled: bool
@dataclass(kw_only=True)
class MoreSettingsStruct(SerializableAttrs):
since: int
@dataclass
class ClientConf(SerializableAttrs):
osVersion: str
clientConf: ClientConf
available: int
available2: int
friendsPollingInterval: Optional[int] = None # NOTE Made optional
settingsPollingInterval: Optional[int] = None # NOTE Made optional
profilePollingInterval: Optional[int] = None # NOTE Made optional
moreListPollingInterval: Optional[int] = None # NOTE Made optional
morePayPollingInterval: Optional[int] = None # NOTE Made optional
daumMediaPollingInterval: Optional[int] = None # NOTE Made optional
lessSettingsPollingInterval: Optional[int] = None # NOTE Made optional
@dataclass
class MoreApps(SerializableAttrs):
recommend: Optional[list[str]] = None # NOTE From unknown[]
all: Optional[list[str]] = None # NOTE From unknown[]
moreApps: MoreApps
shortcuts: Optional[dict[str, int]] = None # NOTE Made optional
seasonProfileRev: int
seasonNoticeRev: int
serviceUserId: Union[Long, int]
accountId: int
accountDisplayId: str
hashedAccountId: str
pstnNumber: str
formattedPstnNumber: str
nsnNumber: str
formattedNsnNumber: str
contactNameSync: int
allowMigration: bool
emailStatus: int
emailAddress: str
emailVerified: bool
uuid: str
uuidSearchable: bool
nickName: str
openChat: OpenChatSettingsStruct
profileImageUrl: str
fullProfileImageUrl: str
originalProfileImageUrl: str
statusMessage: str
@dataclass(kw_only=True)
class LessSettingsStruct(SerializableAttrs):
kakaoAutoLoginDomain: list[str]
daumSsoDomain: list[str]
@dataclass
class GoogleMapsApi(SerializableAttrs):
key: str
signature: str
googleMapsApi: GoogleMapsApi
@dataclass
class ChatReportLimit(SerializableAttrs):
chat: int
open_chat: int
plus_chat: int
chat_report_limit: ChatReportLimit
externalApiList: str # NOTE From unknown
@dataclass
class BirthdayFriends(SerializableAttrs):
landing_url: str
birthday_friends: BirthdayFriends
messageDeleteTime: Optional[int] = None # NOTE Made optional
@dataclass
class VoiceTalk(SerializableAttrs):
groupCallMaxParticipants: int
voiceTalk: VoiceTalk
profileActions: bool
@dataclass
class PostExpirationSetting(SerializableAttrs):
flagOn: bool
newPostTerm: int
postExpirationSetting: PostExpirationSetting
kakaoAlertIds: list[int]
@dataclass
class LoginTokenStruct(SerializableAttrs):
token: str
expires: int
___all___ = [
"OpenChatSettingsStruct",
"MoreSettingsStruct",
"LessSettingsStruct",
"LoginTokenStruct",
]

View File

@ -22,11 +22,8 @@ from mautrix.types import (
Event, Event,
EventID, EventID,
EventType, EventType,
ReactionEvent,
ReactionEventContent,
ReceiptEvent, ReceiptEvent,
RedactionEvent, RedactionEvent,
RelationType,
RoomID, RoomID,
SingleReceiptEventContent, SingleReceiptEventContent,
UserID, UserID,
@ -122,6 +119,7 @@ class MatrixHandler(BaseMatrixHandler):
await portal.handle_matrix_redaction(user, event_id, redaction_event_id) await portal.handle_matrix_redaction(user, event_id, redaction_event_id)
""" TODO
@classmethod @classmethod
async def handle_reaction( async def handle_reaction(
cls, cls,
@ -147,6 +145,7 @@ class MatrixHandler(BaseMatrixHandler):
await portal.handle_matrix_reaction( await portal.handle_matrix_reaction(
user, event_id, content.relates_to.event_id, content.relates_to.key user, event_id, content.relates_to.event_id, content.relates_to.key
) )
"""
async def handle_read_receipt( async def handle_read_receipt(
self, self,
@ -170,6 +169,8 @@ class MatrixHandler(BaseMatrixHandler):
if evt.type == EventType.ROOM_REDACTION: if evt.type == EventType.ROOM_REDACTION:
evt: RedactionEvent evt: RedactionEvent
await self.handle_redaction(evt.room_id, evt.sender, evt.redacts, evt.event_id) await self.handle_redaction(evt.room_id, evt.sender, evt.redacts, evt.event_id)
""" TODO
elif evt.type == EventType.REACTION: elif evt.type == EventType.REACTION:
evt: ReactionEvent evt: ReactionEvent
await self.handle_reaction(evt.room_id, evt.sender, evt.event_id, evt.content) await self.handle_reaction(evt.room_id, evt.sender, evt.event_id, evt.content)
"""

View File

@ -934,10 +934,12 @@ class Portal(DBPortal, BasePortal):
raise NotImplementedError("Only message and reaction redactions are supported") raise NotImplementedError("Only message and reaction redactions are supported")
""" """
""" TODO
async def handle_matrix_reaction( async def handle_matrix_reaction(
self, sender: u.User, event_id: EventID, reacting_to: EventID, reaction: str self, sender: u.User, event_id: EventID, reacting_to: EventID, reaction: str
) -> None: ) -> None:
self.log.info("TODO: handle_matrix_reaction") pass
"""
async def handle_matrix_leave(self, user: u.User) -> None: async def handle_matrix_leave(self, user: u.User) -> None:
if self.is_direct: if self.is_direct:

View File

@ -25,6 +25,7 @@ from mautrix.types import (
JSON, JSON,
MessageType, MessageType,
RoomID, RoomID,
SerializerError,
TextMessageEventContent, TextMessageEventContent,
UserID, UserID,
) )
@ -38,7 +39,7 @@ from .db import User as DBUser
from .kt.client import Client from .kt.client import Client
from .kt.client.errors import AuthenticationRequired, ResponseError from .kt.client.errors import AuthenticationRequired, ResponseError
from .kt.types.api.struct.profile import ProfileStruct from .kt.client.types import SettingsStruct
from .kt.types.bson import Long from .kt.types.bson import Long
from .kt.types.channel.channel_info import ChannelInfo, NormalChannelInfo, NormalChannelData from .kt.types.channel.channel_info import ChannelInfo, NormalChannelInfo, NormalChannelData
from .kt.types.channel.channel_type import ChannelType, KnownChannelType from .kt.types.channel.channel_type import ChannelType, KnownChannelType
@ -96,7 +97,7 @@ class User(DBUser, BaseUser):
_db_instance: DBUser | None _db_instance: DBUser | None
_sync_lock: SimpleLock _sync_lock: SimpleLock
_is_rpc_reconnecting: bool _is_rpc_reconnecting: bool
_logged_in_info: ProfileStruct | None _logged_in_info: SettingsStruct | None
_logged_in_info_time: float _logged_in_info_time: float
def __init__( def __init__(
@ -253,9 +254,9 @@ class User(DBUser, BaseUser):
self.log.warning(f"UUID mismatch: expected {self.uuid}, got {oauth_credential.deviceUUID}") self.log.warning(f"UUID mismatch: expected {self.uuid}, got {oauth_credential.deviceUUID}")
self.uuid = oauth_credential.deviceUUID self.uuid = oauth_credential.deviceUUID
async def get_own_info(self) -> ProfileStruct: async def get_own_info(self) -> SettingsStruct:
if not self._logged_in_info or self._logged_in_info_time + 60 * 60 < time.monotonic(): if not self._logged_in_info or self._logged_in_info_time + 60 * 60 < time.monotonic():
self._logged_in_info = await self.client.get_own_profile() self._logged_in_info = await self.client.get_settings()
self._logged_in_info_time = time.monotonic() self._logged_in_info_time = time.monotonic()
return self._logged_in_info return self._logged_in_info
@ -272,7 +273,7 @@ class User(DBUser, BaseUser):
return False return False
client = Client(self, log=self.log.getChild("ktclient")) client = Client(self, log=self.log.getChild("ktclient"))
user_info = await client.start() user_info = await client.start()
# NOTE On failure, client.start throws instead of returning False # NOTE On failure, client.start throws instead of returning something falsy
self.log.info("Loaded session successfully") self.log.info("Loaded session successfully")
self.client = client self.client = client
self._logged_in_info = user_info self._logged_in_info = user_info
@ -303,6 +304,12 @@ class User(DBUser, BaseUser):
if self._is_logged_in is None or _override: if self._is_logged_in is None or _override:
try: try:
self._is_logged_in = bool(await self.get_own_info()) self._is_logged_in = bool(await self.get_own_info())
except SerializerError:
self.log.exception(
"Unable to deserialize settings struct, "
"but didn't get auth error, so treating user as logged in"
)
self._is_logged_in = True
except Exception: except Exception:
self.log.exception("Exception checking login status") self.log.exception("Exception checking login status")
self._is_logged_in = False self._is_logged_in = False

View File

@ -1 +0,0 @@
from .public import PublicBridgeWebsite

View File

@ -1,380 +0,0 @@
# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge.
# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
import asyncio
import json
import logging
import random
import string
import time
from aiohttp import web
import pkg_resources
from mautrix.types import UserID
from mautrix.util.signed_token import verify_token
from .. import puppet as pu, user as u
class InvalidTokenError(Exception):
pass
class PublicBridgeWebsite:
log: logging.Logger = logging.getLogger("mau.web.public")
app: web.Application
secret_key: str
shared_secret: str
ready_wait: asyncio.Future | None
def __init__(self, shared_secret: str, loop: asyncio.AbstractEventLoop) -> None:
self.app = web.Application()
self.ready_wait = loop.create_future()
self.secret_key = "".join(random.choices(string.ascii_lowercase + string.digits, k=64))
self.shared_secret = shared_secret
for path in (
"whoami",
"login",
"login/prepare",
"login/2fa",
"login/check_approved",
"login/approved",
"logout",
"disconnect",
"reconnect",
"refresh",
):
self.app.router.add_options(f"/api/{path}", self.login_options)
self.app.router.add_get("/api/whoami", self.status)
self.app.router.add_post("/api/login/prepare", self.login_prepare)
self.app.router.add_post("/api/login", self.login)
self.app.router.add_post("/api/login/2fa", self.login_2fa)
self.app.router.add_get("/api/login/check_approved", self.login_check_approved)
self.app.router.add_post("/api/login/approved", self.login_approved)
self.app.router.add_post("/api/logout", self.logout)
self.app.router.add_post("/api/disconnect", self.disconnect)
self.app.router.add_post("/api/reconnect", self.reconnect)
self.app.router.add_post("/api/refresh", self.refresh)
self.app.router.add_static(
"/", pkg_resources.resource_filename("matrix_appservice_kakaotalk.web", "static/")
)
def verify_token(self, token: str) -> UserID:
token = verify_token(self.secret_key, token)
if token:
if token.get("expiry", 0) < int(time.time()):
raise InvalidTokenError("Access token has expired")
return UserID(token.get("mxid"))
raise InvalidTokenError("Access token is invalid")
@property
def _acao_headers(self) -> dict[str, str]:
return {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Authorization, Content-Type",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
}
@property
def _headers(self) -> dict[str, str]:
return {
**self._acao_headers,
"Content-Type": "application/json",
}
async def login_options(self, _: web.Request) -> web.Response:
return web.Response(status=200, headers=self._headers)
async def check_token(self, request: web.Request) -> u.User | None:
if self.ready_wait:
await self.ready_wait
self.ready_wait = None
try:
token = request.headers["Authorization"]
token = token[len("Bearer ") :]
except KeyError:
raise web.HTTPBadRequest(
text='{"error": "Missing Authorization header"}', headers=self._headers
)
except IndexError:
raise web.HTTPBadRequest(
text='{"error": "Malformed Authorization header"}',
headers=self._headers,
)
if self.shared_secret and token == self.shared_secret:
try:
user_id = request.query["user_id"]
except KeyError:
raise web.HTTPBadRequest(
text='{"error": "Missing user_id query param"}',
headers=self._headers,
)
else:
try:
user_id = self.verify_token(token)
except InvalidTokenError as e:
raise web.HTTPForbidden(
text=json.dumps(
{"error": f"{e}, please request a new one from the bridge bot"}
),
headers=self._headers,
)
user = await u.User.get_by_mxid(user_id)
return user
async def status(self, request: web.Request) -> web.Response:
user = await self.check_token(request)
data = {
"permissions": user.permission_level,
"mxid": user.mxid,
"kakaotalk": None,
}
if user.client:
try:
info = await user.get_own_info()
except Exception:
# TODO do something?
self.log.warning(
"Exception while getting self from status endpoint", exc_info=True
)
else:
data["kakaotalk"] = info.serialize()
data["kakaotalk"]["connected"] = user.is_connected
data["kakaotalk"][
"device_displayname"
] = f"{user.state.device.manufacturer} {user.state.device.name}"
return web.json_response(data, headers=self._acao_headers)
async def login_prepare(self, request: web.Request) -> web.Response:
self.log.info("TODO")
"""
user = await self.check_token(request)
state = AndroidState()
state.generate(user.mxid)
api = AndroidAPI(state, log=user.log.getChild("login-api"))
user.command_status = {
"action": "Login",
"state": state,
"api": api,
}
try:
await api.mobile_config_sessionless()
except Exception as e:
self.log.exception(
f"Failed to get mobile_config_sessionless to prepare login for {user.mxid}"
)
return web.json_response({"error": str(e)}, headers=self._acao_headers, status=500)
return web.json_response(
{
"status": "login",
"password_encryption_key_id": state.session.password_encryption_key_id,
"password_encryption_pubkey": state.session.password_encryption_pubkey,
},
headers=self._acao_headers,
)
"""
async def login(self, request: web.Request) -> web.Response:
self.log.info("TODO")
"""
user = await self.check_token(request)
try:
data = await request.json()
except json.JSONDecodeError:
raise web.HTTPBadRequest(text='{"error": "Malformed JSON"}', headers=self._headers)
try:
email = data["email"]
except KeyError:
raise web.HTTPBadRequest(text='{"error": "Missing email"}', headers=self._headers)
try:
password = data["password"]
encrypted_password = None
except KeyError:
try:
encrypted_password = data["encrypted_password"]
password = None
except KeyError:
raise web.HTTPBadRequest(
text='{"error": "Missing password"}', headers=self._headers
)
if encrypted_password:
if not user.command_status or user.command_status["action"] != "Login":
raise web.HTTPBadRequest(
text='{"error": "No login in progress"}', headers=self._headers
)
state: AndroidState = user.command_status["state"]
api: AndroidAPI = user.command_status["api"]
else:
state = AndroidState()
state.generate(user.mxid)
api = AndroidAPI(state, log=user.log.getChild("login-api"))
await api.mobile_config_sessionless()
try:
self.log.debug(f"Logging in as {email} for {user.mxid}")
resp = await api.login(email, password=password, encrypted_password=encrypted_password)
self.log.debug(f"Got successful login response with UID {resp.uid} for {user.mxid}")
await user.on_logged_in(state)
return web.json_response({"status": "logged-in"}, headers=self._acao_headers)
except TwoFactorRequired as e:
self.log.debug(
f"Got 2-factor auth required login error with UID {e.uid} for {user.mxid}"
)
user.command_status = {
"action": "Login",
"state": state,
"api": api,
}
return web.json_response(
{
"status": "two-factor",
"error": e.data,
},
headers=self._acao_headers,
)
except OAuthException as e:
self.log.debug(f"Got OAuthException {e} for {user.mxid}")
return web.json_response({"error": str(e)}, headers=self._acao_headers, status=401)
"""
async def login_2fa(self, request: web.Request) -> web.Response:
self.log.info("TODO")
"""
user = await self.check_token(request)
if not user.command_status or user.command_status["action"] != "Login":
raise web.HTTPBadRequest(
text='{"error": "No login in progress"}', headers=self._headers
)
try:
data = await request.json()
except json.JSONDecodeError:
raise web.HTTPBadRequest(text='{"error": "Malformed JSON"}', headers=self._headers)
try:
email = data["email"]
code = data["code"]
except KeyError as e:
raise web.HTTPBadRequest(
text=json.dumps({"error": f"Missing key {e}"}), headers=self._headers
)
state: AndroidState = user.command_status["state"]
api: AndroidAPI = user.command_status["api"]
try:
self.log.debug(f"Sending 2-factor auth code for {user.mxid}")
resp = await api.login_2fa(email, code)
self.log.debug(
f"Got successful login response with UID {resp.uid} for {user.mxid}"
" after 2fa login"
)
await user.on_logged_in(state)
return web.json_response({"status": "logged-in"}, headers=self._acao_headers)
except IncorrectPassword:
self.log.debug(f"Got incorrect 2fa code error for {user.mxid}")
return web.json_response(
{
"error": "Incorrect two-factor authentication code",
"status": "incorrect-code",
},
headers=self._acao_headers,
status=401,
)
except OAuthException as e:
self.log.debug(f"Got OAuthException {e} for {user.mxid} in 2fa stage")
return web.json_response({"error": str(e)}, headers=self._acao_headers, status=401)
"""
async def login_approved(self, request: web.Request) -> web.Response:
self.log.info("TODO")
"""
user = await self.check_token(request)
if not user.command_status or user.command_status["action"] != "Login":
raise web.HTTPBadRequest(
text='{"error": "No login in progress"}', headers=self._headers
)
state: AndroidState = user.command_status["state"]
api: AndroidAPI = user.command_status["api"]
try:
self.log.debug(f"Trying to log in after approval for {user.mxid}")
resp = await api.login_approved()
self.log.debug(
f"Got successful login response with UID {resp.uid} for {user.mxid}"
" after approval login"
)
await user.on_logged_in(state)
return web.json_response({"status": "logged-in"}, headers=self._acao_headers)
except OAuthException as e:
self.log.debug(f"Got OAuthException {e} for {user.mxid} in checkpoint login stage")
return web.json_response({"error": str(e)}, headers=self._acao_headers, status=401)
"""
async def login_check_approved(self, request: web.Request) -> web.Response:
self.log.info("TODO")
"""
user = await self.check_token(request)
if not user.command_status or user.command_status["action"] != "Login":
raise web.HTTPBadRequest(
text='{"error": "No login in progress"}', headers=self._headers
)
api: AndroidAPI = user.command_status["api"]
approved = await api.check_approved_machine()
return web.json_response({"approved": approved}, headers=self._acao_headers)
"""
async def logout(self, request: web.Request) -> web.Response:
user = await self.check_token(request)
puppet = await pu.Puppet.get_by_ktid(user.ktid)
await user.logout()
if puppet.is_real_user:
await puppet.switch_mxid(None, None)
return web.json_response({}, headers=self._acao_headers)
async def disconnect(self, request: web.Request) -> web.Response:
user = await self.check_token(request)
if not user.is_connected:
raise web.HTTPBadRequest(
text='{"error": "User is not connected"}', headers=self._headers
)
user.mqtt.disconnect()
await user.listen_task
return web.json_response({}, headers=self._acao_headers)
async def reconnect(self, request: web.Request) -> web.Response:
user = await self.check_token(request)
if user.is_connected:
raise web.HTTPConflict(
text='{"error": "User is already connected"}', headers=self._headers
)
user.start_listen()
return web.json_response({}, headers=self._acao_headers)
async def refresh(self, request: web.Request) -> web.Response:
user = await self.check_token(request)
await user.refresh()
return web.json_response({}, headers=self._acao_headers)

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
var n=function(t,s,r,e){var u;s[0]=0;for(var h=1;h<s.length;h++){var p=s[h++],a=s[h]?(s[0]|=p?1:2,r[s[h++]]):s[++h];3===p?e[0]=a:4===p?e[1]=Object.assign(e[1]||{},a):5===p?(e[1]=e[1]||{})[s[++h]]=a:6===p?e[1][s[++h]]+=a+"":p?(u=t.apply(a,n(t,a,r,["",null])),e.push(u),a[0]?s[0]|=2:(s[h-2]=0,s[h]=u)):e.push(a)}return e},t=new Map;export default function(s){var r=t.get(this);return r||(r=new Map,t.set(this,r)),(r=n(this,r.get(s)||(r.set(s,r=function(n){for(var t,s,r=1,e="",u="",h=[0],p=function(n){1===r&&(n||(e=e.replace(/^\s*\n\s*|\s*\n\s*$/g,"")))?h.push(0,n,e):3===r&&(n||e)?(h.push(3,n,e),r=2):2===r&&"..."===e&&n?h.push(4,n,0):2===r&&e&&!n?h.push(5,0,!0,e):r>=5&&((e||!n&&5===r)&&(h.push(r,0,e,s),r=6),n&&(h.push(r,n,0,s),r=6)),e=""},a=0;a<n.length;a++){a&&(1===r&&p(),p(a));for(var l=0;l<n[a].length;l++)t=n[a][l],1===r?"<"===t?(p(),h=[h],r=3):e+=t:4===r?"--"===e&&">"===t?(r=1,e=""):e=t+e[0]:u?t===u?u="":e+=t:'"'===t||"'"===t?u=t:">"===t?(p(),r=1):r&&("="===t?(r=5,s=e,e=""):"/"===t&&(r<5||">"===n[a][l+1])?(p(),3===r&&(h=h[0]),r=h,(h=h[0]).push(2,0,r),r=0):" "===t||"\t"===t||"\n"===t||"\r"===t?(p(),r=2):e+=t),3===r&&"!--"===e&&(r=4,h=h[0])}return p(),h}(s)),r),arguments,[])).length>1?r:r[0]}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,2 +0,0 @@
/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}main{display:block}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details{display:block}summary{display:list-item}template{display:none}[hidden]{display:none}

File diff suppressed because one or more lines are too long

View File

@ -1,9 +0,0 @@
// From http://www-cs-students.stanford.edu/~tjw/jsbn/rng.js
// and http://www-cs-students.stanford.edu/~tjw/jsbn/prng4.js
// Copyright (c) 2005 Tom Wu
// http://www-cs-students.stanford.edu/~tjw/jsbn/LICENSE
function Arcfour(){this.i=0,this.j=0,this.S=new Array}function ARC4init(t){var r,n,i;for(r=0;r<256;++r)this.S[r]=r;for(n=0,r=0;r<256;++r)n=n+this.S[r]+t[r%t.length]&255,i=this.S[r],this.S[r]=this.S[n],this.S[n]=i;this.i=0,this.j=0}function ARC4next(){var t;return this.i=this.i+1&255,this.j=this.j+this.S[this.i]&255,t=this.S[this.i],this.S[this.i]=this.S[this.j],this.S[this.j]=t,this.S[t+this.S[this.i]&255]}function prng_newstate(){return new Arcfour}Arcfour.prototype.init=ARC4init,Arcfour.prototype.next=ARC4next;var rng_state,rng_pool,rng_pptr,rng_psize=256;function rng_seed_int(t){rng_pool[rng_pptr++]^=255&t,rng_pool[rng_pptr++]^=t>>8&255,rng_pool[rng_pptr++]^=t>>16&255,rng_pool[rng_pptr++]^=t>>24&255,rng_pptr>=rng_psize&&(rng_pptr-=rng_psize)}function rng_seed_time(){rng_seed_int((new Date).getTime())}if(null==rng_pool){var t;if(rng_pool=new Array,rng_pptr=0,window.crypto&&window.crypto.getRandomValues){var ua=new Uint8Array(32);for(window.crypto.getRandomValues(ua),t=0;t<32;++t)rng_pool[rng_pptr++]=ua[t]}if("Netscape"==navigator.appName&&navigator.appVersion<"5"&&window.crypto){var z=window.crypto.random(32);for(t=0;t<z.length;++t)rng_pool[rng_pptr++]=255&z.charCodeAt(t)}for(;rng_pptr<rng_psize;)t=Math.floor(65536*Math.random()),rng_pool[rng_pptr++]=t>>>8,rng_pool[rng_pptr++]=255&t;rng_pptr=0,rng_seed_time()}function rng_get_byte(){if(null==rng_state){for(rng_seed_time(),(rng_state=prng_newstate()).init(rng_pool),rng_pptr=0;rng_pptr<rng_pool.length;++rng_pptr)rng_pool[rng_pptr]=0;rng_pptr=0}return rng_state.next()}function rng_get_bytes(t){var r;for(r=0;r<t.length;++r)t[r]=rng_get_byte()}function SecureRandom(){}SecureRandom.prototype.nextBytes=rng_get_bytes;
export default SecureRandom

View File

@ -1,12 +0,0 @@
// From http://www-cs-students.stanford.edu/~tjw/jsbn/rsa.js
// Modified to use Uint8Array instead of UTF-8 strings in pkcs1pad2.
// Copyright (c) 2005 Tom Wu
// http://www-cs-students.stanford.edu/~tjw/jsbn/LICENSE
import BigInteger from "./jsbn.min.js"
import SecureRandom from "./rng.min.js"
function parseBigInt(n,t){return new BigInteger(n,t)}function linebrk(n,t){for(var e="",r=0;r+t<n.length;)e+=n.substring(r,r+t)+"\n",r+=t;return e+n.substring(r,n.length)}function byte2Hex(n){return n<16?"0"+n.toString(16):n.toString(16)}function pkcs1pad2(n,t){if(t<n.length+11)return alert("Message too long for RSA"),null;for(var e=new Array,r=n.length-1;r>=0&&t>0;)e[--t]=n[r--];e[--t]=0;for(var l=new SecureRandom,i=new Array;t>2;){for(i[0]=0;0==i[0];)l.nextBytes(i);e[--t]=i[0]}return e[--t]=2,e[--t]=0,new BigInteger(e)}function RSAKey(){this.n=null,this.e=0,this.d=null,this.p=null,this.q=null,this.dmp1=null,this.dmq1=null,this.coeff=null}function RSASetPublic(n,t){null!=n&&null!=t&&n.length>0&&t.length>0?(this.n=parseBigInt(n,16),this.e=parseInt(t,16)):alert("Invalid RSA public key")}function RSADoPublic(n){return n.modPowInt(this.e,this.n)}function RSAEncrypt(n){var t=pkcs1pad2(n,this.n.bitLength()+7>>3);if(null==t)return null;var e=this.doPublic(t);if(null==e)return null;var r=e.toString(16);return 0==(1&r.length)?r:"0"+r}RSAKey.prototype.doPublic=RSADoPublic,RSAKey.prototype.setPublic=RSASetPublic,RSAKey.prototype.encrypt=RSAEncrypt;
export default RSAKey

View File

@ -1,80 +0,0 @@
.loader {
color: #9b4dca;
font-size: 90px;
text-indent: -9999em;
overflow: hidden;
width: 1em;
height: 1em;
border-radius: 50%;
margin: 72px auto;
position: relative;
-webkit-transform: translateZ(0);
-ms-transform: translateZ(0);
transform: translateZ(0);
-webkit-animation: load6 1.7s infinite ease, round 1.7s infinite ease;
animation: load6 1.7s infinite ease, round 1.7s infinite ease;
}
@-webkit-keyframes load6 {
0% {
box-shadow: 0 -0.83em 0 -0.4em, 0 -0.83em 0 -0.42em, 0 -0.83em 0 -0.44em, 0 -0.83em 0 -0.46em, 0 -0.83em 0 -0.477em;
}
5%,
95% {
box-shadow: 0 -0.83em 0 -0.4em, 0 -0.83em 0 -0.42em, 0 -0.83em 0 -0.44em, 0 -0.83em 0 -0.46em, 0 -0.83em 0 -0.477em;
}
10%,
59% {
box-shadow: 0 -0.83em 0 -0.4em, -0.087em -0.825em 0 -0.42em, -0.173em -0.812em 0 -0.44em, -0.256em -0.789em 0 -0.46em, -0.297em -0.775em 0 -0.477em;
}
20% {
box-shadow: 0 -0.83em 0 -0.4em, -0.338em -0.758em 0 -0.42em, -0.555em -0.617em 0 -0.44em, -0.671em -0.488em 0 -0.46em, -0.749em -0.34em 0 -0.477em;
}
38% {
box-shadow: 0 -0.83em 0 -0.4em, -0.377em -0.74em 0 -0.42em, -0.645em -0.522em 0 -0.44em, -0.775em -0.297em 0 -0.46em, -0.82em -0.09em 0 -0.477em;
}
100% {
box-shadow: 0 -0.83em 0 -0.4em, 0 -0.83em 0 -0.42em, 0 -0.83em 0 -0.44em, 0 -0.83em 0 -0.46em, 0 -0.83em 0 -0.477em;
}
}
@keyframes load6 {
0% {
box-shadow: 0 -0.83em 0 -0.4em, 0 -0.83em 0 -0.42em, 0 -0.83em 0 -0.44em, 0 -0.83em 0 -0.46em, 0 -0.83em 0 -0.477em;
}
5%,
95% {
box-shadow: 0 -0.83em 0 -0.4em, 0 -0.83em 0 -0.42em, 0 -0.83em 0 -0.44em, 0 -0.83em 0 -0.46em, 0 -0.83em 0 -0.477em;
}
10%,
59% {
box-shadow: 0 -0.83em 0 -0.4em, -0.087em -0.825em 0 -0.42em, -0.173em -0.812em 0 -0.44em, -0.256em -0.789em 0 -0.46em, -0.297em -0.775em 0 -0.477em;
}
20% {
box-shadow: 0 -0.83em 0 -0.4em, -0.338em -0.758em 0 -0.42em, -0.555em -0.617em 0 -0.44em, -0.671em -0.488em 0 -0.46em, -0.749em -0.34em 0 -0.477em;
}
38% {
box-shadow: 0 -0.83em 0 -0.4em, -0.377em -0.74em 0 -0.42em, -0.645em -0.522em 0 -0.44em, -0.775em -0.297em 0 -0.46em, -0.82em -0.09em 0 -0.477em;
}
100% {
box-shadow: 0 -0.83em 0 -0.4em, 0 -0.83em 0 -0.42em, 0 -0.83em 0 -0.44em, 0 -0.83em 0 -0.46em, 0 -0.83em 0 -0.477em;
}
}
@-webkit-keyframes round {
0% {
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
@keyframes round {
0% {
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}

View File

@ -1,44 +0,0 @@
<!--
matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge.
Copyright (C) 2021 Tulir Asokan
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>matrix-appservice-kakaotalk login</title>
<link rel="modulepreload" href="lib/jsbn.min.js"/>
<link rel="modulepreload" href="lib/rng.min.js"/>
<link rel="modulepreload" href="lib/rsa.min.js"/>
<link rel="modulepreload" href="lib/asn1hex-1.1.min.js"/>
<link rel="modulepreload" href="lib/preact-10.5.12.min.js"/>
<link rel="modulepreload" href="lib/htm-3.0.4.min.js"/>
<link rel="modulepreload" href="login/crypto.js"/>
<link rel="modulepreload" href="login/api.js"/>
<link rel="stylesheet" href="lib/normalize-8.0.1.min.css"/>
<link rel="stylesheet" href="lib/milligram-1.4.1.min.css"/>
<link rel="stylesheet" href="lib/spinner.css"/>
<link rel="stylesheet" href="login/index.css"/>
<script src="login/app.js" type="module"></script>
<script nomodule>document.body.innerText = "This login page requires modern JavaScript"</script>
</head>
<body>
<noscript>This login page requires JavaScript</noscript>
</body>
</html>

View File

@ -1,62 +0,0 @@
// matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge.
// Copyright (C) 2021 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import encryptPassword from "./crypto.js"
const apiToken = location.hash.slice(1)
const headers = { Authorization: `Bearer ${apiToken}` }
const jsonHeaders = { ...headers, "Content-Type": "application/json" }
const fetchParams = { headers }
export async function whoami() {
const resp = await fetch("api/whoami", fetchParams)
return await resp.json()
}
export async function prepareLogin() {
const resp = await fetch("api/login/prepare", { ...fetchParams, method: "POST" })
return await resp.json()
}
export async function login(pubkey, keyID, email, password) {
const resp = await fetch("api/login", {
method: "POST",
body: JSON.stringify({
email,
encrypted_password: await encryptPassword(pubkey, keyID, password),
}),
headers: jsonHeaders,
})
return await resp.json()
}
export async function login2FA(email, code) {
const resp = await fetch("api/login/2fa", {
method: "POST",
body: JSON.stringify({ email, code }),
headers: jsonHeaders,
})
return await resp.json()
}
export async function loginApproved() {
const resp = await fetch("api/login/approved", { method: "POST", headers })
return await resp.json()
}
export async function wasLoginApproved() {
const resp = await fetch("api/login/check_approved", fetchParams)
return (await resp.json()).approved
}

View File

@ -1,200 +0,0 @@
// matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge.
// Copyright (C) 2021 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { h, Component, render } from "../lib/preact-10.5.12.min.js"
import htm from "../lib/htm-3.0.4.min.js"
import * as api from "./api.js"
const html = htm.bind(h)
class App extends Component {
constructor(props) {
super(props)
this.approveCheckInterval = null
this.state = {
loading: true,
submitting: false,
error: null,
mxid: null,
facebook: null,
status: "pre-login",
pubkey: null,
keyID: null,
email: "",
password: "",
twoFactorCode: "",
twoFactorInfo: {},
}
}
async componentDidMount() {
const { error, mxid, facebook } = await api.whoami()
if (error) {
this.setState({ error, loading: false })
} else {
this.setState({ mxid, facebook, loading: false })
}
}
checkLoginApproved = async () => {
if (!await api.wasLoginApproved()) {
return
}
clearInterval(this.approveCheckInterval)
this.approveCheckInterval = null
const resp = await api.loginApproved()
if (resp.status === "logged-in") {
this.setState({ status: resp.status })
}
}
submitNoDefault = evt => {
evt.preventDefault()
this.submit()
}
async submit() {
if (this.approveCheckInterval) {
clearInterval(this.approveCheckInterval)
this.approveCheckInterval = null
}
this.setState({ submitting: true })
let resp
switch (this.state.status) {
case "pre-login":
resp = await api.prepareLogin()
break
case "login":
resp = await api.login(this.state.pubkey, this.state.keyID,
this.state.email, this.state.password)
break
case "two-factor":
resp = await api.login2FA(this.state.email, this.state.twoFactorCode)
break
}
const stateUpdate = { submitting: false }
if (typeof resp.error === "string") {
stateUpdate.error = resp.error
} else {
stateUpdate.status = resp.status
}
if (resp.password_encryption_key_id) {
stateUpdate.pubkey = resp.password_encryption_pubkey
stateUpdate.keyID = resp.password_encryption_key_id
}
if (resp.status === "two-factor") {
this.approveCheckInterval = setInterval(this.checkLoginApproved, 5000)
stateUpdate.twoFactorInfo = resp.error
} else if (resp.status === "logged-in") {
api.whoami().then(({ facebook }) => this.setState({ facebook }))
}
this.setState(stateUpdate)
}
fieldChange = evt => {
this.setState({ [evt.target.id]: evt.target.value })
}
renderFields() {
switch (this.state.status) {
case "pre-login":
return null
case "login":
return html`
<label for="email">Email</label>
<input type="email" placeholder="user@example.com" id="email"
value=${this.state.email} onChange=${this.fieldChange}/>
<label for="password">Password</label>
<input type="password" placeholder="correct horse battery staple" id="password"
value=${this.state.password} onChange=${this.fieldChange}/>
`
case "two-factor":
return html`
<p>${this.state.twoFactorInfo.error_user_msg}</p>
<label for="email">Email</label>
<input type="email" placeholder="user@example.com" id="email" disabled
value=${this.state.email} onChange=${this.fieldChange}/>
<label for="twoFactorCode">Two-factor authentication code</label>
<input type="number" placeholder="123456" id="twoFactorCode"
value=${this.state.twoFactorCode} onChange=${this.fieldChange}/>
`
}
}
submitButtonText() {
switch (this.state.status) {
case "pre-login":
return "Start"
case "login":
case "two-factor":
return "Sign in"
}
}
renderContent() {
if (this.state.loading) {
return html`
<div class="loader">Loading...</div>
`
} else if (this.state.status === "logged-in") {
if (this.state.facebook) {
return html`
Successfully logged in as ${this.state.facebook.name}. The bridge will appear
as ${this.state.facebook.device_displayname} in Facebook security settings.
`
}
return html`
Successfully logged in
`
} else if (this.state.facebook) {
return html`
You're already logged in as ${this.state.facebook.name}. The bridge appears
as ${this.state.facebook.device_displayname} in Facebook security settings.
`
}
return html`
${this.state.error && html`
<div class="error button" disabled>${this.state.error}</div>
`}
<form onSubmit=${this.submitNoDefault}>
<fieldset>
<label for="mxid">Matrix user ID</label>
<input type="text" placeholder="@user:example.com" id="mxid"
value=${this.state.mxid} disabled/>
${this.renderFields()}
<button type="submit" disabled=${this.state.submitting}>
${this.state.submitting
? "Loading..."
: this.submitButtonText()}
</button>
</fieldset>
</form>
`
}
render() {
return html`
<main>
<h1>matrix-appservice-kakaotalk login</h1>
${this.renderContent()}
</main>
`
}
}
render(html`
<${App}/>
`, document.body)

View File

@ -1,112 +0,0 @@
// matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge.
// Copyright (C) 2021 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// We have to use this pure-js RSA implementation because SubtleCrypto dropped PKCS#1 v1.5 support.
import RSAKey from "../lib/rsa.min.js"
import ASN1HEX from "../lib/asn1hex-1.1.min.js"
function pemToHex(pem) {
// Strip pem header
pem = pem.replace("-----BEGIN PUBLIC KEY-----", "")
pem = pem.replace("-----END PUBLIC KEY-----", "")
// Convert base64 to hex
const raw = atob(pem)
let result = ""
for (let i = 0; i < raw.length; i++) {
const hex = raw.charCodeAt(i).toString(16)
result += (hex.length === 2 ? hex : "0" + hex)
}
return result.toLowerCase()
}
function getKey(pem) {
const keyHex = pemToHex(pem)
if (ASN1HEX.isASN1HEX(keyHex) === false) {
throw new Error("key is not ASN.1 hex string")
} else if (ASN1HEX.getVbyList(keyHex, 0, [0, 0], "06") !== "2a864886f70d010101") {
throw new Error("not PKCS8 RSA key")
} else if (ASN1HEX.getTLVbyListEx(keyHex, 0, [0, 0]) !== "06092a864886f70d010101") {
throw new Error("not PKCS8 RSA public key")
}
const p5hex = ASN1HEX.getTLVbyListEx(keyHex, 0, [1, 0])
if (ASN1HEX.isASN1HEX(p5hex) === false) {
throw new Error("keyHex is not ASN.1 hex string")
}
const aIdx = ASN1HEX.getChildIdx(p5hex, 0)
if (aIdx.length !== 2 || p5hex.substr(aIdx[0], 2) !== "02" || p5hex.substr(aIdx[1], 2) !== "02") {
throw new Error("wrong hex for PKCS#5 public key")
}
const hN = ASN1HEX.getV(p5hex, aIdx[0])
const hE = ASN1HEX.getV(p5hex, aIdx[1])
const key = new RSAKey()
key.setPublic(hN, hE)
return key
}
// encryptPassword encrypts a login password using AES-256-GCM, then encrypts the AES key
// for Facebook's RSA-2048 key using PKCS#1 v1.5 padding.
//
// See https://github.com/mautrix/facebook/blob/v0.3.0/maufbapi/http/login.py#L164-L192
// for the Python implementation of the same encryption protocol.
async function encryptPassword(pubkey, keyID, password) {
// Key and IV for AES encryption
const aesKey = await crypto.subtle.generateKey({
name: "AES-GCM",
length: 256,
}, true, ["encrypt", "decrypt"])
const aesIV = crypto.getRandomValues(new Uint8Array(12))
// Get the actual bytes of the AES key
const aesKeyBytes = await crypto.subtle.exportKey("raw", aesKey)
// Encrypt AES key with Facebook's RSA public key.
const rsaKey = getKey(pubkey)
const encryptedAESKeyHex = rsaKey.encrypt(new Uint8Array(aesKeyBytes))
const encryptedAESKey = new Uint8Array(encryptedAESKeyHex.match(/[0-9A-Fa-f]{2}/g).map(h => parseInt(h, 16)))
const encoder = new TextEncoder()
const time = Math.floor(Date.now() / 1000)
// Encrypt the password. The result includes the ciphertext and AES MAC auth tag.
const encryptedPasswordBuffer = await crypto.subtle.encrypt({
name: "AES-GCM",
iv: aesIV,
// Add the current time to the additional authenticated data (AAD) section
additionalData: encoder.encode(time.toString()),
tagLength: 128,
}, aesKey, encoder.encode(password))
// SubtleCrypto returns the auth tag and ciphertext in the wrong order,
// so we have to flip them around.
const authTag = new Uint8Array(encryptedPasswordBuffer.slice(-16))
const encryptedPassword = new Uint8Array(encryptedPasswordBuffer.slice(0, -16))
const payload = new Uint8Array(2 + aesIV.byteLength + 2 + encryptedAESKey.byteLength + authTag.byteLength + encryptedPassword.byteLength)
// 1 is presumably the version
payload[0] = 1
payload[1] = keyID
payload.set(aesIV, 2)
// Length of the encrypted AES key as a little-endian 16-bit int
payload[aesIV.byteLength + 2] = encryptedAESKey.byteLength & (1 << 8)
payload[aesIV.byteLength + 3] = encryptedAESKey.byteLength >> 8
payload.set(encryptedAESKey, 4 + aesIV.byteLength)
payload.set(authTag, 4 + aesIV.byteLength + encryptedAESKey.byteLength)
payload.set(encryptedPassword, 4 + aesIV.byteLength + encryptedAESKey.byteLength + authTag.byteLength)
return `#PWD_MSGR:1:${time}:${btoa(String.fromCharCode(...payload))}`
}
export default encryptPassword

View File

@ -1,10 +0,0 @@
.error {
background-color: darkred !important;
border-color: darkred !important;
opacity: 1 !important;
}
main {
max-width: 50rem;
margin: 2rem auto 0;
}

12
node/package-lock.json generated
View File

@ -1792,9 +1792,9 @@
} }
}, },
"node_modules/minimist": { "node_modules/minimist": {
"version": "1.2.5", "version": "1.2.6",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz",
"integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==",
"dev": true "dev": true
}, },
"node_modules/ms": { "node_modules/ms": {
@ -3848,9 +3848,9 @@
} }
}, },
"minimist": { "minimist": {
"version": "1.2.5", "version": "1.2.6",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz",
"integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==",
"dev": true "dev": true
}, },
"ms": { "ms": {

View File

@ -460,8 +460,7 @@ export default class PeerClient {
*/ */
userStart = async (req) => { userStart = async (req) => {
const userClient = this.#tryGetUser(req.mxid) || await UserClient.create(req.mxid, req.oauth_credential, this) const userClient = this.#tryGetUser(req.mxid) || await UserClient.create(req.mxid, req.oauth_credential, this)
// TODO Should call requestMore/LessSettings instead const res = await this.#getSettings(userClient.serviceClient)
const res = await userClient.serviceClient.requestMyProfile()
if (res.success) { if (res.success) {
this.userClients.set(req.mxid, userClient) this.userClients.set(req.mxid, userClient)
} }
@ -496,7 +495,31 @@ export default class PeerClient {
/** /**
* @param {Object} req * @param {Object} req
* @param {?string} req.mxid * @param {string} req.mxid
*/
getSettings = async (req) => {
return await this.#getSettings(this.#getUser(req.mxid).serviceClient)
}
/**
* @param {ServiceApiClient} serviceClient
*/
#getSettings = async (serviceClient) => {
const moreRes = await serviceClient.requestMoreSettings()
if (!moreRes.success) return moreRes
const lessRes = await serviceClient.requestLessSettings()
if (!lessRes.success) return lessRes
return makeCommandResult({
more: moreRes.result,
less: lessRes.result,
})
}
/**
* @param {Object} req
* @param {string} req.mxid
*/ */
getOwnProfile = async (req) => { getOwnProfile = async (req) => {
return await this.#getUser(req.mxid).serviceClient.requestMyProfile() return await this.#getUser(req.mxid).serviceClient.requestMyProfile()
@ -504,7 +527,15 @@ export default class PeerClient {
/** /**
* @param {Object} req * @param {Object} req
* @param {?string} req.mxid * @param {string} req.mxid
*/
getOwnProfile = async (req) => {
return await this.#getUser(req.mxid).serviceClient.requestMyProfile()
}
/**
* @param {Object} req
* @param {string} req.mxid
* @param {Long} req.user_id * @param {Long} req.user_id
*/ */
getProfile = async (req) => { getProfile = async (req) => {
@ -522,7 +553,7 @@ export default class PeerClient {
const res = await talkChannel.updateAll() const res = await talkChannel.updateAll()
if (!res.success) return res if (!res.success) return res
return this.#makeCommandResult({ return makeCommandResult({
name: talkChannel.getDisplayName(), name: talkChannel.getDisplayName(),
participants: Array.from(talkChannel.getAllUserInfo()), participants: Array.from(talkChannel.getAllUserInfo()),
// TODO Image // TODO Image
@ -574,7 +605,7 @@ export default class PeerClient {
const res = await this.#getUser(req.mxid).serviceClient.findFriendById(req.friend_id) const res = await this.#getUser(req.mxid).serviceClient.findFriendById(req.friend_id)
if (!res.success) return res if (!res.success) return res
return this.#makeCommandResult(res.result.friend[propertyName]) return makeCommandResult(res.result.friend[propertyName])
} }
/** /**
@ -662,14 +693,6 @@ export default class PeerClient {
}) })
} }
#makeCommandResult(result) {
return {
success: true,
status: 0,
result: result
}
}
handleUnknownCommand = () => { handleUnknownCommand = () => {
throw new Error("Unknown command") throw new Error("Unknown command")
} }
@ -735,6 +758,7 @@ export default class PeerClient {
stop: this.userStop, stop: this.userStop,
connect: this.handleConnect, connect: this.handleConnect,
disconnect: this.handleDisconnect, disconnect: this.handleDisconnect,
get_settings: this.getSettings,
get_own_profile: this.getOwnProfile, get_own_profile: this.getOwnProfile,
get_profile: this.getProfile, get_profile: this.getProfile,
get_portal_channel_info: this.getPortalChannelInfo, get_portal_channel_info: this.getPortalChannelInfo,
@ -790,6 +814,17 @@ export default class PeerClient {
} }
/**
* @param {object} result
*/
function makeCommandResult(result) {
return {
success: true,
status: 0,
result: result
}
}
/** /**
* @param {TalkChannelList} channelList * @param {TalkChannelList} channelList
* @param {ChannelType} channelType * @param {ChannelType} channelType

View File

@ -51,7 +51,7 @@ setuptools.setup(
python_requires="~=3.8", python_requires="~=3.8",
classifiers=[ classifiers=[
"Development Status :: 1 - Planning", "Development Status :: 4 - Beta",
"License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)", "License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
"Topic :: Communications :: Chat", "Topic :: Communications :: Chat",
"Framework :: AsyncIO", "Framework :: AsyncIO",

View File

@ -0,0 +1,18 @@
[Unit]
Description=Node backend for matrix-appservice-kakaotalk
After=multi-user.target network.target
[Service]
; User=matrix-appservice-kakaotalk
; Group=matrix-appservice-kakaotalk
Type=notify
NotifyAccess=all
WorkingDirectory=/opt/matrix-appservice-kakaotalk/node
ConfigurationDirectory=matrix-appservice-kakaotalk
RuntimeDirectory=matrix-appservice-kakaotalk
ExecStart=/usr/bin/env node src/main.js --config ${CONFIGURATION_DIRECTORY}/node-config.json
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,16 @@
[Unit]
Description=matrix-appservice-kakaotalk bridge
After=multi-user.target network.target
[Service]
; User=matrix-appservice-kakaotalk
; Group=matrix-appservice-kakaotalk
WorkingDirectory=/opt/matrix-appservice-kakaotalk
ConfigurationDirectory=matrix-appservice-kakaotalk
RuntimeDirectory=matrix-appservice-kakaotalk
ExecStart=/opt/matrix-appservice-kakaotalk/.venv/bin/python -m matrix_appservice_kakaotalk -c ${CONFIGURATION_DIRECTORY}/config.yaml
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target