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")