diff --git a/ROADMAP.md b/ROADMAP.md index c4c297f..7f58cce 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -67,6 +67,7 @@ * [x] At startup * [x] When receiving invite or message * [ ] When sending message in new chat from LINE app + * [x] Private chat creation by inviting Matrix puppet of LINE user to new room * [x] Notification for message send failure * [ ] Provisioning API for logging in * [x] Use bridge bot for messages sent from LINE app (when double-puppeting is disabled and `bridge.invite_own_puppet_to_pm` is enabled) diff --git a/matrix_puppeteer_line/commands/__init__.py b/matrix_puppeteer_line/commands/__init__.py index 88a4bf0..83c67a1 100644 --- a/matrix_puppeteer_line/commands/__init__.py +++ b/matrix_puppeteer_line/commands/__init__.py @@ -1,2 +1,3 @@ from .auth import SECTION_AUTH from .conn import SECTION_CONNECTION +from .line import SECTION_CHATS diff --git a/matrix_puppeteer_line/commands/conn.py b/matrix_puppeteer_line/commands/conn.py index 21cf4c1..3630880 100644 --- a/matrix_puppeteer_line/commands/conn.py +++ b/matrix_puppeteer_line/commands/conn.py @@ -41,6 +41,12 @@ async def ping(evt: CommandEvent) -> None: @command_handler(needs_auth=True, management_only=False, help_section=SECTION_CONNECTION, - help_text="Synchronize portals") + help_text="Synchronize contacts and portals") async def sync(evt: CommandEvent) -> None: await evt.sender.sync() + + +@command_handler(needs_auth=True, management_only=False, help_section=SECTION_CONNECTION, + help_text="Synchronize contacts") +async def sync_contacts(evt: CommandEvent) -> None: + await evt.sender.sync_contacts() diff --git a/matrix_puppeteer_line/commands/line.py b/matrix_puppeteer_line/commands/line.py new file mode 100644 index 0000000..2551465 --- /dev/null +++ b/matrix_puppeteer_line/commands/line.py @@ -0,0 +1,34 @@ +# matrix-puppeteer-line - A very hacky Matrix-LINE bridge based on running LINE's Chrome extension in Puppeteer +# Copyright (C) 2020-2021 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 . +from mautrix.bridge.commands import HelpSection, command_handler + +from .. import puppet as pu +from .typehint import CommandEvent + +SECTION_CHATS = HelpSection("Contacts & Chats", 40, "") + + +@command_handler(needs_auth=True, management_only=False, help_section=SECTION_CHATS, + help_text="List all LINE contacts") +async def list_contacts(evt: CommandEvent) -> None: + # TODO Use a generator if it's worth it + puppets = await pu.Puppet.get_all() + results = "".join(f"* [{puppet.name}](https://matrix.to/#/{puppet.default_mxid})\n" + for puppet in puppets) + if results: + await evt.reply(f"Contacts:\n\n{results}") + else: + await evt.reply("No contacts found.") \ No newline at end of file diff --git a/matrix_puppeteer_line/db/puppet.py b/matrix_puppeteer_line/db/puppet.py index f9346c6..23cc42b 100644 --- a/matrix_puppeteer_line/db/puppet.py +++ b/matrix_puppeteer_line/db/puppet.py @@ -13,7 +13,7 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Optional, ClassVar, TYPE_CHECKING +from typing import Optional, ClassVar, List, TYPE_CHECKING from attr import dataclass @@ -61,3 +61,10 @@ class Puppet: if not row: return None return cls(**row) + + @classmethod + async def get_all(cls) -> List['Puppet']: + q = ("SELECT mid, name, avatar_path, avatar_mxc, name_set, avatar_set, is_registered " + "FROM puppet") + rows = await cls.db.fetch(q) + return [cls(**row) for row in rows] diff --git a/matrix_puppeteer_line/matrix.py b/matrix_puppeteer_line/matrix.py index 7ac8f84..7f5ca68 100644 --- a/matrix_puppeteer_line/matrix.py +++ b/matrix_puppeteer_line/matrix.py @@ -52,6 +52,23 @@ class MatrixHandler(BaseMatrixHandler): await self.az.intent.send_notice(room_id, "This room has been marked as your " "LINE bridge notice room.") + async def handle_puppet_invite(self, room_id: RoomID, puppet: 'pu.Puppet', + invited_by: 'u.User', _: EventID) -> None: + chat_id = puppet.mid + portal = await po.Portal.get_by_chat_id(chat_id, create=True) + if portal.mxid: + # TODO Allow creating a LINE group/room from a Matrix invite + await portal.main_intent.error_and_leave(room_id, "You already have an existing chat with me!") + return + portal.mxid = room_id + # TODO Put pause/resume in portal methods, with a lock or something + await invited_by.client.pause() + try: + chat_info = await invited_by.client.get_chat(chat_id) + await portal.update_matrix_room(invited_by, chat_info) + finally: + await invited_by.client.resume() + async def handle_leave(self, room_id: RoomID, user_id: UserID, event_id: EventID) -> None: portal = await po.Portal.get_by_mxid(room_id) if not portal: diff --git a/matrix_puppeteer_line/portal.py b/matrix_puppeteer_line/portal.py index 085ed95..a97a0fe 100644 --- a/matrix_puppeteer_line/portal.py +++ b/matrix_puppeteer_line/portal.py @@ -189,6 +189,7 @@ class Portal(DBPortal, BasePortal): async def handle_matrix_leave(self, user: 'u.User') -> None: self.log.info(f"{user.mxid} left portal to {self.chat_id}, " f"cleaning up and deleting...") + # TODO Delete room history in LINE to prevent a re-sync from happening await self.cleanup_and_delete() async def _bridge_own_message_pm(self, source: 'u.User', puppet: Optional['p.Puppet'], mid: str, @@ -711,12 +712,13 @@ class Portal(DBPortal, BasePortal): return await self._create_matrix_room(source, info) async def _update_matrix_room(self, source: 'u.User', info: ChatInfo) -> None: + await self.update_info(info, source.client) + await self.main_intent.invite_user(self.mxid, source.mxid, check_cache=True) puppet = await p.Puppet.get_by_custom_mxid(source.mxid) if puppet and puppet.intent: await puppet.intent.ensure_joined(self.mxid) - await self.update_info(info, source.client) await self.backfill(source, info) async def _create_matrix_room(self, source: 'u.User', info: ChatInfo) -> Optional[RoomID]: diff --git a/matrix_puppeteer_line/puppet.py b/matrix_puppeteer_line/puppet.py index 8bf73c0..027df9a 100644 --- a/matrix_puppeteer_line/puppet.py +++ b/matrix_puppeteer_line/puppet.py @@ -13,7 +13,7 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Optional, Dict, TYPE_CHECKING, cast +from typing import Optional, Dict, List, TYPE_CHECKING, cast from mautrix.bridge import BasePuppet from mautrix.types import UserID, ContentURI @@ -196,8 +196,16 @@ class Puppet(DBPuppet, BasePuppet): def is_mid_for_own_puppet(cls, mid) -> bool: return mid and mid.startswith("_OWN_") + @property + def is_own_puppet(self) -> bool: + return self.mid.startswith("_OWN_") + @classmethod async def get_by_custom_mxid(cls, mxid: UserID) -> Optional['u.User']: if mxid == cls.config["bridge.user"]: return await cls.bridge.get_user(mxid) return None + + @classmethod + async def get_all(cls) -> List['Puppet']: + return [p for p in await super().get_all() if not p.is_own_puppet] diff --git a/matrix_puppeteer_line/rpc/client.py b/matrix_puppeteer_line/rpc/client.py index 5592fd4..6304385 100644 --- a/matrix_puppeteer_line/rpc/client.py +++ b/matrix_puppeteer_line/rpc/client.py @@ -40,6 +40,10 @@ class Client(RPCClient): async def get_own_profile(self) -> Participant: return Participant.deserialize(await self.request("get_own_profile")) + async def get_contacts(self) -> List[Participant]: + resp = await self.request("get_contacts") + return [Participant.deserialize(data) for data in resp] + 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/types.py b/matrix_puppeteer_line/rpc/types.py index 73be388..456068e 100644 --- a/matrix_puppeteer_line/rpc/types.py +++ b/matrix_puppeteer_line/rpc/types.py @@ -35,8 +35,8 @@ class ChatListInfo(SerializableAttrs['ChatListInfo']): id: int name: str icon: Optional[PathImage] - lastMsg: str - lastMsgDate: str + lastMsg: Optional[str] + lastMsgDate: Optional[str] @dataclass diff --git a/matrix_puppeteer_line/user.py b/matrix_puppeteer_line/user.py index 65b7b15..8e0f1f8 100644 --- a/matrix_puppeteer_line/user.py +++ b/matrix_puppeteer_line/user.py @@ -132,6 +132,7 @@ class User(DBUser, BaseUser): await asyncio.sleep(5) async def sync(self) -> None: + await self.sync_contacts() # TODO Use some kind of async lock / event to queue syncing actions self.is_syncing = True if self._connection_check_task: @@ -146,21 +147,25 @@ class User(DBUser, BaseUser): limit = self.config["bridge.initial_conversation_sync"] self.log.info("Syncing chats") await self.send_bridge_notice("Synchronizing chats...") - chats = await self.client.get_chats() - num_created = 0 - for index, chat in enumerate(chats): - portal = await po.Portal.get_by_chat_id(chat.id, create=True) - if portal.mxid or num_created < limit: - chat = await self.client.get_chat(chat.id) - if portal.mxid: - await portal.update_matrix_room(self, chat) - else: - await portal.create_matrix_room(self, chat) - num_created += 1 - await self.send_bridge_notice("Synchronization complete") + + # TODO Since only chat ID is used, retrieve only that + chat_infos = await self.client.get_chats() + for chat_info in chat_infos[:limit]: + portal = await po.Portal.get_by_chat_id(chat_info.id, create=True) + chat_info_full = await self.client.get_chat(chat_info.id) + await portal.create_matrix_room(self, chat_info_full) + await self.send_bridge_notice("Chat synchronization complete") await self.client.resume() self.is_syncing = False + async def sync_contacts(self) -> None: + await self.send_bridge_notice("Synchronizing contacts...") + contacts = await self.client.get_contacts() + for contact in contacts: + puppet = await pu.Puppet.get_by_mid(contact.id) + await puppet.update_info(contact, self.client) + await self.send_bridge_notice("Contact synchronization complete") + async def sync_portal(self, portal: 'po.Portal') -> None: chat_id = portal.chat_id self.log.info(f"Viewing (and syncing) chat {chat_id}") diff --git a/puppet/src/client.js b/puppet/src/client.js index 5193ff6..7ddf600 100644 --- a/puppet/src/client.js +++ b/puppet/src/client.js @@ -261,6 +261,7 @@ export default class Client { pause: () => this.puppet.stopObserving(), resume: () => this.puppet.startObserving(), get_own_profile: () => this.puppet.getOwnProfile(), + get_contacts: () => this.puppet.getContacts(), 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 637feb4..67de2c8 100644 --- a/puppet/src/contentscript.js +++ b/puppet/src/contentscript.js @@ -845,6 +845,33 @@ class MautrixController { return element.getAttribute("data-mid") } + /** + * Parse a friends list item element. + * + * @param {Element} element - The element to parse. + * @param {?string} knownID - The ID of this element, if it is known. + * @return {Participant} - The info in the element. + */ + parseFriendsListItem(element, knownID) { + return { + id: knownID || this.getFriendsListItemID(element), + avatar: this.getFriendsListItemAvatar(element), + name: this.getFriendsListItemName(element), + } + } + + /** + * Parse the friends list. + * + * @return {Participant[]} + */ + parseFriendsList() { + const friends = [] + document.querySelectorAll("#contact_wrap_friends > ul > li[data-mid]") + .forEach(e => friends.push(this.parseFriendsListItem(e))) + return friends + } + /** * Parse a group participants list. * TODO Find what works for a *room* participants list...! diff --git a/puppet/src/puppet.js b/puppet/src/puppet.js index 8d658a5..0b51636 100644 --- a/puppet/src/puppet.js +++ b/puppet/src/puppet.js @@ -412,10 +412,20 @@ export default class MessagesPuppeteer { return false } + /** + * Get all contacts in the Friends list. + * + * @return {Promise} + */ + async getContacts() { + return await this.taskQueue.push(() => + this.page.evaluate(() => window.__mautrixController.parseFriendsList())) + } + /** * Get the IDs of the most recent chats. * - * @return {Promise<[ChatListInfo]>} - List of chat IDs in order of most recent message. + * @return {Promise} - List of chat IDs in order of most recent message. */ async getRecentChats() { return await this.taskQueue.push(() => @@ -425,7 +435,7 @@ export default class MessagesPuppeteer { /** * @typedef ChatInfo * @type object - * @property {[Participant]} participants + * @property {Participant[]} participants */ /** @@ -649,14 +659,32 @@ export default class MessagesPuppeteer { return ownProfile } - _listItemSelector(id) { + _chatItemSelector(id) { return `#_chat_list_body div[data-chatid="${id}"]` } + _friendItemSelector(id) { + return `#contact_wrap_friends > ul > li[data-mid="${id}"]` + } + async _switchChat(chatID, forceView = false) { // TODO Allow passing in an element directly this.log(`Switching to chat ${chatID}`) - const chatListItem = await this.page.$(this._listItemSelector(chatID)) + let chatListItem = await this.page.$(this._chatItemSelector(chatID)) + if (!chatListItem) { + this.log(`Chat ${chatID} not in recents list`) + if (chatID.charAt(0) == 'u') { + const friendsListItem = await this.page.$(this._friendItemSelector(chatID)) + if (!friendsListItem) { + throw `Cannot find friend with ID ${chatID}` + } + friendsListItem.evaluate(e => e.click()) // Evaluate in browser context to avoid having to view tab + } else { + // TODO + throw "Can't yet get info of new groups/rooms" + } + chatListItem = await this.page.waitForSelector(this._chatItemSelector(chatID)) + } const chatName = await chatListItem.evaluate( element => window.__mautrixController.getChatListItemName(element)) @@ -739,10 +767,7 @@ export default class MessagesPuppeteer { } async _getChatInfoUnsafe(chatID, forceView) { - const chatListInfo = await this.page.$eval(this._listItemSelector(chatID), - (element, chatID) => window.__mautrixController.parseChatListItem(element, chatID), - chatID) - + // TODO Commonize this let [isDirect, isGroup, isRoom] = [false,false,false] switch (chatID.charAt(0)) { case "u": @@ -756,6 +781,36 @@ export default class MessagesPuppeteer { break } + const chatListItem = await this.page.$(this._chatItemSelector(chatID)) + if (!chatListItem) { + if (isDirect) { + const friendsListItem = await this.page.$(this._friendItemSelector(chatID)) + if (!friendsListItem) { + throw `Cannot find friend with ID ${chatID}` + } + const friendsListInfo = await friendsListItem.evaluate( + (element, chatID) => window.__mautrixController.parseFriendsListItem(element, chatID), + chatID) + + this.log(`Found NEW direct chat with ${chatID}`) + return { + participants: [friendsListInfo], + id: chatID, + name: friendsListInfo.name, + icon: friendsListInfo.avatar, + lastMsg: null, + lastMsgDate: null, + } + } else { + // TODO + throw "Can't yet get info of new groups/rooms" + } + } + + const chatListInfo = await chatListItem.evaluate( + (element, chatID) => window.__mautrixController.parseChatListItem(element, chatID), + chatID) + let participants if (!isDirect) { this.log("Found multi-user chat, so viewing it to get participants")