// 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 { ReadStreamUtil } from "node-kakao/stream" /** @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").TalkNormalChannel} TalkNormalChannel */ /** @typedef {import("node-kakao").TalkOpenChannel} TalkOpenChannel */ /** @typedef {import("node-kakao/dist/talk").TalkChannelList} TalkChannelList */ // TODO Remove once/if some helper type hints are upstreamed /** @typedef {import("node-kakao").OpenChannelUserInfo} OpenChannelUserInfo */ import openlink from "node-kakao/openlink" const { OpenChannelUserPerm } = openlink import chat from "node-kakao/chat" const { KnownChatType } = chat import * as modutil from "./modutil.js" 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 ProtocolError extends Error {} class PermError extends ProtocolError { /** @type {Map */ static #PERM_NAMES = new Map([ [OpenChannelUserPerm.OWNER, "the channel owner"], [OpenChannelUserPerm.MANAGER, "channel admininstrators"], [OpenChannelUserPerm.BOT, "bots"], [OpenChannelUserPerm.NONE, "registered KakaoTalk users"], ]) /** * @param {?OpenChannelUserPerm[]} permNeeded * @param {?OpenChannelUserPerm} permActual * @param {string} action */ constructor(permNeeded, permActual, action) { const who = !permActual ? "In this channel, no one" : "Only " + permNeeded .map(v => PermError.#PERM_NAMES.get(v)) .reduce((prev, curr) => prev += ` and ${curr}`) super(`${who} can ${action}`) this.name = this.constructor.name } } class UserClient { static #initializing = false #connected = false #talkClient = new TalkClient() get talkClient() { return this.#talkClient } /** @type {ServiceApiClient} */ #serviceClient get serviceClient() { return this.#serviceClient } /** @type {OAuthCredential} */ #credential get userId() { return this.#credential.userId } /** * 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(`Chat ${data.chat.logId} received in channel ${channel.channelId}`) 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(`Chat ${feed.logId} deleted in channel ${channel.channelId} by user ${feedChatlog.sender.userId}`) 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 ${feed.logId} hid from channel ${channel.channelId} by user ${hideLog.sender.userId}`) this.write("chat_deleted", { chatId: feed.logId, senderId: hideLog.sender.userId, timestamp: hideLog.sendAt, channelId: channel.channelId, channelType: channel.info.type, }) }) this.#talkClient.on("chat_read", (chat, channel, reader) => { let senderId if (reader) { senderId = reader.userId } else if (channel.info.type == "MemoChat") { senderId = channel.clientUser.userId } else { this.error(`Chat ${chat.logId} read in channel ${channel.channelId} by unknown reader (channel type: ${channel.info.type || "none"})`) return } this.log(`Chat ${chat.logId} read in channel ${channel.channelId} by ${senderId}`) this.write("chat_read", { chatId: chat.logId, senderId: senderId, channelId: channel.channelId, channelType: channel.info.type, }) }) this.#talkClient.on("chat_event", (channel, author, type, count, chat) => { // TODO Figure out if this is can ever be for anything other than hearts on Shouts this.log(`Event ${type} (count = ${count}) on chat ${chat.logId} in channel ${channel.channelId} sent by user ${author.userId}`) }) this.#talkClient.on("profile_changed", (channel, lastInfo, user) => { this.log(`Profile of ${user.userId} changed (in channel ${channel ? channel.channelId : "None"})`) this.write("profile_changed", { info: user, /* TODO Is this ever a per-channel profile change? channelId: channel.channelId, channelType: channel.info.type, */ }) }) this.#talkClient.on("perm_changed", /** * TODO Upstream these type hints * @param {TalkOpenChannel} channel * @param {OpenChannelUserInfo} lastInfo * @param {OpenChannelUserInfo} user */ (channel, lastInfo, user) => { this.log(`Perms of user ${user.userId} in channel ${channel.channelId} changed from ${lastInfo.perm} to ${user.perm}`) this.write("perm_changed", { is_sequential: true, userId: user.userId, perm: user.perm, senderId: getChannelOwner().userId, channelId: channel.channelId, channelType: channel.info.type, }) }) this.#talkClient.on("host_handover", (channel, lastLink, link) => { // TODO Find how or if this relates to permissions this.log(`Host of channel ${channel.channelId} changed from ${lastLink.linkOwner.nickname} to ${link.linkOwner.nickname}`) }) this.#talkClient.on("channel_added", channel => { this.log(`Added channel ${channel.channelId}`) this.write("channel_added", { channelInfo: channel.info, }) }) this.#talkClient.on("channel_join", channel => { this.log(`Joined channel ${channel.channelId}`) this.write("channel_join", { channelInfo: channel.info, }) }) this.#talkClient.on("channel_left", channel => { this.log(`Left channel ${channel.channelId}`) this.write("channel_left", { channelId: channel.channelId, channelType: channel.info.type, }) }) this.#talkClient.on("channel_kicked", (kickedLog, channel, feed) => { // TODO Confirm whether this can refer to any user that was kicked, or only to the current user this.log(`Kicked from channel ${channel.channelId}`) this.write("channel_kicked", { userId: feed.member.userId, senderId: kickedLog.sender.userId, channelId: channel.channelId, channelType: channel.info.type, }) }) this.#talkClient.on("channel_link_deleted", (feedChatLog, channel, feed) => { // TODO Figure out what this means this.log(`Channel link deleted in channel ${channel.channelId}: feed=${JSON.stringify(feed)}, feedChatLog=${JSON.stringify(feedChatLog)}`) }) this.#talkClient.on("link_created", link => { // TODO Figure out what this means this.log(`Link created: ${JSON.stringify(link)}`) }) this.#talkClient.on("link_deleted", link => { // TODO Figure out what this means this.log(`Link deleted: ${JSON.stringify(link)}`) }) this.#talkClient.on("user_join", (joinLog, channel, user, feed) => { this.log(`User ${user.userId} joined channel ${channel.channelId}`) this.write("user_join", { userId: user.userId, channelId: channel.channelId, channelType: channel.info.type, }) }) this.#talkClient.on("user_left", (leftLog, channel, user, feed) => { this.log(`User ${user.userId} left channel ${channel.channelId}`) this.write("user_left", { userId: user.userId, channelId: channel.channelId, channelType: channel.info.type, }) }) this.#talkClient.on("meta_change", (channel, type, newMeta) => { // TODO Handle announcements as pinned messages this.log(`Channel ${channel.channelId} metadata changed`) }) this.#talkClient.on("push_packet", (method, data) => { // TODO Find a better way to do this...but data doesn't have much. if (method == "SYNCLINKUP") { if (!data?.ol) return const linkURL = data.ol?.lu if (!linkURL) return for (const channel of this.#talkClient.channelList.open.all()) { if (channel.info.openLink?.linkURL == linkURL) { this.write("channel_meta_change", { info: { name: data.ol?.ln, description: data.ol?.desc || null, photoURL: data.ol?.liu || null, }, channelId: channel.channelId, channelType: channel.info.type, }) break } } } }) this.#talkClient.on("disconnected", reason => { this.log(`Disconnected (reason=${reason})`) this.disconnect() this.write("disconnected", { reason: reason, }) }) this.#talkClient.on("switch_server", () => { this.log(`Server switch requested`) this.write("switch_server", { is_sequential: true, }) }) this.#talkClient.on("error", err => { this.log(`Client error: ${err}`) this.write("error", { error: err, }) }) } /** * @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) await userClient.setCredential(credential) return userClient } log(...text) { console.log(`[API/${this.mxid}]`, ...text) } error(...text) { console.error(`[API/${this.mxid}]`, ...text) } /** * @param {OAuthCredential} credential */ async setCredential(credential) { this.#serviceClient = await ServiceApiClient.create(credential) this.#credential = credential } /** * @param {ChannelProps} channelProps */ async getChannel(channelProps) { const talkChannel = this.#talkClient.channelList.get(channelProps.id) if (talkChannel) { return talkChannel } const channelList = getChannelListForType( this.#talkClient.channelList, channelProps.type ) const res = await channelList.addChannel({ channelId: channelProps.id }) if (!res.success) { this.error(`Unable to add ${channelProps.type} channel ${channelProps.id}`) throw res } return res.result } /** * @param {Long} channelId */ async getNormalChannel(channelId) { const channelList = this.#talkClient.channelList.normal const talkChannel = channelList.get(channelId) if (talkChannel) { return talkChannel } const res = await channelList.addChannel({ channelId: channelId }) if (!res.success) { this.error(`Unable to add normal channel ${channelProps.id}`) throw res } return res.result } /** * @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() if (credential && this.#credential != credential) { await this.setCredential(credential) } const res = await this.#talkClient.login(this.#credential) this.#connected = res.success return res } disconnect() { if (this.isConnected()) { this.#talkClient.close() } this.#connected = false } isConnected() { return this.#talkClient?.logon || false } isUnexpectedlyDisconnected() { return this.#connected && !this.isConnected() } /** * Send a user-specific command with (optional) data to the socket. * * @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.registerTimeout = manager.registerTimeout this.loggingKeys = manager.loggingKeys this.socket = socket this.connID = connID this.stopped = false this.notificationID = 0 this.maxCommandID = 0 this.peerID = "" this.deviceName = "KakaoTalk Bridge" /** @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 ${this.registerTimeout/1000} seconds, terminating`) this.stop("Register request timeout") } }, this.registerTimeout) } 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 || "unknown peer"})`) } #closeUsers() { this.log(`Closing all API clients for ${this.peerID || "unknown peer"}`) 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 * @param {boolean} req.forced * @returns The response of the login attempt, including obtained * credentials for subsequent token-based login. If a required device passcode * request failed, its status is stored here. */ handleLogin = async (req) => { const authClient = await this.#createAuthClient(req.uuid) const loginRes = await authClient.login(req.form, req.forced) 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(this.deviceName, uuid, {}, modutil.ModXVCProvider) } // 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 * @param {?OpenChannelUserPerm[]} permNeeded If set, throw if the user's permission level matches none of the values in this list. * @param {?string} action The action requiring permission, to be used in an error message if throwing. * @throws {PermError} if the user does not have the specified permission level. */ async #getUserChannel(mxid, channelProps, permNeeded, action) { const userClient = this.#getUser(mxid) const talkChannel = await userClient.getChannel(channelProps) if (permNeeded) { await this.#requireChannelPerm(talkChannel, permNeeded, action) } return talkChannel } /** * @param {TalkOpenChannel} talkChannel * @param {OpenChannelUserPerm[]} permNeeded Throw if the user's permission level matches none of the values in this list. * @param {string} action The action requiring permission * @throws {PermError} if the user does not have the specified permission level. */ async #requireChannelPerm(talkChannel, permNeeded, action) { const permActual = talkChannel.getUserInfo({ userId: talkChannel.clientUser.userId }).perm if (permNeeded.indexOf(permActual) == -1) { throw new PermError(permNeeded, permActual, action) } } /** * @param {string} mxid * @param {Long} channelId */ async #getUserNormalChannel(mxid, channelId) { return await this.#getUser(mxid).getNormalChannel(channelId) } /** * @param {object} req * @param {string} req.mxid * @param {OAuthCredential} req.oauth_credential */ handleRenew = async (req) => { const userClient = this.#tryGetUser(req.mxid) const oAuthClient = await OAuthApiClient.create() const res = await oAuthClient.renew(req.oauth_credential) if (res.success && userClient) { await userClient.setCredential(res.result.credential) } return res } /** * @param {object} req * @param {string} req.mxid * @param {OAuthCredential} req.oauth_credential */ userStart = async (req) => { let userClient = this.#tryGetUser(req.mxid) if (!userClient) { userClient = await UserClient.create(req.mxid, req.oauth_credential, this) } else { await userClient.setCredential(req.oauth_credential) } const res = await this.#getSettings(userClient.serviceClient) 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 */ isConnected = (req) => { return this.#tryGetUser(req.mxid)?.isConnected() } /** * @param {object} req * @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) => { 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 makeCommandResult({ name: talkChannel.getDisplayName(), description: talkChannel.info.openLink?.description, // TODO Find out why linkCoverURL is blank, despite having updated the channel! photoURL: talkChannel.info.openLink?.linkCoverURL || null, participantInfo: { // TODO Get members from chatON? participants: Array.from(talkChannel.getAllUserInfo()), kickedUserIds: await this.#getKickedUserIds(talkChannel), }, }) } /** * @param {object} req * @param {string} req.mxid * @param {ChannelProps} req.channel_props */ getPortalChannelParticipantInfo = async (req) => { const talkChannel = await this.#getUserChannel(req.mxid, req.channel_props) // TODO Get members from chatON? const participantRes = await talkChannel.getAllLatestUserInfo() if (!participantRes.success) return participantRes return { participants: participantRes.result, kickedUserIds: await this.#getKickedUserIds(talkChannel), } } /** * @param {TalkNormalChannel | TalkOpenChannel} talkChannel */ async #getKickedUserIds(talkChannel) { if (!isChannelTypeOpen(talkChannel.info.type)) { return [] } else { const kickListRes = await talkChannel.getKickList() if (!kickListRes.success) { return [] } else { return kickListRes.result.map(kickUser => kickUser.userId) } } } /** * @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 || !res.length) return res while (true) { const nextRes = await talkChannel.getChatListFrom( res.result[res.result.length - 1].logId ) if (!nextRes.success) return nextRes if (!nextRes.result.length) break res.result.push(...nextRes.result) } if (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 {ChannelProps} req.channel_props * @param {[Long]} req.unread_chat_ids Must be in DECREASING order */ getReadReceipts = async (req) => { const talkChannel = await this.#getUserChannel(req.mxid, req.channel_props) // TODO Is any pre-syncing needed? const userCount = talkChannel.userCount if (userCount == 1) return makeCommandResult([]) /** @type {Map */ const latestReceiptByUser = new Map() let fullyRead = false for (const chatId of req.unread_chat_ids) { const chatReaders = talkChannel.getReaders({ logId: chatId }) for (const chatReader of chatReaders) { if (!latestReceiptByUser.has(chatReader.userId)) { latestReceiptByUser.set(chatReader.userId, chatId) if (latestReceiptByUser.size == userCount) { fullyRead = true break } } } if (fullyRead) { break } } /** * @typedef {object} Receipt * @property {Long} userId * @property {Long} chatId */ /** @type {[Receipt]} */ const receipts = [] latestReceiptByUser.forEach((value, key) => receipts.push({ "userId": key, "chatId": value })) return makeCommandResult(receipts) } /** * @param {object} req * @param {string} req.mxid * @param {string} req.uuid */ canChangeUUID = async (req) => { return await this.#getUser(req.mxid).serviceClient.canChangeUUID(req.uuid) } /** * @param {object} req * @param {string} req.mxid * @param {string} req.uuid */ changeUUID = async (req) => { const serviceClient = this.#getUser(req.mxid).serviceClient const checkRes = await serviceClient.canChangeUUID(req.uuid) if (!checkRes.success) return checkRes return await serviceClient.changeUUID(req.uuid) } /** * @param {object} req * @param {string} req.mxid * @param {boolean} req.searchable */ setUUIDSearchable = async (req) => { const serviceClient = this.#getUser(req.mxid).serviceClient const moreRes = await serviceClient.requestMoreSettings() if (!moreRes.success) { throw new ProtocolError("Error checking status of KakaoTalk ID") } if (!moreRes.result.uuid) { throw new ProtocolError("You do not yet have a KakaoTalk ID") } if (req.searchable == moreRes.result.uuidSearchable) { throw new ProtocolError(`Your KakaoTalk ID is already ${req.searchable ? "searchable" : "hidden"}`) } return await serviceClient.updateSettings({ uuid_searchable: req.searchable, }) } /** * @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 * @param {Long} req.user_id * @param {boolean} req.add */ editFriend = async (req) => { return await this.#editFriend( this.#getUser(req.mxid).serviceClient, req.user_id, req.add ) } /** * @param {object} req * @param {string} req.mxid * @param {string} req.uuid * @param {boolean} req.add */ editFriendByUUID = async (req) => { const serviceClient = this.#getUser(req.mxid).serviceClient const res = await serviceClient.findFriendByUUID(req.uuid) if (!res.success) return res return await this.#editFriend( serviceClient, res.result.member.userId instanceof Long ? res.result.member.userId : Long.fromNumber(res.result.member.userId), req.add ) } /** * @param {ServiceApiClient} serviceClient * @param {Long} id * @param {boolean} add */ async #editFriend(serviceClient, id, add) { const listRes = await serviceClient.requestFriendList() if (listRes.success) { const isFriend = -1 != listRes.result.friends.findIndex(friend => id.equals(friend.userId)) if (isFriend == add) { throw new ProtocolError(`User is already ${add ? "in" : "absent from"} friends list`) } } return add ? await serviceClient.addFriend(id) : await serviceClient.removeFriend(id) } /** * @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 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 // TODO channelList.all() doesn't really return *all* channels... 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.key */ downloadFile = async (req) => { const talkChannel = await this.#getUserChannel(req.mxid, req.channel_props) const res = await talkChannel.downloadMedia({ key: req.key }, KnownChatType.FILE) if (!res.success) return res const data = await ReadStreamUtil.all(res.result) return makeCommandResult(Array.from(data)) } /** * @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) const res = 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}, }) if (res.success) res.result = res.result.logId return res } /** * @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) const res = await talkChannel.sendMedia(req.type, { data: Uint8Array.from(req.data), name: req.name, width: req.width, height: req.height, ext: req.ext, }) if (res.success) res.result = res.result.logId return res } /** * @param {object} req * @param {string} req.mxid * @param {ChannelProps} req.channel_props * @param {Long} req.chat_id */ deleteChat = async (req) => { const talkChannel = await this.#getUserChannel(req.mxid, req.channel_props) return await talkChannel.deleteChat({ logId: req.chat_id, }) } /** * @param {object} req * @param {string} req.mxid * @param {ChannelProps} req.channel_props * @param {Long} req.read_until_chat_id */ markRead = async (req) => { const talkChannel = await this.#getUserChannel(req.mxid, req.channel_props) return await talkChannel.markRead({ logId: req.read_until_chat_id, }) } /** * @param {object} req * @param {string} req.mxid * @param {ChannelProps} req.channel_props * @param {Long} req.user_id * @param {OpenChannelUserPerm} req.perm */ sendPerm = async (req) => { const talkChannel = await this.#getUserChannel( req.mxid, req.channel_props, [OpenChannelUserPerm.OWNER], "change user permissions" ) const user = { userId: req.user_id } if (!talkChannel.getUserInfo(user)) { throw new ProtocolError("Cannot set permission level of a user that is not a channel participant") } if (req.user_id.equals(talkChannel.clientUser.userId)) { throw new ProtocolError("Cannot change own permission level") } return await talkChannel.setUserPerm(user, req.perm) } /** * @param {object} req * @param {string} req.mxid * @param {ChannelProps} req.channel_props * @param {string} req.name */ setChannelName = async (req) => { if (!isChannelTypeOpen(req.channel_props.type)) { const talkChannel = await this.#getUserNormalChannel(req.mxid, req.channel_props.id) return await talkChannel.setTitleMeta(req.name) } else { return await this.#setOpenChannelProperty(req.mxid, req.channel_props, "linkName", req.name) } } /** * @param {object} req * @param {string} req.mxid * @param {ChannelProps} req.channel_props * @param {string} req.description */ setChannelDescription = async (req) => { return await this.#setOpenChannelProperty(req.mxid, req.channel_props, "description", req.description) } /** * @param {string} mxid * @param {ChannelProps} channelProps * @param {string} propertyName * @param {any} propertyValue */ async #setOpenChannelProperty(mxid, channelProps, propertyName, propertyValue) { if (isChannelTypeOpen(channelProps)) { throw ProtocolError(`Cannot set ${propertyName} of non-open channel ${channelProps.id} (type = ${channelProps.type})`) } const userClient = this.#getUser(mxid) /** @type {TalkOpenChannel} */ const talkChannel = await userClient.getChannel(channelProps) this.#requireChannelPerm(talkChannel, [OpenChannelUserPerm.OWNER], `change channel ${propertyName}`) const linkRes = await talkChannel.getLatestOpenLink() if (!linkRes.success) throw linkRes const link = linkRes.result link[propertyName] = propertyValue return await userClient.talkClient.channelList.open.updateOpenLink( { linkId: link.linkId }, link ) } /* * TODO * @param {object} req * @param {string} req.mxid * @param {ChannelProps} req.channel_props * @param {string} req.photo_url setChannelPhoto = async (req) => { const talkChannel = await this.#getUserChannel( req.mxid, req.channel_props, [OpenChannelUserPerm.OWNER], "change channel photo" ) return await talkChannel.setProfileMeta({ imageUrl: req.photo_url, fullImageUrl: req.photo_url, }) } */ /** * @param {object} req * @param {string} req.mxid * @param {Long} req.user_id */ createDirectChat = async (req) => { const userClient = this.#getUser(req.mxid) await this.#requireInFriendsList(userClient.serviceClient, req.user_id) const channelList = userClient.talkClient.channelList.normal const createChannel = !req.user_id.equals(userClient.userId) ? channelList.createChannel.bind(channelList, { userList: [{ userId: req.user_id }], }) : channelList.createMemoChannel.bind(channelList) const retry_delay = 2000 let retries_left = 1 let res do { res = await createChannel() if (res.success) { return makeCommandResult(res.result.channelId) } this.error(`Failed to create direct chat, try again in ${retry_delay} ms (${retries_left - 1} tries remaining)`) await new Promise(resolve => setTimeout(resolve, retry_delay)) } while (retries_left--) this.error(`Failed to create direct chat, not retrying`) return res } /** * @param {ServiceApiClient} serviceClient * @param {Long} id */ async #requireInFriendsList(serviceClient, id) { let listRes = await serviceClient.requestFriendList() if (!listRes.success) { this.error("Failed to check friends list") throw listRes } const isFriend = -1 != listRes.result.friends.findIndex(friend => id.equals(friend.userId)) if (!isFriend) { throw new ProtocolError("This user is not in your friends list") } } /** * @param {object} req * @param {string} req.mxid * @param {ChannelProps} req.channel_props */ leaveChannel = async (req) => { const userClient = this.#getUser(req.mxid) const channelList = getChannelListForType( userClient.talkClient.channelList, req.channel_props.type ) return await channelList.leaveChannel({ channelId: req.channel_props.id }) } handleUnknownCommand = () => { throw new Error("Unknown command") } /** * @param {object} req * @param {string} req.peer_id * @param {object} req.register_config * @param {string} req.register_config.device_name */ handleRegister = async (req) => { this.peerID = req.peer_id this.deviceName = req.register_config.device_name || this.deviceName 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(`Request ${req.id}:`, this.#logObj(req, req.command, this.loggingKeys.request)) 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, is_connected: this.isConnected, get_settings: this.getSettings, get_own_profile: this.getOwnProfile, get_profile: this.getProfile, get_portal_channel_info: this.getPortalChannelInfo, get_portal_channel_participant_info: this.getPortalChannelParticipantInfo, get_participants: this.getParticipants, get_chats: this.getChats, get_read_receipts: this.getReadReceipts, can_change_uuid: this.canChangeUUID, change_uuid: this.changeUUID, set_uuid_searchable: this.setUUIDSearchable, list_friends: this.listFriends, edit_friend: this.editFriend, edit_friend_by_uuid: this.editFriendByUUID, get_friend_dm_id: req => this.getFriendProperty(req, "directChatId"), get_memo_ids: this.getMemoIds, download_file: this.downloadFile, send_chat: this.sendChat, send_media: this.sendMedia, delete_chat: this.deleteChat, mark_read: this.markRead, send_perm: this.sendPerm, set_channel_name: this.setChannelName, set_channel_description: this.setChannelDescription, //set_channel_photo: this.setChannelPhoto, create_direct_chat: this.createDirectChat, leave_channel: this.leaveChannel, }[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 if ("status" in err) { resp.response = err } else { resp.command = "error" let errorDetails if (err instanceof ProtocolError) { resp.error = err.message errorDetails = err.message } else if (err instanceof Error) { resp.error = err.toString() errorDetails = err.stack } else { resp.error = JSON.stringify(err) errorDetails = `throwed ${resp.error}` } this.error(`Response ${resp.id}: ${errorDetails}`) } } if (resp.response) { const success = resp.response.success !== false const logger = (success ? this.log : this.error).bind(this) logger( `Response ${resp.id}:`, this.#logObj( resp.response instanceof Object ? resp.response : {value: resp.response}, success ? "success" : "failure", this.loggingKeys.response ) ) } await this.write(resp) if ("mxid" in req) { const userClient = this.#tryGetUser(req.mxid) if (userClient && userClient.isUnexpectedlyDisconnected()) { this.error("Unexpected disconnect for user", req.mxid) this.userClients.delete(req.mxid) await userClient.write("unexpected_disconnect") } } } /** * @param {object} obj * @param {string} desc * @param {[string]} keys */ #logObj(obj, desc, keys) { return [desc].concat( keys.filter(key => key in obj).map(key => `${key}: ${JSON.stringify(obj[key], this.#writeReplacer)}`) ).join(', ') } #writeReplacer(key, value) { if (value instanceof Long) { return value.toString() } else { return value } } #readReviver(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 {object} result */ function makeCommandResult(result) { return { success: true, status: 0, result: result } } /** * @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 } } /** * @param {TalkOpenChannel} channel */ function getChannelOwner(channel) { for (const userInfo of channel.getAllUserInfo()) { if (userInfo.perm == OpenChannelUserPerm.OWNER) { return userInfo } } return null }