Contact syncing and invites

* Add ability to sync all LINE contacts, which is relevant because the
  list of recent chats excludes users you haven't spoken to lately.
* Add bot command to list all contacts.
* Allow inviting a puppet to a DM to create a portal for that LINE user,
  instead of having to wait for that user to message you first.
This commit is contained in:
Andrew Ferrazzutti 2021-07-19 04:10:10 -04:00
parent ce31caa034
commit 57c448e0c3
14 changed files with 194 additions and 26 deletions

View File

@ -67,6 +67,7 @@
* [x] At startup * [x] At startup
* [x] When receiving invite or message * [x] When receiving invite or message
* [ ] When sending message in new chat from LINE app * [ ] 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 * [x] Notification for message send failure
* [ ] Provisioning API for logging in * [ ] 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) * [x] Use bridge bot for messages sent from LINE app (when double-puppeting is disabled and `bridge.invite_own_puppet_to_pm` is enabled)

View File

@ -1,2 +1,3 @@
from .auth import SECTION_AUTH from .auth import SECTION_AUTH
from .conn import SECTION_CONNECTION from .conn import SECTION_CONNECTION
from .line import SECTION_CHATS

View File

@ -41,6 +41,12 @@ async def ping(evt: CommandEvent) -> None:
@command_handler(needs_auth=True, management_only=False, help_section=SECTION_CONNECTION, @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: async def sync(evt: CommandEvent) -> None:
await evt.sender.sync() 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()

View File

@ -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 <https://www.gnu.org/licenses/>.
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.")

View File

@ -13,7 +13,7 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Optional, ClassVar, TYPE_CHECKING from typing import Optional, ClassVar, List, TYPE_CHECKING
from attr import dataclass from attr import dataclass
@ -61,3 +61,10 @@ class Puppet:
if not row: if not row:
return None return None
return cls(**row) 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]

View File

@ -52,6 +52,23 @@ class MatrixHandler(BaseMatrixHandler):
await self.az.intent.send_notice(room_id, "This room has been marked as your " await self.az.intent.send_notice(room_id, "This room has been marked as your "
"LINE bridge notice room.") "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: async def handle_leave(self, room_id: RoomID, user_id: UserID, event_id: EventID) -> None:
portal = await po.Portal.get_by_mxid(room_id) portal = await po.Portal.get_by_mxid(room_id)
if not portal: if not portal:

View File

@ -189,6 +189,7 @@ class Portal(DBPortal, BasePortal):
async def handle_matrix_leave(self, user: 'u.User') -> None: async def handle_matrix_leave(self, user: 'u.User') -> None:
self.log.info(f"{user.mxid} left portal to {self.chat_id}, " self.log.info(f"{user.mxid} left portal to {self.chat_id}, "
f"cleaning up and deleting...") f"cleaning up and deleting...")
# TODO Delete room history in LINE to prevent a re-sync from happening
await self.cleanup_and_delete() await self.cleanup_and_delete()
async def _bridge_own_message_pm(self, source: 'u.User', puppet: Optional['p.Puppet'], mid: str, 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) return await self._create_matrix_room(source, info)
async def _update_matrix_room(self, source: 'u.User', info: ChatInfo) -> None: 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) await self.main_intent.invite_user(self.mxid, source.mxid, check_cache=True)
puppet = await p.Puppet.get_by_custom_mxid(source.mxid) puppet = await p.Puppet.get_by_custom_mxid(source.mxid)
if puppet and puppet.intent: if puppet and puppet.intent:
await puppet.intent.ensure_joined(self.mxid) await puppet.intent.ensure_joined(self.mxid)
await self.update_info(info, source.client)
await self.backfill(source, info) await self.backfill(source, info)
async def _create_matrix_room(self, source: 'u.User', info: ChatInfo) -> Optional[RoomID]: async def _create_matrix_room(self, source: 'u.User', info: ChatInfo) -> Optional[RoomID]:

View File

@ -13,7 +13,7 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Optional, Dict, TYPE_CHECKING, cast from typing import Optional, Dict, List, TYPE_CHECKING, cast
from mautrix.bridge import BasePuppet from mautrix.bridge import BasePuppet
from mautrix.types import UserID, ContentURI from mautrix.types import UserID, ContentURI
@ -196,8 +196,16 @@ class Puppet(DBPuppet, BasePuppet):
def is_mid_for_own_puppet(cls, mid) -> bool: def is_mid_for_own_puppet(cls, mid) -> bool:
return mid and mid.startswith("_OWN_") return mid and mid.startswith("_OWN_")
@property
def is_own_puppet(self) -> bool:
return self.mid.startswith("_OWN_")
@classmethod @classmethod
async def get_by_custom_mxid(cls, mxid: UserID) -> Optional['u.User']: async def get_by_custom_mxid(cls, mxid: UserID) -> Optional['u.User']:
if mxid == cls.config["bridge.user"]: if mxid == cls.config["bridge.user"]:
return await cls.bridge.get_user(mxid) return await cls.bridge.get_user(mxid)
return None 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]

View File

@ -40,6 +40,10 @@ class Client(RPCClient):
async def get_own_profile(self) -> Participant: async def get_own_profile(self) -> Participant:
return Participant.deserialize(await self.request("get_own_profile")) 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]: async def get_chats(self) -> List[ChatListInfo]:
resp = await self.request("get_chats") resp = await self.request("get_chats")
return [ChatListInfo.deserialize(data) for data in resp] return [ChatListInfo.deserialize(data) for data in resp]

View File

