// 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" /** @typedef {import("node-kakao").OAuthCredential} OAuthCredential */ /** @typedef {import("node-kakao/dist/talk").TalkChannelList} TalkChannelList */ /** @typedef {import("node-kakao").ChannelType} ChannelType */ import chat from "node-kakao/chat" const { KnownChatType } = chat import { emitLines, promisify } from "./util.js" ServiceApiClient.prototype.requestFriendList = async function() { const res = await this._client.requestData( "POST", `${this.getFriendsApiPath("update.json")}`, { phone_number_type: 1, } ); return { status: res.status, success: res.status === 0, result: res, }; } 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 } /** * @param {Object} channel_props * @param {Long} channel_props.id * @param {ChannelType} channel_props.type */ async getChannel(channel_props) { let channel = this.#talkClient.channelList.get(channel_props.id) if (channel) { return channel } else { const channelList = getChannelListForType( this.#talkClient.channelList, channel_props.type ) const res = await channelList.addChannel({ channelId: channel_props.id, }) if (!res.success) { throw new Error(`Unable to add ${channel_props.type} channel ${channel_props.id}`) } return res.result } } 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 {import("./clientmanager.js").default} manager * @param {import("net").Socket} socket * @param {number} connID */ 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 /** @type {Map} */ 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 {Object} req.channel_props */ getPortalChannelInfo = async (req) => { const userClient = this.#getUser(req.mxid) const talkChannel = await userClient.getChannel(req.channel_props) 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 {Object} req.channel_props */ getParticipants = async (req) => { const userClient = this.#getUser(req.mxid) const talkChannel = await userClient.getChannel(req.channel_props) return await talkChannel.getAllLatestUserInfo() } /** * @param {Object} req * @param {string} req.mxid * @param {Object} req.channel_props * @param {?Long} req.sync_from * @param {?Number} req.limit */ getChats = async (req) => { const userClient = this.#getUser(req.mxid) const talkChannel = await userClient.getChannel(req.channel_props) 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 {?OAuthCredential} req.oauth_credential */ listFriends = async (req) => { const serviceClient = await this.#getServiceClient(req.mxid, req.oauth_credential) return await serviceClient.requestFriendList() } /** * @param {Object} req * @param {string} req.mxid * @param {Object} req.channel_props * @param {string} req.text */ sendMessage = async (req) => { const userClient = this.#getUser(req.mxid) const talkChannel = await userClient.getChannel(req.channel_props) 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.log("Received request", req.id, "with command", req.command) 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_participants: this.getParticipants, get_chats: this.getChats, list_friends: this.listFriends, 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 } } /** * @param {TalkChannelList} channelList * @param {ChannelType} channelType */ function getChannelListForType(channelList, channelType) { switch (channelType) { case "OM": case "OD": return channelList.open default: return channelList.normal } }