// 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 . import { Long } from "bson" import { AuthApiClient, OAuthApiClient, ServiceApiClient, TalkClient, KnownAuthStatusCode, util, } from "node-kakao" import chat from "node-kakao/chat" const { KnownChatType } = chat /** @typedef {import("node-kakao").OAuthCredential} OAuthCredential */ /** @typedef {import("./clientmanager.js").default} ClientManager} */ import { emitLines, promisify } from "./util.js" class UserClient { static #initializing = false #talkClient = new TalkClient() get talkClient() { return this.#talkClient } /** @type {ServiceApiClient} */ #serviceClient get serviceClient() { return this.#serviceClient } /** * DO NOT CONSTRUCT DIRECTLY. Callers should use {@link UserClient#create} instead. * @param {string} mxid * @param {OAuthCredential} credential */ constructor(mxid, credential) { if (!UserClient.#initializing) { throw new Error("Private constructor") } UserClient.#initializing = false this.mxid = mxid this.credential = credential } /** * @param {string} mxid The ID of the associated Matrix user * @param {OAuthCredential} credential The tokens that API calls may use */ static async create(mxid, credential) { this.#initializing = true const userClient = new UserClient(mxid, credential) userClient.#serviceClient = await ServiceApiClient.create(credential) return userClient } close() { this.#talkClient.close() } /** * TODO Maybe use a "write" method instead * @param {string} command */ getCmd(command) { return `${command}:${this.mxid}` } } export default class PeerClient { /** * @param {ClientManager} manager * @param {import("net").Socket} socket * @param {number} connID * @param {Map} userClients */ constructor(manager, socket, connID) { this.manager = manager this.socket = socket this.connID = connID this.stopped = false this.notificationID = 0 this.maxCommandID = 0 this.peerID = null this.userClients = new Map() } log(...text) { if (this.peerID) { console.log(`[API/${this.peerID}/${this.connID}]`, ...text) } else { console.log(`[API/${this.connID}]`, ...text) } } error(...text) { if (this.peerID) { console.error(`[API/${this.peerID}/${this.connID}]`, ...text) } else { console.error(`[API/${this.connID}]`, ...text) } } start() { this.log("Received connection", this.connID) emitLines(this.socket) this.socket.on("line", line => this.handleLine(line) .catch(err => this.log("Error handling line:", err))) this.socket.on("end", this.handleEnd) setTimeout(() => { if (!this.peerID && !this.stopped) { this.log("Didn't receive register request within 3 seconds, terminating") this.stop("Register request timeout") } }, 3000) } async stop(error = null) { if (this.stopped) { return } this.stopped = true this.#closeUsers() try { await this.#write({ id: --this.notificationID, command: "quit", error }) await promisify(cb => this.socket.end(cb)) } catch (err) { this.error("Failed to end connection:", err) this.socket.destroy(err) } } handleEnd = () => { this.stopped = true this.#closeUsers() if (this.peerID && this.manager.clients.get(this.peerID) === this) { this.manager.clients.delete(this.peerID) } this.log(`Connection closed (peer: ${this.peerID})`) } #closeUsers() { this.log("Closing all API clients for", this.peerID) for (const userClient of this.userClients.values()) { userClient.close() } this.userClients.clear() } /** * Write JSON data to the socket. * * @param {object} data - The data to write. * @returns {Promise} */ #write(data) { return promisify(cb => this.socket.write(JSON.stringify(data, this.#writeReplacer) + "\n", cb)) } /** * @param {Object} req * @param {string} req.passcode * @param {string} req.uuid * @param {Object} req.form */ registerDevice = async (req) => { // TODO Look for a deregister API call const authClient = await this.#createAuthClient(req.uuid) return await authClient.registerDevice(req.form, req.passcode, true) } /** * Log in. If this fails due to not having a device, also request a device passcode. * @param {Object} req * @param {string} req.uuid * @param {Object} req.form * @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, true) if (loginRes.status === KnownAuthStatusCode.DEVICE_NOT_REGISTERED) { const passcodeRes = await authClient.requestPasscode(req.form) if (!passcodeRes.success) { loginRes.status = passcodeRes.status } } return loginRes } /** * TODO Consider caching per-user * @param {string} uuid */ async #createAuthClient(uuid) { return await AuthApiClient.create("KakaoTalk Bridge", uuid) } // TODO Wrapper for per-user commands /** * Checked lookup of a UserClient for a given mxid. * @param {string} mxid * @returns {UserClient} */ #getUser(mxid) { const userClient = this.userClients.get(mxid) if (userClient === undefined) { throw new Error(`Could not find user ${mxid}`) } return userClient } /** * Unchecked lookup of a UserClient for a given mxid. * @param {string} mxid * @returns {UserClient | undefined} */ #tryGetUser(mxid) { return this.userClients.get(mxid) } /** * Get the service client for the specified user ID, or create * and return a new service client if no user ID is provided. * @param {string} mxid * @param {OAuthCredential} oauth_credential */ async #getServiceClient(mxid, oauth_credential) { return this.#tryGetUser(mxid)?.serviceClient || await ServiceApiClient.create(oauth_credential) } /** * @param {Object} req * @param {OAuthCredential} req.oauth_credential */ handleRenew = async (req) => { const oAuthClient = await OAuthApiClient.create() return await oAuthClient.renew(req.oauth_credential) } /** * @param {Object} req * @param {string} req.mxid * @param {OAuthCredential} req.oauth_credential */ handleConnect = async (req) => { // TODO Don't re-login if possible. But must still return a LoginResult! this.handleDisconnect(req) const userClient = await UserClient.create(req.mxid, req.oauth_credential) const res = await userClient.talkClient.login(req.oauth_credential) if (!res.success) return res this.userClients.set(req.mxid, userClient) userClient.talkClient.on("chat", (data, channel) => { this.log(`Received message ${data.chat.logId} in channel ${channel.channelId}`) return this.#write({ id: --this.notificationID, command: userClient.getCmd("message"), //is_sequential: true, // TODO Make sequential per user & channel (if it isn't already) chatlog: data.chat, channelId: channel.channelId, channelType: channel.info.type, }) }) /* TODO Many more listeners userClient.talkClient.on("chat_read", (chat, channel, reader) => { this.log(`chat_read in channel ${channel.channelId}`) //chat.logId }) */ return res } /** * @param {Object} req * @param {string} req.mxid */ handleDisconnect = (req) => { const userClient = this.#tryGetUser(req.mxid) if (!!userClient) { userClient.close() this.userClients.delete(req.mxid) return true } else { return false } } /** * @param {Object} req * @param {string} req.mxid * @param {OAuthCredential} req.oauth_credential */ getOwnProfile = async (req) => { const serviceClient = await this.#getServiceClient(req.mxid, req.oauth_credential) return await serviceClient.requestMyProfile() } /** * @param {Object} req * @param {string} req.mxid * @param {OAuthCredential} req.oauth_credential * @param {Long} req.user_id */ getProfile = async (req) => { const serviceClient = await this.#getServiceClient(req.mxid, req.oauth_credential) return await serviceClient.requestProfile(req.user_id) } /** * @param {Object} req * @param {string} req.mxid * @param {Long} req.channel_id */ getPortalChannelInfo = async (req) => { const userClient = this.#getUser(req.mxid) const talkChannel = userClient.talkClient.channelList.get(req.channel_id) const res = await talkChannel.updateAll() if (!res.success) return res return this.#makeCommandResult({ name: talkChannel.getDisplayName(), participants: Array.from(talkChannel.getAllUserInfo()), // TODO Image }) } /** * @param {Object} req * @param {string} req.mxid * @param {Long} req.channel_id * @param {Long?} req.sync_from * @param {Number?} req.limit */ getChats = async (req) => { const userClient = this.#getUser(req.mxid) const talkChannel = userClient.talkClient.channelList.get(req.channel_id) const res = await talkChannel.getChatListFrom(req.sync_from) if (res.success && 0 < req.limit && req.limit < res.result.length) { res.result.splice(0, res.result.length - req.limit) } return res } /** * @param {Object} req * @param {string} req.mxid * @param {Long} req.channel_id * @param {string} req.text */ sendMessage = async (req) => { const userClient = this.#getUser(req.mxid) const talkChannel = userClient.talkClient.channelList.get(req.channel_id) return await talkChannel.sendChat({ type: KnownChatType.TEXT, text: req.text, }) } #makeCommandResult(result) { return { success: true, status: 0, result: result } } handleUnknownCommand = () => { throw new Error("Unknown command") } /** * @param {Object} req * @param {string} req.peer_id */ handleRegister = async (req) => { this.peerID = req.peer_id this.log(`Registered socket ${this.connID} -> ${this.peerID}`) if (this.manager.clients.has(this.peerID)) { const oldClient = this.manager.clients.get(this.peerID) this.log(`Terminating previous socket ${oldClient.connID} for ${this.peerID}`) await oldClient.stop("Socket replaced by new connection") } this.manager.clients.set(this.peerID, this) return { client_exists: this.authClient !== null } } async handleLine(line) { if (this.stopped) { this.log("Ignoring line, client is stopped") return } let req try { req = JSON.parse(line, this.#readReviver) } catch (err) { this.log("Non-JSON request:", line) return } if (!req.command || !req.id) { this.log("Invalid request:", line) return } if (req.id <= this.maxCommandID) { this.log("Ignoring old request", req.id) return } this.maxCommandID = req.id let handler if (!this.peerID) { if (req.command !== "register") { this.log("First request wasn't a register request, terminating") await this.stop("Invalid first request") return } else if (!req.peer_id) { this.log("Register request didn't contain ID, terminating") await this.stop("Invalid register request") return } handler = this.handleRegister } else { handler = { // TODO Wrapper for per-user commands generate_uuid: util.randomAndroidSubDeviceUUID, register_device: this.registerDevice, login: this.handleLogin, renew: this.handleRenew, connect: this.handleConnect, disconnect: this.handleDisconnect, get_own_profile: this.getOwnProfile, get_profile: this.getProfile, get_portal_channel_info: this.getPortalChannelInfo, get_chats: this.getChats, send_message: this.sendMessage, }[req.command] || this.handleUnknownCommand } const resp = { id: req.id } delete req.id delete req.command resp.command = "response" try { resp.response = await handler(req) } catch (err) { if (err.isAxiosError) { resp.response = { success: false, status: err.response.status, } } else { resp.command = "error" resp.error = err.toString() this.log(`Error handling request ${resp.id} ${err.stack}`) // TODO Check if session is broken. If it is, close the PeerClient } } await this.#write(resp) } #writeReplacer = function(key, value) { if (value instanceof Long) { return value.toString() } else { return value } } #readReviver = function(key, value) { if (value instanceof Object) { // TODO Use a type map if there will be many possible types if (value.__type__ == "Long") { return Long.fromString(value.str) } } return value } }