@ -35,8 +35,8 @@ class ChatListInfo(SerializableAttrs['ChatListInfo']):
id: int id: int
name: str name: str
icon: Optional[PathImage] icon: Optional[PathImage]
lastMsg: str lastMsg: Optional[str]
lastMsgDate: str lastMsgDate: Optional[str]
@dataclass @dataclass

View File

@ -132,6 +132,7 @@ class User(DBUser, BaseUser):
await asyncio.sleep(5) await asyncio.sleep(5)
async def sync(self) -> None: async def sync(self) -> None:
await self.sync_contacts()
# TODO Use some kind of async lock / event to queue syncing actions # TODO Use some kind of async lock / event to queue syncing actions
self.is_syncing = True self.is_syncing = True
if self._connection_check_task: if self._connection_check_task:
@ -146,21 +147,25 @@ class User(DBUser, BaseUser):
limit = self.config["bridge.initial_conversation_sync"] limit = self.config["bridge.initial_conversation_sync"]
self.log.info("Syncing chats") self.log.info("Syncing chats")
await self.send_bridge_notice("Synchronizing chats...") await self.send_bridge_notice("Synchronizing chats...")
chats = await self.client.get_chats()
num_created = 0 # TODO Since only chat ID is used, retrieve only that
for index, chat in enumerate(chats): chat_infos = await self.client.get_chats()
portal = await po.Portal.get_by_chat_id(chat.id, create=True) for chat_info in chat_infos[:limit]:
if portal.mxid or num_created < limit: portal = await po.Portal.get_by_chat_id(chat_info.id, create=True)
chat = await self.client.get_chat(chat.id) chat_info_full = await self.client.get_chat(chat_info.id)
if portal.mxid: await portal.create_matrix_room(self, chat_info_full)
await portal.update_matrix_room(self, chat) await self.send_bridge_notice("Chat synchronization complete")
else:
await portal.create_matrix_room(self, chat)
num_created += 1
await self.send_bridge_notice("Synchronization complete")
await self.client.resume() await self.client.resume()
self.is_syncing = False 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: async def sync_portal(self, portal: 'po.Portal') -> None:
chat_id = portal.chat_id chat_id = portal.chat_id
self.log.info(f"Viewing (and syncing) chat {chat_id}") self.log.info(f"Viewing (and syncing) chat {chat_id}")

View File

@ -261,6 +261,7 @@ export default class Client {
pause: () => this.puppet.stopObserving(), pause: () => this.puppet.stopObserving(),
resume: () => this.puppet.startObserving(), resume: () => this.puppet.startObserving(),
get_own_profile: () => this.puppet.getOwnProfile(), get_own_profile: () => this.puppet.getOwnProfile(),
get_contacts: () => this.puppet.getContacts(),
get_chats: () => this.puppet.getRecentChats(), get_chats: () => this.puppet.getRecentChats(),
get_chat: req => this.puppet.getChatInfo(req.chat_id, req.force_view), get_chat: req => this.puppet.getChatInfo(req.chat_id, req.force_view),
get_messages: req => this.puppet.getMessages(req.chat_id), get_messages: req => this.puppet.getMessages(req.chat_id),

View File

@ -845,6 +845,33 @@ class MautrixController {
return element.getAttribute("data-mid") 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. * Parse a group participants list.
* TODO Find what works for a *room* participants list...! * TODO Find what works for a *room* participants list...!

View File

@ -412,10 +412,20 @@ export default class MessagesPuppeteer {
return false return false
} }
/**
* Get all contacts in the Friends list.
*
* @return {Promise<Participant[]>}
*/
async getContacts() {
return await this.taskQueue.push(() =>
this.page.evaluate(() => window.__mautrixController.parseFriendsList()))
}
/** /**
* Get the IDs of the most recent chats. * Get the IDs of the most recent chats.
* *
* @return {Promise<[ChatListInfo]>} - List of chat IDs in order of most recent message. * @return {Promise<ChatListInfo[]>} - List of chat IDs in order of most recent message.
*/ */
async getRecentChats() { async getRecentChats() {
return await this.taskQueue.push(() => return await this.taskQueue.push(() =>
@ -425,7 +435,7 @@ export default class MessagesPuppeteer {
/** /**
* @typedef ChatInfo * @typedef ChatInfo
* @type object * @type object
* @property {[Participant]} participants * @property {Participant[]} participants
*/ */
/** /**
@ -649,14 +659,32 @@ export default class MessagesPuppeteer {
return ownProfile return ownProfile
} }
_listItemSelector(id) { _chatItemSelector(id) {
return `#_chat_list_body div[data-chatid="${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) { async _switchChat(chatID, forceView = false) {
// TODO Allow passing in an element directly // TODO Allow passing in an element directly
this.log(`Switching to chat ${chatID}`) 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( const chatName = await chatListItem.evaluate(
element => window.__mautrixController.getChatListItemName(element)) element => window.__mautrixController.getChatListItemName(element))
@ -739,10 +767,7 @@ export default class MessagesPuppeteer {
} }
async _getChatInfoUnsafe(chatID, forceView) { async _getChatInfoUnsafe(chatID, forceView) {
const chatListInfo = await this.page.$eval(this._listItemSelector(chatID), // TODO Commonize this
(element, chatID) => window.__mautrixController.parseChatListItem(element, chatID),
chatID)
let [isDirect, isGroup, isRoom] = [false,false,false] let [isDirect, isGroup, isRoom] = [false,false,false]
switch (chatID.charAt(0)) { switch (chatID.charAt(0)) {
case "u": case "u":
@ -756,6 +781,36 @@ export default class MessagesPuppeteer {
break 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 let participants
if (!isDirect) { if (!isDirect) {
this.log("Found multi-user chat, so viewing it to get participants") this.log("Found multi-user chat, so viewing it to get participants")