diff --git a/matrix_puppeteer_line/db/__init__.py b/matrix_puppeteer_line/db/__init__.py index 666c340..62784dc 100644 --- a/matrix_puppeteer_line/db/__init__.py +++ b/matrix_puppeteer_line/db/__init__.py @@ -3,6 +3,7 @@ from mautrix.util.async_db import Database from .upgrade import upgrade_table from .user import User from .puppet import Puppet +from .stranger import Stranger from .portal import Portal from .message import Message from .media import Media @@ -10,8 +11,8 @@ from .receipt_reaction import ReceiptReaction def init(db: Database) -> None: - for table in (User, Puppet, Portal, Message, Media, ReceiptReaction): + for table in (User, Puppet, Stranger, Portal, Message, Media, ReceiptReaction): table.db = db -__all__ = ["upgrade_table", "User", "Puppet", "Portal", "Message", "Media", "ReceiptReaction"] +__all__ = ["upgrade_table", "User", "Puppet", "Stranger", "Portal", "Message", "Media", "ReceiptReaction"] diff --git a/matrix_puppeteer_line/db/stranger.py b/matrix_puppeteer_line/db/stranger.py new file mode 100644 index 0000000..5de8a2e --- /dev/null +++ b/matrix_puppeteer_line/db/stranger.py @@ -0,0 +1,93 @@ +# 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 typing import Optional, ClassVar, TYPE_CHECKING + +from attr import dataclass +from random import randint, seed + +from mautrix.util.async_db import Database + +fake_db = Database("") if TYPE_CHECKING else None + + +@dataclass +class Stranger: + db: ClassVar[Database] = fake_db + + # Optional properties are ones that should be set by Puppet + fake_mid: str + name: Optional[str] = None + avatar_path: Optional[str] = None + available: bool = False + + async def insert(self) -> None: + q = ("INSERT INTO stranger (fake_mid, name, avatar_path, available) " + "VALUES ($1, $2, $3, $4)") + await self.db.execute(q, self.fake_mid, self.name, self.avatar_path, self.available) + + async def update_profile_info(self) -> None: + q = ("UPDATE stranger SET name=$2, avatar_path=$3 " + "WHERE fake_mid=$1") + await self.db.execute(q, self.fake_mid, self.name, self.avatar_path) + + async def make_available(self) -> None: + q = ("UPDATE stranger SET available=true " + "WHERE name=$1 AND avatar_path=$2") + await self.db.execute(q, self.name, self.avatar_path) + + @classmethod + async def get_by_mid(cls, mid: str) -> Optional['Stranger']: + q = ("SELECT fake_mid, name, avatar_path, available " + "FROM stranger WHERE fake_mid=$1") + row = await cls.db.fetchrow(q, mid) + if not row: + return None + return cls(**row) + + @classmethod + async def get_by_profile(cls, info: 'Participant') -> Optional['Stranger']: + q = ("SELECT fake_mid, name, avatar_path, available " + "FROM stranger WHERE name=$1 AND avatar_path=$2") + row = await cls.db.fetchrow(q, info.name, info.avatar.path if info.avatar else "") + if not row: + return None + return cls(**row) + + @classmethod + async def get_any_available(cls) -> Optional['Stranger']: + q = ("SELECT fake_mid, name, avatar_path, available " + "FROM stranger WHERE available=true") + row = await cls.db.fetchrow(q) + if not row: + return None + return cls(**row) + + @classmethod + async def init_available_or_new(cls) -> 'Stranger': + stranger = await cls.get_any_available() + if not stranger: + while True: + fake_mid = "_STRANGER_" + for _ in range(32): + fake_mid += f"{randint(0,15):x}" + if await cls.get_by_mid(fake_mid) != None: + # Extremely unlikely event of a randomly-generated ID colliding with another. + # If it happens, must be not that unlikely after all, so pick a new seed. + seed() + else: + stranger = cls(fake_mid) + break + return stranger \ No newline at end of file diff --git a/matrix_puppeteer_line/db/upgrade.py b/matrix_puppeteer_line/db/upgrade.py index d863009..f3e96f9 100644 --- a/matrix_puppeteer_line/db/upgrade.py +++ b/matrix_puppeteer_line/db/upgrade.py @@ -109,7 +109,23 @@ async def upgrade_read_receipts(conn: Connection) -> None: @upgrade_table.register(description="Media metadata") async def upgrade_deduplicate_blob(conn: Connection) -> None: await conn.execute("""ALTER TABLE media - ADD COLUMN IF NOT EXISTS mime_type TEXT, - ADD COLUMN IF NOT EXISTS file_name TEXT, - ADD COLUMN IF NOT EXISTS size INTEGER - """) \ No newline at end of file + ADD COLUMN IF NOT EXISTS mime_type TEXT, + ADD COLUMN IF NOT EXISTS file_name TEXT, + ADD COLUMN IF NOT EXISTS size INTEGER + """) + + +@upgrade_table.register(description="Strangers") +async def upgrade_strangers(conn: Connection) -> None: + await conn.execute(""" + CREATE TABLE IF NOT EXISTS stranger ( + fake_mid TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + avatar_path TEXT NOT NULL, + available BOOLEAN NOT NULL DEFAULT false, + + PRIMARY KEY (name, avatar_path), + FOREIGN KEY (fake_mid) + REFERENCES puppet (mid) + ON DELETE CASCADE + )""") \ No newline at end of file diff --git a/matrix_puppeteer_line/portal.py b/matrix_puppeteer_line/portal.py index be357db..f03d86c 100644 --- a/matrix_puppeteer_line/portal.py +++ b/matrix_puppeteer_line/portal.py @@ -205,29 +205,34 @@ class Portal(DBPortal, BasePortal): intent = None return intent - async def handle_remote_message(self, source: 'u.User', sender: Optional['p.Puppet'], - evt: Message) -> None: + async def handle_remote_message(self, source: 'u.User', evt: Message) -> None: + if await DBMessage.get_by_mid(evt.id): + self.log.debug(f"Ignoring duplicate message {evt.id}") + return + if evt.is_outgoing: if source.intent: + sender = None intent = source.intent else: 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 intent = await self._bridge_own_message_pm(source, sender, f"message {evt.id}") if not intent: return - elif self.other_user: - intent = (await p.Puppet.get_by_mid(self.other_user)).intent - elif sender: - intent = sender.intent else: - self.log.warning(f"Ignoring message {evt.id}: sender puppet is unavailable") - return - - if await DBMessage.get_by_mid(evt.id): - self.log.debug(f"Ignoring duplicate message {evt.id}") - return + sender = await p.Puppet.get_by_mid(self.other_user if self.is_direct else evt.sender.id) + # TODO Respond to name/avatar changes of users in a DM + if not self.is_direct: + if sender: + await sender.update_info(evt.sender, source.client) + else: + self.log.warning(f"Could not find ID of LINE user who sent event {evt.id}") + sender = await p.Puppet.get_by_profile(evt.sender, source.client) + intent = sender.intent + intent.ensure_joined(self.mxid) if evt.image and evt.image.url: if not evt.image.is_sticker or self.config["bridge.receive_stickers"]: @@ -318,6 +323,9 @@ class Portal(DBPortal, BasePortal): format=Format.HTML if msg_html else None, body=msg_text, formatted_body=msg_html) event_id = await self._send_message(intent, content, timestamp=evt.timestamp) + # TODO Joins/leaves/invites/rejects, which are sent as LINE message events after all! + # Also keep track of strangers who leave / get blocked / become friends + # (maybe not here for all of that) else: content = TextMessageEventContent( msgtype=MessageType.NOTICE, @@ -349,11 +357,13 @@ class Portal(DBPortal, BasePortal): if reaction: await self.main_intent.redact(self.mxid, reaction.mxid) await reaction.delete() + # If there are as many receipts as there are chat participants, then everyone + # must have read the message, so send real read receipts from each puppet. # TODO Not just -1 if there are multiple _OWN_ puppets... if receipt_count == len(self._last_participant_update) - 1: - for participant in filter(lambda participant: not p.Puppet.is_mid_for_own_puppet(participant), self._last_participant_update): - puppet = await p.Puppet.get_by_mid(participant) - await puppet.intent.send_receipt(self.mxid, event_id) + for mid in filter(lambda mid: not p.Puppet.is_mid_for_own_puppet(mid), self._last_participant_update): + intent = (await p.Puppet.get_by_mid(mid)).intent + await intent.send_receipt(self.mxid, event_id) else: # TODO Translatable string for "Read by" reaction_mxid = await self.main_intent.react(self.mxid, event_id, f"(Read by {receipt_count})") @@ -418,8 +428,13 @@ class Portal(DBPortal, BasePortal): if self._main_intent is self.az.intent: self._main_intent = (await p.Puppet.get_by_mid(self.other_user)).intent for participant in conv.participants: - puppet = await p.Puppet.get_by_mid(participant.id) - await puppet.update_info(participant, client) + # REMINDER: multi-user chats include your own LINE user in the participant list + if participant.id != None: + puppet = await p.Puppet.get_by_mid(participant.id, client) + await puppet.update_info(participant, client) + else: + self.log.warning(f"Could not find ID of LINE user {participant.name}") + puppet = await p.Puppet.get_by_profile(participant, client) # TODO Consider setting no room name for non-group chats. # But then the LINE bot itself may appear in the title... changed = await self._update_name(f"{conv.name} (LINE)") @@ -480,7 +495,12 @@ class Portal(DBPortal, BasePortal): return # Store the current member list to prevent unnecessary updates - current_members = {participant.id for participant in participants} + current_members = set() + for participant in participants: + current_members.add( + participant.id if participant.id != None else \ + (await p.Puppet.get_by_profile(participant)).mid) + if current_members == self._last_participant_update: self.log.trace("Not updating participants: list matches cached list") return @@ -495,7 +515,7 @@ class Portal(DBPortal, BasePortal): for participant in participants: if forbid_own_puppets and p.Puppet.is_mid_for_own_puppet(participant.id): continue - intent = (await p.Puppet.get_by_mid(participant.id)).intent + intent = (await p.Puppet.get_by_sender(participant)).intent await intent.ensure_joined(self.mxid) print(current_members) @@ -536,15 +556,8 @@ class Portal(DBPortal, BasePortal): self.log.debug("Got %d messages from server", len(messages)) async with NotificationDisabler(self.mxid, source): - # Member joins/leaves are not shown in chat history. - # Best we can do is have a puppet join if its user had sent a message. - members_known = set(await self.main_intent.get_room_members(self.mxid)) if not self.is_direct else None for evt in messages: - puppet = await p.Puppet.get_by_mid(evt.sender.id) if not self.is_direct else None - if puppet and evt.sender.id not in members_known: - await puppet.update_info(evt.sender, source.client) - members_known.add(evt.sender.id) - await self.handle_remote_message(source, puppet, evt) + await self.handle_remote_message(source, evt) self.log.info("Backfilled %d messages through %s", len(messages), source.mxid) @property @@ -680,10 +693,8 @@ class Portal(DBPortal, BasePortal): self.by_mxid[self.mxid] = self await self.backfill(source) if not self.is_direct: - # For multi-user chats, backfill before updating participants, - # to act as as a best guess of when users actually joined. - # No way to tell when a user actually left, so just check the - # participants list after backfilling. + # TODO Joins and leaves are (usually) shown after all, so track them properly. + # In the meantime, just check the participants list after backfilling. await self._update_participants(info.participants) return self.mxid diff --git a/matrix_puppeteer_line/puppet.py b/matrix_puppeteer_line/puppet.py index 9a3c626..8bf73c0 100644 --- a/matrix_puppeteer_line/puppet.py +++ b/matrix_puppeteer_line/puppet.py @@ -19,7 +19,7 @@ from mautrix.bridge import BasePuppet from mautrix.types import UserID, ContentURI from mautrix.util.simple_template import SimpleTemplate -from .db import Puppet as DBPuppet +from .db import Puppet as DBPuppet, Stranger from .config import Config from .rpc import Participant, Client, PathImage from . import user as u @@ -141,6 +141,9 @@ class Puppet(DBPuppet, BasePuppet): @classmethod async def get_by_mid(cls, mid: str, create: bool = True) -> Optional['Puppet']: + if mid is None: + return None + # TODO Might need to parse a real id from "_OWN" try: return cls.by_mid[mid] @@ -160,10 +163,38 @@ class Puppet(DBPuppet, BasePuppet): return None + @classmethod + async def get_by_profile(cls, info: Participant, client: Optional[Client] = None) -> 'Puppet': + stranger = await Stranger.get_by_profile(info) + if not stranger: + stranger = await Stranger.init_available_or_new() + + puppet = cls(stranger.fake_mid) + # NOTE An update will insert anyways, so just do it now + await puppet.insert() + await puppet.update_info(info, client) + puppet._add_to_cache() + + # Get path from puppet in case it uses the URL as the path. + # But that should never happen in practice for strangers, + # which should only occur in rooms, where avatars have paths. + stranger.avatar_path = puppet.avatar_path + stranger.name = info.name + await stranger.insert() + # TODO Need a way to keep stranger name/avatar up to date, + # lest name/avatar changes get seen as another stranger. + # Also need to detect when a stranger becomes a friend. + return await cls.get_by_mid(stranger.fake_mid) + + @classmethod + async def get_by_sender(cls, info: Participant, client: Optional[Client] = None) -> 'Puppet': + puppet = await cls.get_by_mid(info.id) + return puppet if puppet else await cls.get_by_profile(info, client) + # TODO When supporting multiple bridge users, this should return the user whose puppet this is @classmethod def is_mid_for_own_puppet(cls, mid) -> bool: - return mid.startswith("_OWN_") if mid else False + return mid and mid.startswith("_OWN_") @classmethod async def get_by_custom_mxid(cls, mxid: UserID) -> Optional['u.User']: diff --git a/matrix_puppeteer_line/rpc/types.py b/matrix_puppeteer_line/rpc/types.py index 9b96413..af27117 100644 --- a/matrix_puppeteer_line/rpc/types.py +++ b/matrix_puppeteer_line/rpc/types.py @@ -41,9 +41,9 @@ class ChatListInfo(SerializableAttrs['ChatListInfo']): @dataclass class Participant(SerializableAttrs['Participant']): - id: str - avatar: Optional[PathImage] name: str + avatar: Optional[PathImage] + id: Optional[str] = None @dataclass diff --git a/matrix_puppeteer_line/user.py b/matrix_puppeteer_line/user.py index c7344ca..daa21a6 100644 --- a/matrix_puppeteer_line/user.py +++ b/matrix_puppeteer_line/user.py @@ -162,12 +162,11 @@ class User(DBUser, BaseUser): async def handle_message(self, evt: Message) -> None: self.log.trace("Received message %s", evt) portal = await po.Portal.get_by_chat_id(evt.chat_id, create=True) - puppet = await pu.Puppet.get_by_mid(evt.sender.id) if not portal.is_direct else None if not portal.mxid: await self.client.set_last_message_ids(await DBMessage.get_max_mids()) chat_info = await self.client.get_chat(evt.chat_id) await portal.create_matrix_room(self, chat_info) - await portal.handle_remote_message(self, puppet, evt) + await portal.handle_remote_message(self, evt) async def handle_receipt(self, receipt: Receipt) -> None: self.log.trace(f"Received receipt for chat {receipt.chat_id}") diff --git a/puppet/src/contentscript.js b/puppet/src/contentscript.js index 394af5a..226a7c4 100644 --- a/puppet/src/contentscript.js +++ b/puppet/src/contentscript.js @@ -238,10 +238,18 @@ class MautrixController { sender.id = this.getUserIdFromFriendsList(sender.name) // Group members aren't necessarily friends, // but the participant list includes their ID. + // ROOMS DO NOT!! Ugh. if (!sender.id) { const participantsList = document.querySelector(participantsListSelector) - imgElement = participantsList.querySelector(`img[alt='${sender.name}'`) - sender.id = imgElement.parentElement.parentElement.getAttribute("data-mid") + // Groups use a participant's name as the alt text of their avatar image, + // but rooms do not...ARGH! But they both use a dedicated element for it. + const participantNameElement = + Array.from(participantsList.querySelectorAll(`.mdRGT13Ttl`)) + .find(e => e.innerText == sender.name) + if (participantNameElement) { + imgElement = participantNameElement.previousElementSibling.firstElementChild + sender.id = imgElement?.parentElement.parentElement.getAttribute("data-mid") + } } else { imgElement = element.querySelector(".mdRGT07Img > img") }