Support LINE users with no discoverable ID

AKA "strangers". Should only happen to non-friends in rooms (not groups!)
This commit is contained in:
Andrew Ferrazzutti 2021-06-03 01:13:00 -04:00
parent ec14b90711
commit 9d1d6e379c
8 changed files with 204 additions and 45 deletions

View File

@ -3,6 +3,7 @@ from mautrix.util.async_db import Database
from .upgrade import upgrade_table from .upgrade import upgrade_table
from .user import User from .user import User
from .puppet import Puppet from .puppet import Puppet
from .stranger import Stranger
from .portal import Portal from .portal import Portal
from .message import Message from .message import Message
from .media import Media from .media import Media
@ -10,8 +11,8 @@ from .receipt_reaction import ReceiptReaction
def init(db: Database) -> None: 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 table.db = db
__all__ = ["upgrade_table", "User", "Puppet", "Portal", "Message", "Media", "ReceiptReaction"] __all__ = ["upgrade_table", "User", "Puppet", "Stranger", "Portal", "Message", "Media", "ReceiptReaction"]

View File

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

View File

@ -109,7 +109,23 @@ async def upgrade_read_receipts(conn: Connection) -> None:
@upgrade_table.register(description="Media metadata") @upgrade_table.register(description="Media metadata")
async def upgrade_deduplicate_blob(conn: Connection) -> None: async def upgrade_deduplicate_blob(conn: Connection) -> None:
await conn.execute("""ALTER TABLE media await conn.execute("""ALTER TABLE media
ADD COLUMN IF NOT EXISTS mime_type TEXT, ADD COLUMN IF NOT EXISTS mime_type TEXT,
ADD COLUMN IF NOT EXISTS file_name TEXT, ADD COLUMN IF NOT EXISTS file_name TEXT,
ADD COLUMN IF NOT EXISTS size INTEGER 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
)""")

View File

@ -205,29 +205,34 @@ class Portal(DBPortal, BasePortal):
intent = None intent = None
return intent return intent
async def handle_remote_message(self, source: 'u.User', sender: Optional['p.Puppet'], async def handle_remote_message(self, source: 'u.User', evt: Message) -> None:
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 evt.is_outgoing:
if source.intent: if source.intent:
sender = None
intent = source.intent intent = source.intent
else: else:
if not self.invite_own_puppet_to_pm: if not self.invite_own_puppet_to_pm:
self.log.warning(f"Ignoring message {evt.id}: double puppeting isn't enabled") self.log.warning(f"Ignoring message {evt.id}: double puppeting isn't enabled")
return 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}") intent = await self._bridge_own_message_pm(source, sender, f"message {evt.id}")
if not intent: if not intent:
return return
elif self.other_user:
intent = (await p.Puppet.get_by_mid(self.other_user)).intent
elif sender:
intent = sender.intent
else: else:
self.log.warning(f"Ignoring message {evt.id}: sender puppet is unavailable") sender = await p.Puppet.get_by_mid(self.other_user if self.is_direct else evt.sender.id)
return # TODO Respond to name/avatar changes of users in a DM
if not self.is_direct:
if await DBMessage.get_by_mid(evt.id): if sender:
self.log.debug(f"Ignoring duplicate message {evt.id}") await sender.update_info(evt.sender, source.client)
return 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 evt.image and evt.image.url:
if not evt.image.is_sticker or self.config["bridge.receive_stickers"]: 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, format=Format.HTML if msg_html else None,
body=msg_text, formatted_body=msg_html) body=msg_text, formatted_body=msg_html)
event_id = await self._send_message(intent, content, timestamp=evt.timestamp) 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: else:
content = TextMessageEventContent( content = TextMessageEventContent(
msgtype=MessageType.NOTICE, msgtype=MessageType.NOTICE,
@ -349,11 +357,13 @@ class Portal(DBPortal, BasePortal):
if reaction: if reaction:
await self.main_intent.redact(self.mxid, reaction.mxid) await self.main_intent.redact(self.mxid, reaction.mxid)
await reaction.delete() 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... # TODO Not just -1 if there are multiple _OWN_ puppets...
if receipt_count == len(self._last_participant_update) - 1: 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): for mid in filter(lambda mid: not p.Puppet.is_mid_for_own_puppet(mid), self._last_participant_update):
puppet = await p.Puppet.get_by_mid(participant) intent = (await p.Puppet.get_by_mid(mid)).intent
await puppet.intent.send_receipt(self.mxid, event_id) await intent.send_receipt(self.mxid, event_id)
else: else:
# TODO Translatable string for "Read by" # TODO Translatable string for "Read by"
reaction_mxid = await self.main_intent.react(self.mxid, event_id, f"(Read by {receipt_count})") 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: if self._main_intent is self.az.intent:
self._main_intent = (await p.Puppet.get_by_mid(self.other_user)).intent self._main_intent = (await p.Puppet.get_by_mid(self.other_user)).intent
for participant in conv.participants: for participant in conv.participants:
puppet = await p.Puppet.get_by_mid(participant.id) # REMINDER: multi-user chats include your own LINE user in the participant list
await puppet.update_info(participant, client) 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. # TODO Consider setting no room name for non-group chats.
# But then the LINE bot itself may appear in the title... # But then the LINE bot itself may appear in the title...
changed = await self._update_name(f"{conv.name} (LINE)") changed = await self._update_name(f"{conv.name} (LINE)")
@ -480,7 +495,12 @@ class Portal(DBPortal, BasePortal):
return return
# Store the current member list to prevent unnecessary updates # 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: if current_members == self._last_participant_update:
self.log.trace("Not updating participants: list matches cached list") self.log.trace("Not updating participants: list matches cached list")
return return
@ -495,7 +515,7 @@ class Portal(DBPortal, BasePortal):
for participant in participants: for participant in participants:
if forbid_own_puppets and p.Puppet.is_mid_for_own_puppet(participant.id): if forbid_own_puppets and p.Puppet.is_mid_for_own_puppet(participant.id):
continue 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) await intent.ensure_joined(self.mxid)
print(current_members) print(current_members)
@ -536,15 +556,8 @@ class Portal(DBPortal, BasePortal):
self.log.debug("Got %d messages from server", len(messages)) self.log.debug("Got %d messages from server", len(messages))
async with NotificationDisabler(self.mxid, source): 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: for evt in messages:
puppet = await p.Puppet.get_by_mid(evt.sender.id) if not self.is_direct else None await self.handle_remote_message(source, evt)
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)
self.log.info("Backfilled %d messages through %s", len(messages), source.mxid) self.log.info("Backfilled %d messages through %s", len(messages), source.mxid)
@property @property
@ -680,10 +693,8 @@ class Portal(DBPortal, BasePortal):
self.by_mxid[self.mxid] = self self.by_mxid[self.mxid] = self
await self.backfill(source) await self.backfill(source)
if not self.is_direct: if not self.is_direct:
# For multi-user chats, backfill before updating participants, # TODO Joins and leaves are (usually) shown after all, so track them properly.
# to act as as a best guess of when users actually joined. # In the meantime, just check the participants list after backfilling.
# No way to tell when a user actually left, so just check the
# participants list after backfilling.
await self._update_participants(info.participants) await self._update_participants(info.participants)
return self.mxid return self.mxid

View File

@ -19,7 +19,7 @@ from mautrix.bridge import BasePuppet
from mautrix.types import UserID, ContentURI from mautrix.types import UserID, ContentURI
from mautrix.util.simple_template import SimpleTemplate 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 .config import Config
from .rpc import Participant, Client, PathImage from .rpc import Participant, Client, PathImage
from . import user as u from . import user as u
@ -141,6 +141,9 @@ class Puppet(DBPuppet, BasePuppet):
@classmethod @classmethod
async def get_by_mid(cls, mid: str, create: bool = True) -> Optional['Puppet']: 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" # TODO Might need to parse a real id from "_OWN"
try: try:
return cls.by_mid[mid] return cls.by_mid[mid]
@ -160,10 +163,38 @@ class Puppet(DBPuppet, BasePuppet):
return None 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 # TODO When supporting multiple bridge users, this should return the user whose puppet this is
@classmethod @classmethod
def is_mid_for_own_puppet(cls, mid) -> bool: def is_mid_for_own_puppet(cls, mid) -> bool:
return mid.startswith("_OWN_") if mid else False return mid and 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']:

View File

@ -41,9 +41,9 @@ class ChatListInfo(SerializableAttrs['ChatListInfo']):
@dataclass @dataclass
class Participant(SerializableAttrs['Participant']): class Participant(SerializableAttrs['Participant']):
id: str
avatar: Optional[PathImage]
name: str name: str
avatar: Optional[PathImage]
id: Optional[str] = None
@dataclass @dataclass

View File

@ -162,12 +162,11 @@ class User(DBUser, BaseUser):
async def handle_message(self, evt: Message) -> None: async def handle_message(self, evt: Message) -> None:
self.log.trace("Received message %s", evt) self.log.trace("Received message %s", evt)
portal = await po.Portal.get_by_chat_id(evt.chat_id, create=True) 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: if not portal.mxid:
await self.client.set_last_message_ids(await DBMessage.get_max_mids()) await self.client.set_last_message_ids(await DBMessage.get_max_mids())
chat_info = await self.client.get_chat(evt.chat_id) chat_info = await self.client.get_chat(evt.chat_id)
await portal.create_matrix_room(self, chat_info) 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: async def handle_receipt(self, receipt: Receipt) -> None:
self.log.trace(f"Received receipt for chat {receipt.chat_id}") self.log.trace(f"Received receipt for chat {receipt.chat_id}")

View File

@ -238,10 +238,18 @@ class MautrixController {
sender.id = this.getUserIdFromFriendsList(sender.name) sender.id = this.getUserIdFromFriendsList(sender.name)
// Group members aren't necessarily friends, // Group members aren't necessarily friends,
// but the participant list includes their ID. // but the participant list includes their ID.
// ROOMS DO NOT!! Ugh.
if (!sender.id) { if (!sender.id) {
const participantsList = document.querySelector(participantsListSelector) const participantsList = document.querySelector(participantsListSelector)
imgElement = participantsList.querySelector(`img[alt='${sender.name}'`) // Groups use a participant's name as the alt text of their avatar image,
sender.id = imgElement.parentElement.parentElement.getAttribute("data-mid") // 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 { } else {
imgElement = element.querySelector(".mdRGT07Img > img") imgElement = element.querySelector(".mdRGT07Img > img")
} }