// 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").ChannelType} ChannelType */ /** @typedef {import("node-kakao").ReplyAttachment} ReplyAttachment */ /** @typedef {import("node-kakao").MentionStruct} MentionStruct */ /** @typedef {import("node-kakao/dist/talk").TalkChannelList} TalkChannelList */ import chat from "node-kakao/chat" const { KnownChatType } = chat import { emitLines, promisify } from "./util.js" /** * @typedef {Object} ChannelProps * @property {Long} id * @property {ChannelType} type */ 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 {PeerClient} peerClient TODO Make RPC user-specific instead of needing this */ constructor(mxid, peerClient) { if (!UserClient.#initializing) { throw new Error("Private constructor") } UserClient.#initializing = false this.mxid = mxid this.peerClient = peerClient this.#talkClient.on("chat", (data, channel) => { this.log(`Received chat message ${data.chat.logId} in channel ${channel.channelId}`) return this.write("chat", { //is_sequential: true, // TODO Make sequential per user & channel (if it isn't already) chatlog: data.chat, channelId: channel.channelId, channelType: channel.info.type, }) }) this.#talkClient.on("chat_deleted", (feedChatlog, channel, feed) => { this.log(`${feed.logId} deleted in channel ${channel.channelId} by user ${feedChatlog.sender.userId}`); return this.write("chat_deleted", { chatId: feed.logId, senderId: feedChatlog.sender.userId, timestamp: feedChatlog.sendAt, channelId: channel.channelId, channelType: channel.info.type, }) }) this.#talkClient.on("message_hidden", (hideLog, channel, feed) => { this.log(`Message ${hideLog.logId} hid from channel ${channel.channelId} by user ${hideLog.sender.userId}`); return this.write("chat_deleted", { chatId: feed.logId, senderId: hideLog.sender.userId, timestamp: hideLog.sendAt, channelId: channel.channelId, channelType: channel.info.type, }) }) /* TODO Many more listeners this.#talkClient.on("chat_read", (chat, channel, reader) => { this.log(`chat_read in channel ${channel.channelId}`) //chat.logId }) */ this.#talkClient.on("disconnected", (reason) => { this.log(`Disconnected (reason=${reason})`) this.disconnect() return this.write("disconnected", { reason: reason, }) }) this.#talkClient.on("switch_server", () => { this.log(`Server switch requested`) return this.write("switch_server", { is_sequential: true, }) }) } /** * @param {string} mxid The ID of the associated Matrix user * @param {OAuthCredential} credential The token to log in with, obtained from prior login * @param {PeerClient} peerClient What handles RPC */ static async create(mxid, credential, peerClient) { this.#initializing = true const userClient = new UserClient(mxid, peerClient) userClient.#serviceClient = await ServiceApiClient.create(credential) return userClient } log(...text) { console.log(`[API/${this.mxid}]`, ...text) } error(...text) { console.error(`[API/${this.mxid}]`, ...text) } /** * @param {ChannelProps} channelProps */ async getChannel(channelProps) { let channel = this.#talkClient.channelList.get(channelProps.id) if (channel) { return channel } else { const channelList = getChannelListForType( this.#talkClient.channelList, channelProps.type ) const res = await channelList.addChannel({ channelId: channelProps.id, }) if (!res.success) { throw new Error(`Unable to add ${channelProps.type} channel ${channelProps.id}`) } return res.result } } /** * @param {OAuthCredential} credential The token to log in with, obtained from prior login */ async connect(credential) { // TODO Don't re-login if possible. But must still return a LoginResult! this.disconnect() return await this.#talkClient.login(credential) } disconnect() { if (this.#talkClient.logon) { this.#talkClient.close() } } /** * Send a user-specific command with (optional) data to the socket. * * @param {string} command - The data to write. * @param {?object} data - The data to write. */ write(command, data) { return this.peerClient.write({ id: --this.peerClient.notificationID, command: `${command}:${this.mxid}`, ...data }) } } 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.disconnect() } 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) } /** * Obtain login tokens. 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) } /** * @param {string} mxid * @param {ChannelProps} channelProps */ async #getUserChannel(mxid, channelProps) { return await this.#getUser(mxid).getChannel(channelProps) } /** * @param {Object} req * @param {OAuthCredential} req.oauth_credential */ handleRenew = async (req) => { // TODO Cache per user? Reset API client objects? const oAuthClient = await OAuthApiClient.create() return await oAuthClient.renew(req.oauth_credential) } /** * @param {Object} req * @param {string} req.mxid * @param {OAuthCredential} req.oauth_credential */ userStart = async (req) => { const userClient = this.#tryGetUser(req.mxid) || await UserClient.create(req.mxid, req.oauth_credential, this) // TODO Should call requestMore/LessSettings instead const res = await userClient.serviceClient.requestMyProfile() if (res.success) { this.userClients.set(req.mxid, userClient) } return res } /** * @param {Object} req * @param {string} req.mxid */ userStop = async (req) => { this.handleDisconnect(req) this.userClients.delete(req.mxid) } /** * @param {Object} req * @param {string} req.mxid * @param {OAuthCredential} req.oauth_credential */ handleConnect = async (req) => { return await this.#getUser(req.mxid).connect(req.oauth_credential) } /** * @param {Object} req * @param {string} req.mxid */ handleDisconnect = (req) => { this.#tryGetUser(req.mxid)?.disconnect() } /** * @param {Object} req * @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 */ getProfile = async (req) => { return await this.#getUser(req.mxid).serviceClient.requestProfile(req.user_id) } /** * @param {Object} req * @param {string} req.mxid * @param {ChannelProps} req.channel_props */ getPortalChannelInfo = async (req) => { const talkChannel = await this.#getUserChannel(req.mxid, 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 {ChannelProps} req.channel_props */ getParticipants = async (req) => { const talkChannel = await this.#getUserChannel(req.mxid, req.channel_props) return await talkChannel.getAllLatestUserInfo() } /** * @param {Object} req * @param {string} req.mxid * @param {ChannelProps} req.channel_props * @param {?Long} req.sync_from * @param {?Number} req.limit */ getChats = async (req) => { const talkChannel = await this.#getUserChannel(req.mxid, 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 */ listFriends = async (req) => { return await this.#getUser(req.mxid).serviceClient.requestFriendList() } /** * @param {Object} req * @param {string} req.mxid The user whose friend is being looked up. * @param {string} req.friend_id The friend to search for. * @param {string} propertyName The property to retrieve from the specified friend. */ getFriendProperty = async (req, propertyName) => { const res = await this.#getUser(req.mxid).serviceClient.findFriendById(req.friend_id) if (!res.success) return res return this.#makeCommandResult(res.result.friend[propertyName]) } /** * @param {Object} req * @param {string} req.mxid */ getMemoIds = (req) => { /** @type Long[] */ const channelIds = [] const channelList = this.#getUser(req.mxid).talkClient.channelList for (const channel of channelList.all()) { if (channel.info.type == "MemoChat") { channelIds.push(channel.channelId) } } return channelIds } /** * @param {Object} req * @param {string} req.mxid * @param {ChannelProps} req.channel_props * @param {string} req.text * @param {?ReplyAttachment} req.reply_to * @param {?MentionStruct[]} req.mentions */ sendChat = async (req) => { const talkChannel = await this.#getUserChannel(req.mxid, req.channel_props) return await talkChannel.sendChat({ text: req.text, type: !!req.reply_to ? KnownChatType.REPLY : KnownChatType.TEXT, attachment: !req.mentions ? req.reply_to : {...req.reply_to, mentions: req.mentions}, }) } /** * @param {Object} req * @param {string} req.mxid * @param {ChannelProps} req.channel_props * @param {int} req.type * @param {number[]} req.data * @param {string} req.name * @param {?int} req.width * @param {?int} req.height * @param {?string} req.ext */ sendMedia = async (req) => { const talkChannel = await this.#getUserChannel(req.mxid, req.channel_props) return await talkChannel.sendMedia(req.type, { data: Uint8Array.from(req.data), name: req.name, width: req.width, height: req.height, ext: req.ext, }) } #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, start: this.userStart, stop: this.userStop, 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, get_friend_dm_id: req => this.getFriendProperty(req, "directChatId"), get_memo_ids: this.getMemoIds, send_chat: this.sendChat, send_media: this.sendMedia, }[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) { return isChannelTypeOpen(channelType) ? channelList.open : channelList.normal } /** * @param {ChannelType} channelType */ function isChannelTypeOpen(channelType) { switch (channelType) { case "OM": case "OD": return true default: return false } }