forked from fair/matrix-puppeteer-line
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:
parent
ce31caa034
commit
57c448e0c3
|
@ -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)
|
||||
|
|
|
@ -1,2 +1,3 @@
|
|||
from .auth import SECTION_AUTH
|
||||
from .conn import SECTION_CONNECTION
|
||||
from .line import SECTION_CHATS
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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.")
|
|
@ -13,7 +13,7 @@
|
|||
#
|
||||
# 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 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]
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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]:
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
#
|
||||
# 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 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]
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}")
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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...!
|
||||
|
|
|
@ -412,10 +412,20 @@ export default class MessagesPuppeteer {
|
|||
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.
|
||||
*
|
||||
* @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() {
|
||||
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")
|
||||
|
|
Loading…
Reference in New Issue