// mautrix-amp - A very hacky Matrix-SMS bridge based on using Android Messages for Web in Puppeteer // Copyright (C) 2020 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 . const puppeteer = require("puppeteer") const chrono = require("chrono-node") const process = require("process") const path = require("path") const TaskQueue = require("./taskqueue") class MessagesPuppeteer { url = "https://messages.google.com/web/" constructor(profilePath) { if (!profilePath.startsWith("/")) { profilePath = path.join(process.cwd(), profilePath) } this.profilePath = profilePath this.updatedChats = new Set() this.mostRecentMessages = new Map() this.taskQueue = new TaskQueue() } /** * Start the browser and open the messages for web page. * This must be called before doing anything else. */ async start(debug = false) { console.log("Launching browser") this.browser = await puppeteer.launch({ userDataDir: this.profilePath, headless: !debug, defaultViewport: { width: 1920, height: 1080 }, }) console.log("Opening new tab") const pages = await this.browser.pages() if (pages.length > 0) { this.page = pages[0] } else { this.page = await this.browser.newPage() } console.log("Opening", this.url) await this.page.goto(this.url) console.log("Injecting content script") await this.page.addScriptTag({ path: "./src/contentscript.js", type: "module" }) console.log("Exposing this._receiveQRChange") await this.page.exposeFunction("__mautrixReceiveQR", this._receiveQRChange.bind(this)) console.log("Exposing this._receiveChatListChanges") await this.page.exposeFunction("__mautrixReceiveChanges", this._receiveChatListChanges.bind(this)) console.log("Exposing chrono.parseDate") await this.page.exposeFunction("__chronoParseDate", chrono.parseDate) console.log("Waiting for load") // Wait for the page to load (either QR code for login or chat list when already logged in) await Promise.race([ this.page.waitForSelector("mw-main-container mws-conversations-list .conv-container", { visible: true }), this.page.waitForSelector("mw-authentication-container mw-qr-code", { visible: true }), ]) this.taskQueue.start() if (await this.isLoggedIn()) { await this.startObserving() } console.log("Startup complete") } /** * Wait for the session to be logged in and monitor QR code changes while it's not. */ async waitForLogin() { if (await this.isLoggedIn()) { return } const qrSelector = "mw-authentication-container mw-qr-code" console.log("Clicking Remember Me button") await this.page.click("mat-slide-toggle:not(.mat-checked) > label") console.log("Fetching current QR code") const currentQR = await this.page.$eval(qrSelector, element => element.getAttribute("data-qr-code")) this._receiveQRChange(currentQR) console.log("Adding QR observer") await this.page.$eval(qrSelector, element => window.__mautrixController.addQRObserver(element)) console.log("Waiting for login") await this.page.waitForSelector("mws-conversations-list .conv-container", { visible: true, timeout: 0, }) console.log("Removing QR observer") await this.page.evaluate(() => window.__mautrixController.removeQRObserver()) await this.startObserving() console.log("Login complete") } /** * Close the browser. */ async stop() { this.taskQueue.stop() await this.page.close() await this.browser.close() console.log("Everything stopped") } /** * Check if the session is currently logged in. * * @return {Promise} - Whether or not the session is logged in. */ async isLoggedIn() { return (await this.page.$("mw-main-container mws-conversations-list")) !== null } /** * Get the IDs of the most recent chats. * * @return {Promise<[ChatListInfo]>} - List of chat IDs in order of most recent message. */ async getRecentChats() { return await this.page.$eval("mws-conversations-list .conv-container", elem => window.__mautrixController.parseChatList(elem)) } /** * @typedef ChatInfo * @type object * @property {[Participant]} participants */ /** * Get info about a chat. * * @param {number} id - The chat ID whose info to get. * @return {Promise} - Info about the chat. */ async getChatInfo(id) { return await this.taskQueue.push(() => this._getChatInfoUnsafe(id)) } /** * Send a message to a chat. * * @param {number} chatID - The ID of the chat to send a message to. * @param {string} text - The text to send. */ async sendMessage(chatID, text) { await this.taskQueue.push(() => this._sendMessageUnsafe(chatID, text)) } /** * Get messages in a chat. * * @param {number} id The ID of the chat whose messages to get. * @return {Promise<[MessageData]>} - The messages visible in the chat. */ async getMessages(id) { return this.taskQueue.push(() => this._getMessagesUnsafe(id)) } async startObserving() { console.log("Adding chat list observer") await this.page.$eval("mws-conversations-list .conv-container", element => window.__mautrixController.addChatListObserver(element)) } async stopObserving() { console.log("Removing chat list observer") await this.page.evaluate(() => window.__mautrixController.removeChatListObserver()) } _listItemSelector(id) { return `mws-conversation-list-item > a.list-item[href="/web/conversations/${id}"]` } async _switchChatUnsafe(id) { console.log("Switching to chat", id) await this.page.click(this._listItemSelector(id)) } async _getChatInfoUnsafe(id) { await this._switchChatUnsafe(id) await this.page.click("mw-conversation-menu button") await this.page.waitForSelector(".mat-menu-panel button.mat-menu-item.details", { timeout: 1000 }) // There's a 250ms animation and I don't know how to wait for it properly await new Promise(resolve => setTimeout(resolve, 250)) await this.page.click(".mat-menu-panel button.mat-menu-item.details") await this.page.waitForSelector("mws-dialog mw-conversation-details .participants", { timeout: 1000 }) const participants = await this.page.$eval("mws-dialog mw-conversation-details .participants", elem => window.__mautrixController.parseParticipantList(elem)) await this.page.click("mws-dialog mat-dialog-actions button.confirm") return { participants, ...await this.page.$eval(this._listItemSelector(id), elem => window.__mautrixController.parseChatListItem(elem)), } } async _sendMessageUnsafe(chatID, text) { await this._switchChatUnsafe(chatID) await this.page.focus("mws-message-compose .input-box textarea") await this.page.keyboard.type(text) await this.page.click(".compose-container > mws-message-send-button > button") } async _getMessagesUnsafe(id, minID = 0) { await this._switchChatUnsafe(id) console.log("Waiting for messages to load") await this.page.waitFor("mws-message-wrapper") const messages = await this.page.$eval("mws-messages-list .content", element => window.__mautrixController.parseMessageList(element)) if (minID) { return messages.filter(message => message.id > minID) } return messages } async _processChatListChangeUnsafe(id) { this.updatedChats.delete(id) console.log("Processing change to", id) const lastMsgID = this.mostRecentMessages.get(id) || 0 const messages = await this._getMessagesUnsafe(id, lastMsgID) if (messages.length === 0) { console.log("No new messages found in", id) return } const newFirstID = messages[0].id const newLastID = messages[messages.length - 1].id this.mostRecentMessages.set(id, newLastID) console.log(`Loaded messages in ${id} after ${lastMsgID}: got ${newFirstID}-${newLastID}`) // TODO send messages somewhere for (const message of messages) { console.info("New message:", message) message.chatID = id } } _receiveChatListChanges(changes) { console.log("Received chat list changes:", changes) for (const item of changes) { if (!this.updatedChats.has(item)) { this.updatedChats.add(item) this.taskQueue.push(() => this._processChatListChangeUnsafe(item)) .catch(err => console.error("Error handling chat list changes")) } } } _receiveQRChange(newLink) { console.info("QR code changed:", newLink) } } module.exports = MessagesPuppeteer