diff --git a/matrix_puppeteer_line/portal.py b/matrix_puppeteer_line/portal.py index 043e83e..7cce7e7 100644 --- a/matrix_puppeteer_line/portal.py +++ b/matrix_puppeteer_line/portal.py @@ -192,9 +192,7 @@ class Portal(DBPortal, BasePortal): async def _bridge_own_message_pm(self, source: 'u.User', sender: Optional['p.Puppet'], mid: str, invite: bool = True) -> Optional[IntentAPI]: - # Use bridge bot as puppet for own user when puppet for own user is unavailable - # TODO Use own LINE puppet instead, and create it if it's not available yet - intent = sender.intent if sender else self.az.intent + intent = sender.intent if sender else (await source.get_own_puppet()).intent if self.is_direct and (sender is None or sender.mid == source.mid and not sender.is_real_user): if self.invite_own_puppet_to_pm and invite: try: @@ -228,7 +226,7 @@ class Portal(DBPortal, BasePortal): if not self.invite_own_puppet_to_pm: self.log.warning(f"Ignoring message {evt.id}: double puppeting isn't enabled") return - sender = p.Puppet.get_by_mid(evt.sender.id) if not self.is_direct else None + sender = await p.Puppet.get_by_mid(evt.sender.id) if evt.sender else None intent = await self._bridge_own_message_pm(source, sender, f"message {evt.id}") if not intent: return @@ -552,11 +550,12 @@ class Portal(DBPortal, BasePortal): continue mid = p.Puppet.get_id_from_mxid(user_id) - if mid and mid not in current_members: + is_own_puppet = p.Puppet.is_mid_for_own_puppet(mid) + if mid and mid not in current_members and not is_own_puppet: print(mid) await self.main_intent.kick_user(self.mxid, user_id, reason="User had left this chat") - elif forbid_own_puppets and p.Puppet.is_mid_for_own_puppet(mid): + elif forbid_own_puppets and is_own_puppet: await self.main_intent.kick_user(self.mxid, user_id, reason="Kicking own puppet") diff --git a/matrix_puppeteer_line/rpc/client.py b/matrix_puppeteer_line/rpc/client.py index 90ca95a..d2e00ed 100644 --- a/matrix_puppeteer_line/rpc/client.py +++ b/matrix_puppeteer_line/rpc/client.py @@ -19,7 +19,7 @@ from base64 import b64decode import asyncio from .rpc import RPCClient -from .types import ChatListInfo, ChatInfo, Message, Receipt, ImageData, StartStatus +from .types import ChatListInfo, ChatInfo, ImageData, Message, Participant, Receipt, StartStatus class LoginCommand(TypedDict): @@ -41,6 +41,9 @@ class Client(RPCClient): async def resume(self) -> None: await self.request("resume") + async def get_own_profile(self) -> Participant: + return Participant.deserialize(await self.request("get_own_profile")) + async def get_chats(self) -> List[ChatListInfo]: resp = await self.request("get_chats") return [ChatListInfo.deserialize(data) for data in resp] diff --git a/matrix_puppeteer_line/rpc/rpc.py b/matrix_puppeteer_line/rpc/rpc.py index 49e088a..3400865 100644 --- a/matrix_puppeteer_line/rpc/rpc.py +++ b/matrix_puppeteer_line/rpc/rpc.py @@ -40,10 +40,11 @@ class RPCClient: _response_waiters: Dict[int, asyncio.Future] _event_handlers: Dict[str, List[EventHandler]] - def __init__(self, user_id: UserID) -> None: + def __init__(self, user_id: UserID, own_id: str) -> None: self.log = self.log.getChild(user_id) self.loop = asyncio.get_running_loop() self.user_id = user_id + self.own_id = own_id self._req_id = 0 self._min_broadcast_id = 0 self._event_handlers = {} @@ -67,7 +68,9 @@ class RPCClient: self._writer = w self.loop.create_task(self._try_read_loop()) self.loop.create_task(self._command_loop()) - await self.request("register", user_id=self.user_id) + await self.request("register", + user_id=self.user_id, + own_id = self.own_id) async def disconnect(self) -> None: self._writer.write_eof() diff --git a/matrix_puppeteer_line/user.py b/matrix_puppeteer_line/user.py index 7fd738e..662a2f5 100644 --- a/matrix_puppeteer_line/user.py +++ b/matrix_puppeteer_line/user.py @@ -69,6 +69,14 @@ class User(DBUser, BaseUser): self.log.debug(f"Sending bridge notice: {text}") await self.az.intent.send_notice(self.notice_room, text) + @property + def own_id(self) -> str: + # Remove characters that will conflict with mxid grammar + return f"_OWN_{self.mxid[1:].replace(':', '_ON_')}" + + async def get_own_puppet(self) -> 'pu.Puppet': + return await pu.Puppet.get_by_mid(self.own_id) + async def is_logged_in(self) -> bool: try: return self.client and (await self.client.start()).is_logged_in @@ -95,7 +103,7 @@ class User(DBUser, BaseUser): async def connect(self) -> None: self.loop.create_task(self.connect_double_puppet()) - self.client = Client(self.mxid) + self.client = Client(self.mxid, self.own_id) self.log.debug("Starting client") await self.send_bridge_notice("Starting up...") state = await self.client.start() @@ -126,6 +134,7 @@ class User(DBUser, BaseUser): self._connection_check_task.cancel() self._connection_check_task = self.loop.create_task(self._check_connection_loop()) await self.client.pause() + await self.sync_own_profile() await self.client.set_last_message_ids(await DBMessage.get_max_mids()) limit = self.config["bridge.initial_conversation_sync"] self.log.info("Syncing chats") @@ -152,6 +161,12 @@ class User(DBUser, BaseUser): await portal.update_matrix_room(self, chat) await self.client.resume() + async def sync_own_profile(self) -> None: + self.log.info("Syncing own LINE profile info") + own_profile = await self.client.get_own_profile() + puppet = await self.get_own_puppet() + await puppet.update_info(own_profile, self.client) + async def stop(self) -> None: # TODO Notices for shutdown messages if self._connection_check_task: diff --git a/puppet/src/client.js b/puppet/src/client.js index 0a0a94d..89fa25b 100644 --- a/puppet/src/client.js +++ b/puppet/src/client.js @@ -164,7 +164,7 @@ export default class Client { let started = false if (this.puppet === null) { this.log("Opening new puppeteer for", this.userID) - this.puppet = new MessagesPuppeteer(this.userID, this) + this.puppet = new MessagesPuppeteer(this.userID, this.ownID, this) this.manager.puppets.set(this.userID, this.puppet) await this.puppet.start(!!req.debug) started = true @@ -194,11 +194,12 @@ export default class Client { handleRegister = async (req) => { this.userID = req.user_id - this.log("Registered socket", this.connID, "->", this.userID) + this.ownID = req.own_id + this.log(`Registered socket ${this.connID} -> ${this.userID}`) if (this.manager.clients.has(this.userID)) { const oldClient = this.manager.clients.get(this.userID) this.manager.clients.set(this.userID, this) - this.log("Terminating previous socket", oldClient.connID, "for", this.userID) + this.log(`Terminating previous socket ${oldClient.connID} for ${this.userID}`) await oldClient.stop("Socket replaced by new connection") } else { this.manager.clients.set(this.userID, this) @@ -258,6 +259,7 @@ export default class Client { set_last_message_ids: req => this.puppet.setLastMessageIDs(req.msg_ids), pause: () => this.puppet.stopObserving(), resume: () => this.puppet.startObserving(), + get_own_profile: () => this.puppet.getOwnProfile(), get_chats: () => this.puppet.getRecentChats(), get_chat: req => this.puppet.getChatInfo(req.chat_id, req.force_view), get_messages: req => this.puppet.getMessages(req.chat_id), diff --git a/puppet/src/contentscript.js b/puppet/src/contentscript.js index 06e77b0..ce36b45 100644 --- a/puppet/src/contentscript.js +++ b/puppet/src/contentscript.js @@ -102,9 +102,7 @@ class MautrixController { } setOwnID(ownID) { - // Remove characters that will conflict with mxid grammar - const suffix = ownID.slice(1).replace(":", "_ON_") - this.ownID = `_OWN_${suffix}` + this.ownID = ownID } // TODO Commonize with Node context @@ -527,7 +525,7 @@ class MautrixController { * @typedef PathImage * @type object * @property {?string} path - The virtual path of the image (behaves like an ID). Optional. - * @property {string} src - The URL of the image. Mandatory. + * @property {string} url - The URL of the image. Mandatory. */ /** @@ -683,6 +681,49 @@ class MautrixController { return promise } + /** + * Wait for updates to the active chat's message list to settle down. + * Wait an additional bit of time every time an update is observed. + * TODO Look (harder) for an explicit signal of when a chat is fully updated... + * + * @returns Promise + */ + waitForMessageListStability() { + // Increase this if messages get missed on sync / chat change. + // Decrease it if response times are too slow. + const delayMillis = 2000 + + let myResolve + const promise = new Promise(resolve => {myResolve = resolve}) + + let observer + const onTimeout = () => { + console.log("Message list looks stable, continue") + console.debug(`timeoutID = ${timeoutID}`) + observer.disconnect() + myResolve() + } + + let timeoutID + const startTimer = () => { + timeoutID = setTimeout(onTimeout, delayMillis) + } + + observer = new MutationObserver(changes => { + clearTimeout(timeoutID) + console.log("CHANGE to message list detected! Wait a bit longer...") + console.debug(`timeoutID = ${timeoutID}`) + console.debug(changes) + startTimer() + }) + observer.observe( + document.querySelector("#_chat_message_area"), + {childList: true, attributes: true, subtree: true}) + startTimer() + + return promise + } + /** * @param {[MutationRecord]} mutations - The mutation records that occurred * @private diff --git a/puppet/src/puppet.js b/puppet/src/puppet.js index 2e0342b..fbb9496 100644 --- a/puppet/src/puppet.js +++ b/puppet/src/puppet.js @@ -36,12 +36,13 @@ export default class MessagesPuppeteer { * @param {string} id * @param {?Client} [client] */ - constructor(id, client = null) { + constructor(id, ownID, client = null) { let profilePath = path.join(MessagesPuppeteer.profileDir, id) if (!profilePath.startsWith("/")) { profilePath = path.join(process.cwd(), profilePath) } this.id = id + this.ownID = ownID this.profilePath = profilePath this.updatedChats = new Set() this.sentMessageIDs = new Set() @@ -146,6 +147,18 @@ export default class MessagesPuppeteer { } } + /** + * Set the contents of a text input field to the given text. + * Works by triple-clicking the input field to select all existing text, to replace it on type. + * + * @param {ElementHandle} inputElement - The input element to type into. + * @param {string} text - The text to input. + */ + async _enterText(inputElement, text) { + await inputElement.click({clickCount: 3}) + await inputElement.type(text) + } + /** * Wait for the session to be logged in and monitor changes while it's not. */ @@ -238,7 +251,7 @@ export default class MessagesPuppeteer { this.log("Removing observers") // TODO __mautrixController is undefined when cancelling, why? - await this.page.evaluate(ownID => window.__mautrixController.setOwnID(ownID), this.id) + await this.page.evaluate(ownID => window.__mautrixController.setOwnID(ownID), this.ownID) await this.page.evaluate(() => window.__mautrixController.removeQRChangeObserver()) await this.page.evaluate(() => window.__mautrixController.removeQRAppearObserver()) await this.page.evaluate(() => window.__mautrixController.removeEmailAppearObserver()) @@ -274,15 +287,6 @@ export default class MessagesPuppeteer { this.loginRunning = false await this.blankPage.bringToFront() - // Don't start observing yet, instead wait for explicit request. - // But at least view the most recent chat. - try { - const mostRecentChatID = await this.page.$eval("#_chat_list_body li", - element => window.__mautrixController.getChatListItemID(element.firstElementChild)) - await this._switchChat(mostRecentChatID) - } catch (e) { - this.log("No chats available to focus on") - } this.log("Login complete") } @@ -464,6 +468,41 @@ export default class MessagesPuppeteer { () => window.__mautrixController.removeMsgListObserver()) } + async getOwnProfile() { + return await this.taskQueue.push(() => this._getOwnProfileUnsafe()) + } + + async _getOwnProfileUnsafe() { + // NOTE Will send a read receipt if a chat was in view! + // Best to use this on startup when no chat is viewed. + let ownProfile + await this._interactWithPage(async () => { + this.log("Opening settings view") + await this.page.click("button.mdGHD01SettingBtn") + await this.page.waitForSelector("#context_menu li#settings", {visible: true}).then(e => e.click()) + await this.page.waitForSelector("#settings_contents", {visible: true}) + + this.log("Getting own profile info") + ownProfile = { + id: this.ownID, + name: await this.page.$eval("#settings_basic_name_input", e => e.innerText), + avatar: { + path: null, + url: await this.page.$eval(".mdCMN09ImgInput", e => { + const imgStr = e.style?.getPropertyValue("background-image") + const matches = imgStr.match(/url\("(blob:.*)"\)/) + return matches?.length == 2 ? matches[1] : null + }), + }, + } + + const backSelector = "#label_setting button" + await this.page.click(backSelector) + await this.page.waitForSelector(backSelector, {visible: false}) + }) + return ownProfile + } + _listItemSelector(id) { return `#_chat_list_body div[data-chatid="${id}"]` } @@ -520,6 +559,9 @@ export default class MessagesPuppeteer { await this.page.waitForSelector("#_chat_detail_area > .mdRGT02Info") }) + this.log("Waiting for chat to stabilize") + await this.page.evaluate(() => window.__mautrixController.waitForMessageListStability()) + if (hadMsgListObserver) { this.log("Restoring msg list observer") await this.page.evaluate( @@ -595,8 +637,11 @@ export default class MessagesPuppeteer { const input = await this.page.$("#_chat_room_input") await this._interactWithPage(async () => { + // Live-typing in the field can have its text mismatch what was requested!! + // Probably because the input element is a div instead of a real text input...ugh! + // Setting its innerText directly works fine though... await input.click() - await input.type(text) + await input.evaluate((e, text) => e.innerText = text, text) await input.press("Enter") }) @@ -808,18 +853,8 @@ export default class MessagesPuppeteer { async _sendEmailCredentials() { this.log("Inputting login credentials") - - // Triple-click input fields to select all existing text and replace it on type - let input - - input = await this.page.$("#line_login_email") - await input.click({clickCount: 3}) - await input.type(this.login_email) - - input = await this.page.$("#line_login_pwd") - await input.click({clickCount: 3}) - await input.type(this.login_password) - + await this._enterText(await this.page.$("#line_login_email"), this.login_email) + await this._enterText(await this.page.$("#line_login_pwd"), this.login_password) await this.page.click("button#login_btn") }