forked from fair/matrix-puppeteer-line
Compare commits
9 Commits
3286d7e6e2
...
6ac15333f8
Author | SHA1 | Date |
---|---|---|
Cristian Le | 6ac15333f8 | |
Cristian Le | 92fd74afa2 | |
Andrew Ferrazzutti | 47a0284e81 | |
Andrew Ferrazzutti | b06e4532a1 | |
Andrew Ferrazzutti | 8613ad1256 | |
Andrew Ferrazzutti | 3c5c8cd610 | |
Andrew Ferrazzutti | 54099caf87 | |
Andrew Ferrazzutti | a3195955cc | |
Andrew Ferrazzutti | 3cca9f9606 |
|
@ -52,7 +52,7 @@
|
||||||
* [x] Rooms (unnamed chats / "multi-user direct chats")
|
* [x] Rooms (unnamed chats / "multi-user direct chats")
|
||||||
* [ ] Membership actions
|
* [ ] Membership actions
|
||||||
* [x] Add member
|
* [x] Add member
|
||||||
* [x] Remove member
|
* [ ] Remove member
|
||||||
* [ ] Block
|
* [ ] Block
|
||||||
* Misc
|
* Misc
|
||||||
* [x] Automatic portal creation
|
* [x] Automatic portal creation
|
||||||
|
|
2
SETUP.md
2
SETUP.md
|
@ -38,3 +38,5 @@ An easy way to do so is to install `xvfb` from your distribution, and run the Pu
|
||||||
|
|
||||||
# Upgrading
|
# Upgrading
|
||||||
Simply `git pull` or `git rebase` the latest changes, and rerun any installation commands (`yarn --production`, `pip install -Ur ...`).
|
Simply `git pull` or `git rebase` the latest changes, and rerun any installation commands (`yarn --production`, `pip install -Ur ...`).
|
||||||
|
|
||||||
|
To upgrade the LINE extension used by Puppeteer, simply download and extract the latest .crx in the same location as for initial setup.
|
||||||
|
|
|
@ -73,7 +73,10 @@ class Config(BaseBridgeConfig):
|
||||||
copy("bridge.delivery_receipts")
|
copy("bridge.delivery_receipts")
|
||||||
copy("bridge.delivery_error_reports")
|
copy("bridge.delivery_error_reports")
|
||||||
copy("bridge.resend_bridge_info")
|
copy("bridge.resend_bridge_info")
|
||||||
copy("bridge.command_prefix")
|
copy("bridge.receive_stickers")
|
||||||
|
copy("bridge.resend_bridge_info")
|
||||||
|
copy("bridge.use_sticker_events")
|
||||||
|
copy("bridge.emoji_scale_factor")
|
||||||
copy("bridge.user")
|
copy("bridge.user")
|
||||||
|
|
||||||
copy("puppeteer.connection.type")
|
copy("puppeteer.connection.type")
|
||||||
|
|
|
@ -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"]
|
||||||
|
|
|
@ -29,12 +29,14 @@ class Media:
|
||||||
|
|
||||||
media_id: str
|
media_id: str
|
||||||
mxc: ContentURI
|
mxc: ContentURI
|
||||||
# TODO Consider whether mime_type, file_name, and size are needed.
|
mime_type: str
|
||||||
|
file_name: str
|
||||||
|
size: int
|
||||||
|
|
||||||
async def insert(self) -> None:
|
async def insert(self) -> None:
|
||||||
q = ("INSERT INTO media (media_id, mxc) "
|
q = ("INSERT INTO media (media_id, mxc, mime_type, file_name, size) "
|
||||||
"VALUES ($1, $2)")
|
"VALUES ($1, $2, $3, $4, $5)")
|
||||||
await self.db.execute(q, self.media_id, self.mxc)
|
await self.db.execute(q, self.media_id, self.mxc, self.mime_type, self.file_name, self.size)
|
||||||
|
|
||||||
async def update(self) -> None:
|
async def update(self) -> None:
|
||||||
q = ("UPDATE media SET mxc=$2 "
|
q = ("UPDATE media SET mxc=$2 "
|
||||||
|
@ -42,8 +44,8 @@ class Media:
|
||||||
await self.db.execute(q, self.media_id, self.mxc)
|
await self.db.execute(q, self.media_id, self.mxc)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_by_id(cls, media_id: str) -> Optional[ContentURI]:
|
async def get_by_id(cls, media_id: str) -> Optional['DBMedia']:
|
||||||
q = ("SELECT media_id, mxc "
|
q = ("SELECT media_id, mxc, mime_type, file_name, size "
|
||||||
"FROM media WHERE media_id=$1")
|
"FROM media WHERE media_id=$1")
|
||||||
row = await cls.db.fetchrow(q, media_id)
|
row = await cls.db.fetchrow(q, media_id)
|
||||||
if not row:
|
if not row:
|
||||||
|
|
|
@ -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
|
|
@ -104,3 +104,28 @@ async def upgrade_read_receipts(conn: Connection) -> None:
|
||||||
REFERENCES portal (mxid)
|
REFERENCES portal (mxid)
|
||||||
ON DELETE CASCADE
|
ON DELETE CASCADE
|
||||||
)""")
|
)""")
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
)""")
|
|
@ -131,6 +131,12 @@ bridge:
|
||||||
# This field will automatically be changed back to false after it,
|
# This field will automatically be changed back to false after it,
|
||||||
# except if the config file is not writable.
|
# except if the config file is not writable.
|
||||||
resend_bridge_info: false
|
resend_bridge_info: false
|
||||||
|
# Set this to false to disable bridging stickers and emoji.
|
||||||
|
receive_stickers: true
|
||||||
|
# Set this to false to use m.image events for stickers instead of m.sticker.
|
||||||
|
use_sticker_events: true
|
||||||
|
# The scale by which to display emojis with. Must be a positive integer.
|
||||||
|
emoji_scale_factor: 1
|
||||||
|
|
||||||
# The prefix for commands. Only required in non-management rooms.
|
# The prefix for commands. Only required in non-management rooms.
|
||||||
command_prefix: "!line"
|
command_prefix: "!line"
|
||||||
|
|
|
@ -29,14 +29,14 @@ from mautrix.types import (EventID, MessageEventContent, RoomID, EventType, Mess
|
||||||
TextMessageEventContent, MediaMessageEventContent, Membership, Format,
|
TextMessageEventContent, MediaMessageEventContent, Membership, Format,
|
||||||
ContentURI, EncryptedFile, ImageInfo,
|
ContentURI, EncryptedFile, ImageInfo,
|
||||||
RelatesTo, RelationType)
|
RelatesTo, RelationType)
|
||||||
from mautrix.errors import IntentError, MatrixError
|
from mautrix.errors import IntentError
|
||||||
from mautrix.util.simple_lock import SimpleLock
|
from mautrix.util.simple_lock import SimpleLock
|
||||||
|
|
||||||
from .db import Portal as DBPortal, Message as DBMessage, ReceiptReaction as DBReceiptReaction, Media as DBMedia
|
from .db import Portal as DBPortal, Message as DBMessage, ReceiptReaction as DBReceiptReaction, Media as DBMedia
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .rpc import ChatInfo, Participant, Message, Receipt, Client, PathImage
|
from .rpc import ChatInfo, Participant, Message, Receipt, Client, PathImage
|
||||||
from .rpc.types import RPCError
|
from .rpc.types import RPCError
|
||||||
from . import user as u, puppet as p, matrix as m
|
from . import user as u, puppet as p
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .__main__ import MessagesBridge
|
from .__main__ import MessagesBridge
|
||||||
|
@ -48,9 +48,9 @@ except ImportError:
|
||||||
|
|
||||||
StateBridge = EventType.find("m.bridge", EventType.Class.STATE)
|
StateBridge = EventType.find("m.bridge", EventType.Class.STATE)
|
||||||
StateHalfShotBridge = EventType.find("uk.half-shot.bridge", EventType.Class.STATE)
|
StateHalfShotBridge = EventType.find("uk.half-shot.bridge", EventType.Class.STATE)
|
||||||
ReuploadedMediaInfo = NamedTuple('ReuploadedMediaInfo', mxc=Optional[ContentURI],
|
MediaInfo = NamedTuple('MediaInfo', mxc=Optional[ContentURI],
|
||||||
decryption_info=Optional[EncryptedFile],
|
decryption_info=Optional[EncryptedFile],
|
||||||
mime_type=str, file_name=str, size=int)
|
mime_type=str, file_name=str, size=int)
|
||||||
|
|
||||||
|
|
||||||
class Portal(DBPortal, BasePortal):
|
class Portal(DBPortal, BasePortal):
|
||||||
|
@ -112,6 +112,7 @@ class Portal(DBPortal, BasePortal):
|
||||||
cls.loop = bridge.loop
|
cls.loop = bridge.loop
|
||||||
cls.bridge = bridge
|
cls.bridge = bridge
|
||||||
cls.invite_own_puppet_to_pm = cls.config["bridge.invite_own_puppet_to_pm"]
|
cls.invite_own_puppet_to_pm = cls.config["bridge.invite_own_puppet_to_pm"]
|
||||||
|
cls.emoji_scale_factor = max(int(cls.config["bridge.emoji_scale_factor"]), 1)
|
||||||
NotificationDisabler.puppet_cls = p.Puppet
|
NotificationDisabler.puppet_cls = p.Puppet
|
||||||
NotificationDisabler.config_enabled = cls.config["bridge.backfill.disable_notifications"]
|
NotificationDisabler.config_enabled = cls.config["bridge.backfill.disable_notifications"]
|
||||||
|
|
||||||
|
@ -170,7 +171,7 @@ class Portal(DBPortal, BasePortal):
|
||||||
self.log.debug(f"Handled Matrix message {event_id} -> {message_id}")
|
self.log.debug(f"Handled Matrix message {event_id} -> {message_id}")
|
||||||
except UniqueViolationError as e:
|
except UniqueViolationError as e:
|
||||||
self.log.warning(f"Failed to handle Matrix message {event_id} -> {message_id}: {e}")
|
self.log.warning(f"Failed to handle Matrix message {event_id} -> {message_id}: {e}")
|
||||||
if not msg:
|
if not msg and self.config["bridge.delivery_error_reports"]:
|
||||||
await self.main_intent.send_notice(
|
await self.main_intent.send_notice(
|
||||||
self.mxid,
|
self.mxid,
|
||||||
"Posting this message to LINE may have failed.",
|
"Posting this message to LINE may have failed.",
|
||||||
|
@ -179,12 +180,6 @@ 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...")
|
||||||
if self.invite_own_puppet_to_pm:
|
|
||||||
# TODO Use own puppet instead of bridge bot. Then cleanup_and_delete will handle it
|
|
||||||
try:
|
|
||||||
await self.az.intent.leave_room(self.mxid)
|
|
||||||
except MatrixError:
|
|
||||||
pass
|
|
||||||
await self.cleanup_and_delete()
|
await self.cleanup_and_delete()
|
||||||
|
|
||||||
async def _bridge_own_message_pm(self, source: 'u.User', sender: Optional['p.Puppet'], mid: str,
|
async def _bridge_own_message_pm(self, source: 'u.User', sender: Optional['p.Puppet'], mid: str,
|
||||||
|
@ -210,39 +205,67 @@ 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 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 await DBMessage.get_by_mid(evt.id):
|
if evt.image and evt.image.url:
|
||||||
self.log.debug(f"Ignoring duplicate message {evt.id}")
|
if not evt.image.is_sticker or self.config["bridge.receive_stickers"]:
|
||||||
return
|
media_info = await self._handle_remote_media(
|
||||||
|
source, intent, evt.image.url,
|
||||||
event_id = None
|
deduplicate=not self.encrypted and evt.image.is_sticker)
|
||||||
if evt.image_url:
|
image_info = ImageInfo(
|
||||||
# TODO Deduplicate stickers, but only if encryption is disabled
|
# Element Web doesn't animate PNGs, but setting the mimetype to GIF works.
|
||||||
content = await self._handle_remote_photo(source, intent, evt)
|
# (PNG stickers never animate, and PNG images only animate after being clicked on.)
|
||||||
if not content:
|
# Making this exception since E.W. seems to be the only client that supports inline animated stickers & images.
|
||||||
content = TextMessageEventContent(
|
# TODO Open an E.W. issue for this
|
||||||
msgtype=MessageType.NOTICE,
|
# TODO Test Element Android
|
||||||
body="<unbridgeable media>")
|
# TODO Find & test other non-GIF formats for animated images
|
||||||
event_id = await self._send_message(intent, content, timestamp=evt.timestamp)
|
mimetype="image/gif" if evt.image.is_animated and media_info.mime_type == "image/png" else media_info.mime_type,
|
||||||
|
size=media_info.size) if media_info else None
|
||||||
|
else:
|
||||||
|
media_info = None
|
||||||
|
send_sticker = self.config["bridge.use_sticker_events"] and evt.image.is_sticker and not self.encrypted and media_info
|
||||||
|
if send_sticker:
|
||||||
|
event_id = await intent.send_sticker(
|
||||||
|
self.mxid, media_info.mxc, image_info, "<sticker>", timestamp=evt.timestamp)
|
||||||
|
else:
|
||||||
|
if media_info:
|
||||||
|
content = MediaMessageEventContent(
|
||||||
|
url=media_info.mxc, file=media_info.decryption_info,
|
||||||
|
msgtype=MessageType.IMAGE,
|
||||||
|
body=media_info.file_name,
|
||||||
|
info=image_info)
|
||||||
|
else:
|
||||||
|
content = TextMessageEventContent(
|
||||||
|
msgtype=MessageType.NOTICE,
|
||||||
|
body=f"<{'sticker' if evt.image.is_sticker else 'image'}>")
|
||||||
|
event_id = await self._send_message(intent, content, timestamp=evt.timestamp)
|
||||||
elif evt.html and not evt.html.isspace():
|
elif evt.html and not evt.html.isspace():
|
||||||
chunks = []
|
chunks = []
|
||||||
|
|
||||||
|
@ -251,12 +274,11 @@ class Portal(DBPortal, BasePortal):
|
||||||
chunks.append({"type": "data", "data": data})
|
chunks.append({"type": "data", "data": data})
|
||||||
|
|
||||||
def handle_starttag(tag, attrs):
|
def handle_starttag(tag, attrs):
|
||||||
if tag == "img":
|
obj = {"type": tag}
|
||||||
obj = {"type": tag}
|
for attr in attrs:
|
||||||
for attr in attrs:
|
obj[attr[0]] = attr[1]
|
||||||
obj[attr[0]] = attr[1]
|
nonlocal chunks
|
||||||
nonlocal chunks
|
chunks.append(obj)
|
||||||
chunks.append(obj)
|
|
||||||
|
|
||||||
parser = HTMLParser()
|
parser = HTMLParser()
|
||||||
parser.handle_data = handle_data
|
parser.handle_data = handle_data
|
||||||
|
@ -268,11 +290,17 @@ class Portal(DBPortal, BasePortal):
|
||||||
|
|
||||||
for chunk in chunks:
|
for chunk in chunks:
|
||||||
ctype = chunk["type"]
|
ctype = chunk["type"]
|
||||||
if ctype == "data":
|
if ctype == "br":
|
||||||
|
msg_text += "\n"
|
||||||
|
if not msg_html:
|
||||||
|
msg_html = msg_text
|
||||||
|
msg_html += "<br>"
|
||||||
|
elif ctype == "data":
|
||||||
msg_text += chunk["data"]
|
msg_text += chunk["data"]
|
||||||
if msg_html:
|
if msg_html:
|
||||||
msg_html += chunk["data"]
|
msg_html += chunk["data"]
|
||||||
elif ctype == "img":
|
elif ctype == "img":
|
||||||
|
height = int(chunk.get("height", 19)) * self.emoji_scale_factor
|
||||||
cclass = chunk["class"]
|
cclass = chunk["class"]
|
||||||
if cclass == "emojione":
|
if cclass == "emojione":
|
||||||
alt = chunk["alt"]
|
alt = chunk["alt"]
|
||||||
|
@ -283,11 +311,11 @@ class Portal(DBPortal, BasePortal):
|
||||||
media_id = f'{chunk.get("data-stickon-pkg-cd", 0)}/{chunk.get("data-stickon-stk-cd", 0)}'
|
media_id = f'{chunk.get("data-stickon-pkg-cd", 0)}/{chunk.get("data-stickon-stk-cd", 0)}'
|
||||||
|
|
||||||
# NOTE Not encrypting content linked to by HTML tags
|
# NOTE Not encrypting content linked to by HTML tags
|
||||||
if not self.encrypted:
|
if not self.encrypted and self.config["bridge.receive_stickers"]:
|
||||||
media_mxc = await self._get_mxc_for_remote_media(source, intent, chunk["src"], media_id)
|
media_info = await self._handle_remote_media(source, intent, chunk["src"], media_id, deduplicate=True)
|
||||||
if not msg_html:
|
if not msg_html:
|
||||||
msg_html = msg_text
|
msg_html = msg_text
|
||||||
msg_html += f'<img data-mx-emoticon src="{media_mxc}" alt="{alt}" title="{alt}" height="32">'
|
msg_html += f'<img data-mx-emoticon src="{media_info.mxc}" alt="{alt}" title="{alt}" height="{height}">'
|
||||||
msg_text += alt
|
msg_text += alt
|
||||||
|
|
||||||
content = TextMessageEventContent(
|
content = TextMessageEventContent(
|
||||||
|
@ -295,16 +323,24 @@ 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)
|
||||||
if event_id:
|
# TODO Joins/leaves/invites/rejects, which are sent as LINE message events after all!
|
||||||
if evt.is_outgoing and evt.receipt_count:
|
# Also keep track of strangers who leave / get blocked / become friends
|
||||||
await self._handle_receipt(event_id, evt.receipt_count)
|
# (maybe not here for all of that)
|
||||||
msg = DBMessage(mxid=event_id, mx_room=self.mxid, mid=evt.id, chat_id=self.chat_id)
|
else:
|
||||||
try:
|
content = TextMessageEventContent(
|
||||||
await msg.insert()
|
msgtype=MessageType.NOTICE,
|
||||||
await self._send_delivery_receipt(event_id)
|
body="<Unbridgeable message>")
|
||||||
self.log.debug(f"Handled remote message {evt.id} -> {event_id}")
|
event_id = await self._send_message(intent, content, timestamp=evt.timestamp)
|
||||||
except UniqueViolationError as e:
|
|
||||||
self.log.debug(f"Failed to handle remote message {evt.id} -> {event_id}: {e}")
|
if evt.is_outgoing and evt.receipt_count:
|
||||||
|
await self._handle_receipt(event_id, evt.receipt_count)
|
||||||
|
msg = DBMessage(mxid=event_id, mx_room=self.mxid, mid=evt.id, chat_id=self.chat_id)
|
||||||
|
try:
|
||||||
|
await msg.insert()
|
||||||
|
await self._send_delivery_receipt(event_id)
|
||||||
|
self.log.debug(f"Handled remote message {evt.id} -> {event_id}")
|
||||||
|
except UniqueViolationError as e:
|
||||||
|
self.log.debug(f"Failed to handle remote message {evt.id} -> {event_id}: {e}")
|
||||||
|
|
||||||
async def handle_remote_receipt(self, receipt: Receipt) -> None:
|
async def handle_remote_receipt(self, receipt: Receipt) -> None:
|
||||||
msg = await DBMessage.get_by_mid(receipt.id)
|
msg = await DBMessage.get_by_mid(receipt.id)
|
||||||
|
@ -321,46 +357,46 @@ 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...
|
||||||
if receipt_count == len(self._last_participant_update) - 1:
|
if receipt_count == len(self._last_participant_update) - 1:
|
||||||
for participant in 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.id)
|
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})")
|
||||||
await DBReceiptReaction(reaction_mxid, self.mxid, event_id, receipt_count).insert()
|
await DBReceiptReaction(reaction_mxid, self.mxid, event_id, receipt_count).insert()
|
||||||
|
|
||||||
async def _handle_remote_photo(self, source: 'u.User', intent: IntentAPI, message: Message
|
async def _handle_remote_media(self, source: 'u.User', intent: IntentAPI,
|
||||||
) -> Optional[MediaMessageEventContent]:
|
media_url: str, media_id: Optional[str] = None,
|
||||||
try:
|
deduplicate: bool = False) -> MediaInfo:
|
||||||
resp = await source.client.read_image(message.image_url)
|
|
||||||
except (RPCError, TypeError) as e:
|
|
||||||
self.log.warning(f"Failed to download remote photo from chat {self.chat_id}: {e}")
|
|
||||||
return None
|
|
||||||
media_info = await self._reupload_remote_media(resp.data, intent, resp.mime)
|
|
||||||
return MediaMessageEventContent(url=media_info.mxc, file=media_info.decryption_info,
|
|
||||||
msgtype=MessageType.IMAGE, body=media_info.file_name,
|
|
||||||
info=ImageInfo(mimetype=media_info.mime_type, size=media_info.size))
|
|
||||||
|
|
||||||
async def _get_mxc_for_remote_media(self, source: 'u.User', intent: IntentAPI,
|
|
||||||
media_url: str, media_id: Optional[str] = None
|
|
||||||
) -> ContentURI:
|
|
||||||
if not media_id:
|
if not media_id:
|
||||||
media_id = media_url
|
media_id = media_url
|
||||||
media_info = await DBMedia.get_by_id(media_id)
|
db_media_info = await DBMedia.get_by_id(media_id) if deduplicate else None
|
||||||
if not media_info:
|
if not db_media_info:
|
||||||
self.log.debug(f"Did not find existing mxc URL for {media_id}, uploading media now")
|
# NOTE Blob URL of stickers only persists for a single session...still better than nothing.
|
||||||
resp = await source.client.read_image(media_url)
|
self.log.debug(f"{'Did not find existing mxc URL for' if deduplicate else 'Not deduplicating'} {media_id}, uploading media now")
|
||||||
media_info = await self._reupload_remote_media(resp.data, intent, resp.mime, disable_encryption=True)
|
try:
|
||||||
await DBMedia(media_id=media_id, mxc=media_info.mxc).insert()
|
resp = await source.client.read_image(media_url)
|
||||||
self.log.debug(f"Uploaded media as {media_info.mxc}")
|
except (RPCError, TypeError) as e:
|
||||||
|
self.log.warning(f"Failed to download remote media from chat {self.chat_id}: {e}")
|
||||||
|
return None
|
||||||
|
media_info = await self._reupload_remote_media(resp.data, intent, resp.mime, disable_encryption=deduplicate)
|
||||||
|
if deduplicate:
|
||||||
|
await DBMedia(
|
||||||
|
media_id=media_id, mxc=media_info.mxc,
|
||||||
|
size=media_info.size, mime_type=media_info.mime_type, file_name=media_info.file_name
|
||||||
|
).insert()
|
||||||
|
return media_info
|
||||||
else:
|
else:
|
||||||
self.log.debug(f"Found existing mxc URL for {media_id}: {media_info.mxc}")
|
self.log.debug(f"Found existing mxc URL for {media_id}: {db_media_info.mxc}")
|
||||||
return media_info.mxc
|
return MediaInfo(db_media_info.mxc, None, db_media_info.mime_type, db_media_info.file_name, db_media_info.size)
|
||||||
|
|
||||||
async def _reupload_remote_media(self, data: bytes, intent: IntentAPI,
|
async def _reupload_remote_media(self, data: bytes, intent: IntentAPI,
|
||||||
mime_type: str = None, file_name: str = None,
|
mime_type: str = None, file_name: str = None,
|
||||||
disable_encryption: bool = True) -> ReuploadedMediaInfo:
|
disable_encryption: bool = True) -> MediaInfo:
|
||||||
if not mime_type:
|
if not mime_type:
|
||||||
mime_type = magic.from_buffer(data, mime=True)
|
mime_type = magic.from_buffer(data, mime=True)
|
||||||
upload_mime_type = mime_type
|
upload_mime_type = mime_type
|
||||||
|
@ -378,10 +414,13 @@ class Portal(DBPortal, BasePortal):
|
||||||
filename=upload_file_name)
|
filename=upload_file_name)
|
||||||
|
|
||||||
if decryption_info:
|
if decryption_info:
|
||||||
|
self.log.debug(f"Uploaded encrypted media as {mxc}")
|
||||||
decryption_info.url = mxc
|
decryption_info.url = mxc
|
||||||
mxc = None
|
mxc = None
|
||||||
|
else:
|
||||||
|
self.log.debug(f"Uploaded media as {mxc}")
|
||||||
|
|
||||||
return ReuploadedMediaInfo(mxc, decryption_info, mime_type, file_name, len(data))
|
return MediaInfo(mxc, decryption_info, mime_type, file_name, len(data))
|
||||||
|
|
||||||
async def update_info(self, conv: ChatInfo, client: Optional[Client]) -> None:
|
async def update_info(self, conv: ChatInfo, client: Optional[Client]) -> None:
|
||||||
if self.is_direct:
|
if self.is_direct:
|
||||||
|
@ -389,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)")
|
||||||
|
@ -418,25 +462,45 @@ class Portal(DBPortal, BasePortal):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def _update_icon(self, icon: Optional[PathImage], client: Client) -> bool:
|
async def _update_icon(self, icon: Optional[PathImage], client: Client) -> bool:
|
||||||
icon_path = icon.path if icon else None
|
if icon:
|
||||||
|
if icon.url and not icon.path:
|
||||||
|
self.log.warn(f"Using URL as path for room icon of {self.name}")
|
||||||
|
icon_path = icon_url = icon.url
|
||||||
|
else:
|
||||||
|
icon_path = icon.path
|
||||||
|
icon_url = icon.url
|
||||||
|
else:
|
||||||
|
icon_path = icon_url = None
|
||||||
|
|
||||||
if icon_path != self.icon_path:
|
if icon_path != self.icon_path:
|
||||||
|
self.log.info(f"Updating room icon of {self.name}")
|
||||||
self.icon_path = icon_path
|
self.icon_path = icon_path
|
||||||
if icon and icon.url:
|
if icon_url:
|
||||||
resp = await client.read_image(icon.url)
|
resp = await client.read_image(icon.url)
|
||||||
self.icon_mxc = await self.main_intent.upload_media(resp.data, mime_type=resp.mime)
|
self.icon_mxc = await self.main_intent.upload_media(resp.data, mime_type=resp.mime)
|
||||||
else:
|
else:
|
||||||
self.icon_mxc = ContentURI("")
|
self.icon_mxc = ContentURI("")
|
||||||
if self.mxid:
|
if self.mxid:
|
||||||
await self.main_intent.set_room_avatar(self.mxid, self.icon_mxc)
|
try:
|
||||||
|
await self.main_intent.set_room_avatar(self.mxid, self.icon_mxc)
|
||||||
|
except Exception as e:
|
||||||
|
self.log.exception(f"Failed to set room icon: {e}")
|
||||||
return True
|
return True
|
||||||
return False
|
else:
|
||||||
|
self.log.debug(f"No need to update room icon of {self.name}, new icon has same path as old one")
|
||||||
|
return False
|
||||||
|
|
||||||
async def _update_participants(self, participants: List[Participant]) -> None:
|
async def _update_participants(self, participants: List[Participant]) -> None:
|
||||||
if not self.mxid:
|
if not self.mxid:
|
||||||
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
|
||||||
|
@ -449,10 +513,10 @@ class Portal(DBPortal, BasePortal):
|
||||||
|
|
||||||
# Make sure puppets who should be here are here
|
# Make sure puppets who should be here are here
|
||||||
for participant in participants:
|
for participant in participants:
|
||||||
puppet = await p.Puppet.get_by_mid(participant.id)
|
|
||||||
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
|
||||||
await puppet.intent.ensure_joined(self.mxid)
|
intent = (await p.Puppet.get_by_sender(participant)).intent
|
||||||
|
await intent.ensure_joined(self.mxid)
|
||||||
|
|
||||||
print(current_members)
|
print(current_members)
|
||||||
|
|
||||||
|
@ -492,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
|
||||||
|
@ -578,12 +635,15 @@ class Portal(DBPortal, BasePortal):
|
||||||
"content": self.bridge_info,
|
"content": self.bridge_info,
|
||||||
}]
|
}]
|
||||||
invites = [source.mxid]
|
invites = [source.mxid]
|
||||||
|
|
||||||
if self.config["bridge.encryption.default"] and self.matrix.e2ee:
|
if self.config["bridge.encryption.default"] and self.matrix.e2ee:
|
||||||
self.encrypted = True
|
self.encrypted = True
|
||||||
initial_state.append({
|
initial_state.append({
|
||||||
"type": str(EventType.ROOM_ENCRYPTION),
|
"type": str(EventType.ROOM_ENCRYPTION),
|
||||||
"content": {"algorithm": "m.megolm.v1.aes-sha2"},
|
"content": {"algorithm": "m.megolm.v1.aes-sha2"},
|
||||||
})
|
})
|
||||||
|
if self.is_direct:
|
||||||
|
invites.append(self.az.bot_mxid)
|
||||||
# NOTE Set the room title even for direct chats, because
|
# NOTE Set the room title even for direct chats, because
|
||||||
# the LINE bot itself may appear in the title otherwise.
|
# the LINE bot itself may appear in the title otherwise.
|
||||||
#if self.encrypted or not self.is_direct:
|
#if self.encrypted or not self.is_direct:
|
||||||
|
@ -599,9 +659,11 @@ class Portal(DBPortal, BasePortal):
|
||||||
"users": {
|
"users": {
|
||||||
self.az.bot_mxid: 100,
|
self.az.bot_mxid: 100,
|
||||||
self.main_intent.mxid: 100,
|
self.main_intent.mxid: 100,
|
||||||
|
source.mxid: 25,
|
||||||
},
|
},
|
||||||
"events": {
|
"events": {
|
||||||
str(EventType.REACTION): 1
|
str(EventType.REACTION): 100,
|
||||||
|
str(EventType.ROOM_ENCRYPTION): 25,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -634,10 +696,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
|
||||||
|
|
|
@ -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
|
||||||
|
@ -85,14 +85,24 @@ class Puppet(DBPuppet, BasePuppet):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def _update_avatar(self, avatar: Optional[PathImage], client: Client) -> bool:
|
async def _update_avatar(self, avatar: Optional[PathImage], client: Client) -> bool:
|
||||||
if avatar and avatar.url and not avatar.path:
|
if avatar:
|
||||||
# Avatar exists, but in a form that cannot be uniquely identified.
|
if avatar.url and not avatar.path:
|
||||||
# Skip it for now.
|
if self.avatar_set and self.avatar_path:
|
||||||
return False
|
self.log.warn(f"Not updating user avatar of {self.name}: new avatar exists, but in a form that cannot be uniquely identified")
|
||||||
avatar_path = avatar.path if avatar else None
|
return False
|
||||||
if avatar_path != self.avatar_path or not self.avatar_set:
|
else:
|
||||||
|
self.log.warn(f"Using URL as path for user avatar of {self.name}: no previous avatar exists")
|
||||||
|
avatar_path = avatar_url = avatar.url
|
||||||
|
else:
|
||||||
|
avatar_path = avatar.path
|
||||||
|
avatar_url = avatar.url
|
||||||
|
else:
|
||||||
|
avatar_path = avatar_url = None
|
||||||
|
|
||||||
|
if not self.avatar_set or avatar_path != self.avatar_path:
|
||||||
|
self.log.info(f"Updating user avatar of {self.name}")
|
||||||
self.avatar_path = avatar_path
|
self.avatar_path = avatar_path
|
||||||
if avatar and avatar.url:
|
if avatar_url:
|
||||||
resp = await client.read_image(avatar.url)
|
resp = await client.read_image(avatar.url)
|
||||||
self.avatar_mxc = await self.intent.upload_media(resp.data, mime_type=resp.mime)
|
self.avatar_mxc = await self.intent.upload_media(resp.data, mime_type=resp.mime)
|
||||||
else:
|
else:
|
||||||
|
@ -100,11 +110,13 @@ class Puppet(DBPuppet, BasePuppet):
|
||||||
try:
|
try:
|
||||||
await self.intent.set_avatar_url(self.avatar_mxc)
|
await self.intent.set_avatar_url(self.avatar_mxc)
|
||||||
self.avatar_set = True
|
self.avatar_set = True
|
||||||
except Exception:
|
except Exception as e:
|
||||||
self.log.exception("Failed to set user avatar")
|
self.log.exception(f"Failed to set user avatar: {e}")
|
||||||
self.avatar_set = False
|
self.avatar_set = False
|
||||||
return True
|
return True
|
||||||
return False
|
else:
|
||||||
|
self.log.debug(f"No need to update user avatar of {self.name}, new avatar has same path as old one")
|
||||||
|
return False
|
||||||
|
|
||||||
def _add_to_cache(self) -> None:
|
def _add_to_cache(self) -> None:
|
||||||
self.by_mid[self.mid] = self
|
self.by_mid[self.mid] = self
|
||||||
|
@ -129,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]
|
||||||
|
@ -148,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']:
|
||||||
|
|
|
@ -98,6 +98,12 @@ class Client(RPCClient):
|
||||||
|
|
||||||
self.add_event_handler("receipt", wrapper)
|
self.add_event_handler("receipt", wrapper)
|
||||||
|
|
||||||
|
async def on_logged_out(self, func: Callable[[], Awaitable[None]]) -> None:
|
||||||
|
async def wrapper(data: Dict[str, Any]) -> None:
|
||||||
|
await func()
|
||||||
|
|
||||||
|
self.add_event_handler("logged_out", wrapper)
|
||||||
|
|
||||||
# TODO Type hint for sender
|
# TODO Type hint for sender
|
||||||
async def login(self, sender, **login_data) -> AsyncGenerator[Tuple[str, str], None]:
|
async def login(self, sender, **login_data) -> AsyncGenerator[Tuple[str, str], None]:
|
||||||
login_data["login_type"] = sender.command_status["login_type"]
|
login_data["login_type"] = sender.command_status["login_type"]
|
||||||
|
|
|
@ -26,7 +26,7 @@ class RPCError(Exception):
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PathImage(SerializableAttrs['PathImage']):
|
class PathImage(SerializableAttrs['PathImage']):
|
||||||
path: str
|
path: Optional[str]
|
||||||
url: str
|
url: str
|
||||||
|
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -51,6 +51,13 @@ class ChatInfo(ChatListInfo, SerializableAttrs['ChatInfo']):
|
||||||
participants: List[Participant]
|
participants: List[Participant]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MessageImage(SerializableAttrs['MessageImage']):
|
||||||
|
url: str
|
||||||
|
is_sticker: bool
|
||||||
|
is_animated: bool
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Message(SerializableAttrs['Message']):
|
class Message(SerializableAttrs['Message']):
|
||||||
id: int
|
id: int
|
||||||
|
@ -59,7 +66,7 @@ class Message(SerializableAttrs['Message']):
|
||||||
sender: Optional[Participant]
|
sender: Optional[Participant]
|
||||||
timestamp: int = None
|
timestamp: int = None
|
||||||
html: Optional[str] = None
|
html: Optional[str] = None
|
||||||
image_url: Optional[str] = None
|
image: Optional[MessageImage] = None
|
||||||
receipt_count: Optional[int] = None
|
receipt_count: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -64,8 +64,9 @@ class User(DBUser, BaseUser):
|
||||||
cls.loop = bridge.loop
|
cls.loop = bridge.loop
|
||||||
Client.config = bridge.config
|
Client.config = bridge.config
|
||||||
|
|
||||||
async def send_notice(self, text) -> None:
|
async def send_bridge_notice(self, text) -> None:
|
||||||
if self.notice_room:
|
if self.notice_room:
|
||||||
|
self.log.debug(f"Sending bridge notice: {text}")
|
||||||
await self.az.intent.send_notice(self.notice_room, text)
|
await self.az.intent.send_notice(self.notice_room, text)
|
||||||
|
|
||||||
async def is_logged_in(self) -> bool:
|
async def is_logged_in(self) -> bool:
|
||||||
|
@ -96,17 +97,18 @@ class User(DBUser, BaseUser):
|
||||||
self.loop.create_task(self.connect_double_puppet())
|
self.loop.create_task(self.connect_double_puppet())
|
||||||
self.client = Client(self.mxid)
|
self.client = Client(self.mxid)
|
||||||
self.log.debug("Starting client")
|
self.log.debug("Starting client")
|
||||||
await self.send_notice("Starting up...")
|
await self.send_bridge_notice("Starting up...")
|
||||||
state = await self.client.start()
|
state = await self.client.start()
|
||||||
await self.client.on_message(self.handle_message)
|
await self.client.on_message(self.handle_message)
|
||||||
await self.client.on_receipt(self.handle_receipt)
|
await self.client.on_receipt(self.handle_receipt)
|
||||||
|
await self.client.on_logged_out(self.handle_logged_out)
|
||||||
if state.is_connected:
|
if state.is_connected:
|
||||||
self._track_metric(METRIC_CONNECTED, True)
|
self._track_metric(METRIC_CONNECTED, True)
|
||||||
if state.is_logged_in:
|
if state.is_logged_in:
|
||||||
await self.send_notice("Already logged in to LINE")
|
await self.send_bridge_notice("Already logged in to LINE")
|
||||||
self.loop.create_task(self._try_sync())
|
self.loop.create_task(self._try_sync())
|
||||||
else:
|
else:
|
||||||
await self.send_notice("Ready to log in to LINE")
|
await self.send_bridge_notice("Ready to log in to LINE")
|
||||||
|
|
||||||
async def _try_sync(self) -> None:
|
async def _try_sync(self) -> None:
|
||||||
try:
|
try:
|
||||||
|
@ -123,22 +125,24 @@ class User(DBUser, BaseUser):
|
||||||
if self._connection_check_task:
|
if self._connection_check_task:
|
||||||
self._connection_check_task.cancel()
|
self._connection_check_task.cancel()
|
||||||
self._connection_check_task = self.loop.create_task(self._check_connection_loop())
|
self._connection_check_task = self.loop.create_task(self._check_connection_loop())
|
||||||
await self.client.set_last_message_ids(await DBMessage.get_max_mids())
|
|
||||||
self.log.info("Syncing chats")
|
|
||||||
await self.send_notice("Synchronizing chats...")
|
|
||||||
await self.client.pause()
|
await self.client.pause()
|
||||||
chats = await self.client.get_chats()
|
await self.client.set_last_message_ids(await DBMessage.get_max_mids())
|
||||||
limit = self.config["bridge.initial_conversation_sync"]
|
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):
|
for index, chat in enumerate(chats):
|
||||||
portal = await po.Portal.get_by_chat_id(chat.id, create=True)
|
portal = await po.Portal.get_by_chat_id(chat.id, create=True)
|
||||||
if portal.mxid or index < limit:
|
if portal.mxid or num_created < limit:
|
||||||
chat = await self.client.get_chat(chat.id)
|
chat = await self.client.get_chat(chat.id)
|
||||||
if portal.mxid:
|
if portal.mxid:
|
||||||
await portal.update_matrix_room(self, chat)
|
await portal.update_matrix_room(self, chat)
|
||||||
else:
|
else:
|
||||||
await portal.create_matrix_room(self, chat)
|
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()
|
||||||
await self.send_notice("Synchronization complete")
|
|
||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
# TODO Notices for shutdown messages
|
# TODO Notices for shutdown messages
|
||||||
|
@ -158,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}")
|
||||||
|
@ -173,6 +176,12 @@ class User(DBUser, BaseUser):
|
||||||
await portal.create_matrix_room(self, chat_info)
|
await portal.create_matrix_room(self, chat_info)
|
||||||
await portal.handle_remote_receipt(receipt)
|
await portal.handle_remote_receipt(receipt)
|
||||||
|
|
||||||
|
async def handle_logged_out(self) -> None:
|
||||||
|
await self.send_bridge_notice("Logged out of LINE. Please run the \"login\" command to log back in.")
|
||||||
|
if self._connection_check_task:
|
||||||
|
self._connection_check_task.cancel()
|
||||||
|
self._connection_check_task = None
|
||||||
|
|
||||||
def _add_to_cache(self) -> None:
|
def _add_to_cache(self) -> None:
|
||||||
self.by_mxid[self.mxid] = self
|
self.by_mxid[self.mxid] = self
|
||||||
|
|
||||||
|
|
|
@ -144,6 +144,14 @@ export default class Client {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sendLoggedOut() {
|
||||||
|
this.log("Sending logout notice to client")
|
||||||
|
return this._write({
|
||||||
|
id: --this.notificationID,
|
||||||
|
command: "logged_out",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
handleStart = async (req) => {
|
handleStart = async (req) => {
|
||||||
let started = false
|
let started = false
|
||||||
if (this.puppet === null) {
|
if (this.puppet === null) {
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -97,7 +97,6 @@ export default class MessagesPuppeteer {
|
||||||
await this.page.exposeFunction("__mautrixReceiveQR", this._receiveQRChange.bind(this))
|
await this.page.exposeFunction("__mautrixReceiveQR", this._receiveQRChange.bind(this))
|
||||||
await this.page.exposeFunction("__mautrixSendEmailCredentials", this._sendEmailCredentials.bind(this))
|
await this.page.exposeFunction("__mautrixSendEmailCredentials", this._sendEmailCredentials.bind(this))
|
||||||
await this.page.exposeFunction("__mautrixReceivePIN", this._receivePIN.bind(this))
|
await this.page.exposeFunction("__mautrixReceivePIN", this._receivePIN.bind(this))
|
||||||
await this.page.exposeFunction("__mautrixExpiry", this._receiveExpiry.bind(this))
|
|
||||||
await this.page.exposeFunction("__mautrixReceiveMessageID",
|
await this.page.exposeFunction("__mautrixReceiveMessageID",
|
||||||
id => this.sentMessageIDs.add(id))
|
id => this.sentMessageIDs.add(id))
|
||||||
await this.page.exposeFunction("__mautrixReceiveChanges",
|
await this.page.exposeFunction("__mautrixReceiveChanges",
|
||||||
|
@ -108,7 +107,8 @@ export default class MessagesPuppeteer {
|
||||||
this._receiveReceiptDirectLatest.bind(this))
|
this._receiveReceiptDirectLatest.bind(this))
|
||||||
await this.page.exposeFunction("__mautrixReceiveReceiptMulti",
|
await this.page.exposeFunction("__mautrixReceiveReceiptMulti",
|
||||||
this._receiveReceiptMulti.bind(this))
|
this._receiveReceiptMulti.bind(this))
|
||||||
await this.page.exposeFunction("__mautrixShowParticipantsList", this._showParticipantList.bind(this))
|
await this.page.exposeFunction("__mautrixLoggedOut",
|
||||||
|
this._onLoggedOut.bind(this))
|
||||||
await this.page.exposeFunction("__chronoParseDate", chrono.parseDate)
|
await this.page.exposeFunction("__chronoParseDate", chrono.parseDate)
|
||||||
|
|
||||||
// NOTE Must *always* re-login on a browser session, so no need to check if already logged in
|
// NOTE Must *always* re-login on a browser session, so no need to check if already logged in
|
||||||
|
@ -126,6 +126,7 @@ export default class MessagesPuppeteer {
|
||||||
}
|
}
|
||||||
this.log("Injecting content script")
|
this.log("Injecting content script")
|
||||||
await this.page.addScriptTag({ path: "./src/contentscript.js", type: "module" })
|
await this.page.addScriptTag({ path: "./src/contentscript.js", type: "module" })
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -183,8 +184,6 @@ export default class MessagesPuppeteer {
|
||||||
|
|
||||||
await this.page.evaluate(
|
await this.page.evaluate(
|
||||||
element => window.__mautrixController.addPINAppearObserver(element), loginContentArea)
|
element => window.__mautrixController.addPINAppearObserver(element), loginContentArea)
|
||||||
await this.page.$eval("#layer_contents",
|
|
||||||
element => window.__mautrixController.addExpiryObserver(element))
|
|
||||||
|
|
||||||
this.log("Waiting for login response")
|
this.log("Waiting for login response")
|
||||||
let doneWaiting = false
|
let doneWaiting = false
|
||||||
|
@ -226,7 +225,6 @@ export default class MessagesPuppeteer {
|
||||||
await this.page.evaluate(() => window.__mautrixController.removeQRAppearObserver())
|
await this.page.evaluate(() => window.__mautrixController.removeQRAppearObserver())
|
||||||
await this.page.evaluate(() => window.__mautrixController.removeEmailAppearObserver())
|
await this.page.evaluate(() => window.__mautrixController.removeEmailAppearObserver())
|
||||||
await this.page.evaluate(() => window.__mautrixController.removePINAppearObserver())
|
await this.page.evaluate(() => window.__mautrixController.removePINAppearObserver())
|
||||||
await this.page.evaluate(() => window.__mautrixController.removeExpiryObserver())
|
|
||||||
delete this.login_email
|
delete this.login_email
|
||||||
delete this.login_password
|
delete this.login_password
|
||||||
|
|
||||||
|
@ -249,10 +247,23 @@ export default class MessagesPuppeteer {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
//this._sendLoginFailure(`Failed to sync: ${err}`)
|
//this._sendLoginFailure(`Failed to sync: ${err}`)
|
||||||
this.log("LINE's sync took too long, assume it's fine and carry on...")
|
this.log("LINE's sync took too long, assume it's fine and carry on...")
|
||||||
|
} finally {
|
||||||
|
const syncText = await this.page.evaluate(
|
||||||
|
messageSyncElement => messageSyncElement.innerText,
|
||||||
|
result)
|
||||||
|
this.log(`Final sync text is: "${syncText}"`)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.loginRunning = false
|
this.loginRunning = false
|
||||||
await this.startObserving()
|
// Don't start observing yet, instead wait for explicit request.
|
||||||
|
// But at least view the most recent chat.
|
||||||
|
try {
|
||||||
|
const mostRecentChatID = await this.page.$eval("#_chat_list_body li",
|
||||||
|
element => window.__mautrixController.getChatListItemID(element.firstElementChild))
|
||||||
|
await this._switchChat(mostRecentChatID)
|
||||||
|
} catch (e) {
|
||||||
|
this.log("No chats available to focus on")
|
||||||
|
}
|
||||||
this.log("Login complete")
|
this.log("Login complete")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -298,7 +309,17 @@ export default class MessagesPuppeteer {
|
||||||
* @return {Promise<boolean>} - Whether or not the session is logged in.
|
* @return {Promise<boolean>} - Whether or not the session is logged in.
|
||||||
*/
|
*/
|
||||||
async isLoggedIn() {
|
async isLoggedIn() {
|
||||||
return await this.page.$("#wrap_message_sync") !== null
|
const selectors = [
|
||||||
|
"#mainApp:not(.MdNonDisp)",
|
||||||
|
"#wrap_message_sync",
|
||||||
|
"#_chat_list_body",
|
||||||
|
]
|
||||||
|
for (const selector of selectors) {
|
||||||
|
if (await this.page.$(selector) == null) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
async isPermanentlyDisconnected() {
|
async isPermanentlyDisconnected() {
|
||||||
|
@ -380,15 +401,7 @@ export default class MessagesPuppeteer {
|
||||||
* @return {Promise<[MessageData]>} - The messages visible in the chat.
|
* @return {Promise<[MessageData]>} - The messages visible in the chat.
|
||||||
*/
|
*/
|
||||||
async getMessages(chatID) {
|
async getMessages(chatID) {
|
||||||
return await this.taskQueue.push(async () => {
|
return await this.taskQueue.push(async () => this._getMessagesUnsafe(chatID))
|
||||||
const messages = await this._getMessagesUnsafe(chatID)
|
|
||||||
if (messages.length > 0) {
|
|
||||||
for (const message of messages) {
|
|
||||||
message.chat_id = chatID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return messages
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setLastMessageIDs(ids) {
|
setLastMessageIDs(ids) {
|
||||||
|
@ -396,7 +409,8 @@ export default class MessagesPuppeteer {
|
||||||
for (const [chatID, messageID] of Object.entries(ids)) {
|
for (const [chatID, messageID] of Object.entries(ids)) {
|
||||||
this.mostRecentMessages.set(chatID, messageID)
|
this.mostRecentMessages.set(chatID, messageID)
|
||||||
}
|
}
|
||||||
this.log("Updated most recent message ID map:", this.mostRecentMessages)
|
this.log("Updated most recent message ID map:")
|
||||||
|
this.log(this.mostRecentMessages)
|
||||||
}
|
}
|
||||||
|
|
||||||
async readImage(imageUrl) {
|
async readImage(imageUrl) {
|
||||||
|
@ -411,11 +425,15 @@ export default class MessagesPuppeteer {
|
||||||
}
|
}
|
||||||
|
|
||||||
async startObserving() {
|
async startObserving() {
|
||||||
this.log("Adding observers")
|
const chatID = await this.page.evaluate(() => window.__mautrixController.getCurrentChatID())
|
||||||
|
this.log(`Adding observers for ${chatID || "empty chat"}`)
|
||||||
await this.page.evaluate(
|
await this.page.evaluate(
|
||||||
() => window.__mautrixController.addChatListObserver())
|
() => window.__mautrixController.addChatListObserver())
|
||||||
await this.page.evaluate(
|
if (chatID) {
|
||||||
() => window.__mautrixController.addMsgListObserver())
|
await this.page.evaluate(
|
||||||
|
(mostRecentMessage) => window.__mautrixController.addMsgListObserver(mostRecentMessage),
|
||||||
|
this.mostRecentMessages.get(chatID))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async stopObserving() {
|
async stopObserving() {
|
||||||
|
@ -448,9 +466,10 @@ export default class MessagesPuppeteer {
|
||||||
if (await this.page.evaluate(isCorrectChatVisible, chatName)) {
|
if (await this.page.evaluate(isCorrectChatVisible, chatName)) {
|
||||||
this.log("Already viewing chat, no need to switch")
|
this.log("Already viewing chat, no need to switch")
|
||||||
} else {
|
} else {
|
||||||
this.log("Switching chat, so remove msg list observer")
|
this.log("Ensuring msg list observer is removed")
|
||||||
const hadMsgListObserver = await this.page.evaluate(
|
const hadMsgListObserver = await this.page.evaluate(
|
||||||
() => window.__mautrixController.removeMsgListObserver())
|
() => window.__mautrixController.removeMsgListObserver())
|
||||||
|
this.log(hadMsgListObserver ? "Observer was already removed" : "Removed observer")
|
||||||
|
|
||||||
await chatListItem.click()
|
await chatListItem.click()
|
||||||
this.log(`Waiting for chat header title to be "${chatName}"`)
|
this.log(`Waiting for chat header title to be "${chatName}"`)
|
||||||
|
@ -459,39 +478,28 @@ export default class MessagesPuppeteer {
|
||||||
{polling: "mutation"},
|
{polling: "mutation"},
|
||||||
chatName)
|
chatName)
|
||||||
|
|
||||||
// For consistent behaviour later, wait for the chat details sidebar to be hidden
|
// Always show the chat details sidebar, as this makes life easier
|
||||||
|
this.log("Waiting for detail area to be auto-hidden upon entering chat")
|
||||||
await this.page.waitForFunction(
|
await this.page.waitForFunction(
|
||||||
detailArea => detailArea.childElementCount == 0,
|
detailArea => detailArea.childElementCount == 0,
|
||||||
{},
|
{},
|
||||||
await this.page.$("#_chat_detail_area"))
|
await this.page.$("#_chat_detail_area"))
|
||||||
|
this.log("Clicking chat header to show detail area")
|
||||||
|
await this.page.click("#_chat_header_area > .mdRGT04Link")
|
||||||
|
this.log("Waiting for detail area")
|
||||||
|
await this.page.waitForSelector("#_chat_detail_area > .mdRGT02Info")
|
||||||
|
|
||||||
if (hadMsgListObserver) {
|
if (hadMsgListObserver) {
|
||||||
this.log("Restoring msg list observer")
|
this.log("Restoring msg list observer")
|
||||||
await this.page.evaluate(
|
await this.page.evaluate(
|
||||||
() => window.__mautrixController.addMsgListObserver())
|
(mostRecentMessage) => window.__mautrixController.addMsgListObserver(mostRecentMessage),
|
||||||
|
this.mostRecentMessages.get(chatID))
|
||||||
} else {
|
} else {
|
||||||
this.log("Not restoring msg list observer, as there never was one")
|
this.log("Not restoring msg list observer, as there never was one")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO Commonize
|
|
||||||
async _getParticipantList() {
|
|
||||||
await this._showParticipantList()
|
|
||||||
return await this.page.$("#_chat_detail_area > .mdRGT02Info ul.mdRGT13Ul")
|
|
||||||
}
|
|
||||||
|
|
||||||
async _showParticipantList() {
|
|
||||||
const selector = "#_chat_detail_area > .mdRGT02Info ul.mdRGT13Ul"
|
|
||||||
let participantList = await this.page.$(selector)
|
|
||||||
if (!participantList) {
|
|
||||||
this.log("Participant list hidden, so clicking chat header to show it")
|
|
||||||
await this.page.click("#_chat_header_area > .mdRGT04Link")
|
|
||||||
// Use no timeout since the browser itself is using this
|
|
||||||
await this.page.waitForSelector(selector, {timeout: 0})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async _getChatInfoUnsafe(chatID) {
|
async _getChatInfoUnsafe(chatID) {
|
||||||
const chatListItem = await this.page.$(this._listItemSelector(chatID))
|
const chatListItem = await this.page.$(this._listItemSelector(chatID))
|
||||||
const chatListInfo = await chatListItem.evaluate(
|
const chatListInfo = await chatListItem.evaluate(
|
||||||
|
@ -516,7 +524,7 @@ export default class MessagesPuppeteer {
|
||||||
this.log("Found multi-user chat, so clicking chat header to get participants")
|
this.log("Found multi-user chat, so clicking chat header to get participants")
|
||||||
// TODO This will mark the chat as "read"!
|
// TODO This will mark the chat as "read"!
|
||||||
await this._switchChat(chatID)
|
await this._switchChat(chatID)
|
||||||
const participantList = await this._getParticipantList()
|
const participantList = await this.page.$("#_chat_detail_area > .mdRGT02Info ul.mdRGT13Ul")
|
||||||
// TODO Is a group not actually created until a message is sent(?)
|
// TODO Is a group not actually created until a message is sent(?)
|
||||||
// If so, maybe don't create a portal until there is a message.
|
// If so, maybe don't create a portal until there is a message.
|
||||||
participants = await participantList.evaluate(
|
participants = await participantList.evaluate(
|
||||||
|
@ -546,6 +554,7 @@ export default class MessagesPuppeteer {
|
||||||
|
|
||||||
async _sendMessageUnsafe(chatID, text) {
|
async _sendMessageUnsafe(chatID, text) {
|
||||||
await this._switchChat(chatID)
|
await this._switchChat(chatID)
|
||||||
|
// TODO Initiate the promise in the content script
|
||||||
await this.page.evaluate(
|
await this.page.evaluate(
|
||||||
() => window.__mautrixController.promiseOwnMessage(5000, "time"))
|
() => window.__mautrixController.promiseOwnMessage(5000, "time"))
|
||||||
|
|
||||||
|
@ -597,15 +606,12 @@ export default class MessagesPuppeteer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_receiveMessages(chatID, messages) {
|
async _receiveMessages(chatID, messages) {
|
||||||
if (this.client) {
|
if (this.client) {
|
||||||
messages = this._filterMessages(chatID, messages)
|
messages = await this._processMessages(chatID, messages)
|
||||||
if (messages.length > 0) {
|
for (const message of messages) {
|
||||||
for (const message of messages) {
|
this.client.sendMessage(message).catch(err =>
|
||||||
message.chat_id = chatID
|
this.error("Failed to send message", message.id, "to client:", err))
|
||||||
this.client.sendMessage(message).catch(err =>
|
|
||||||
this.error("Failed to send message", message.id, "to client:", err))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.log("No client connected, not sending messages")
|
this.log("No client connected, not sending messages")
|
||||||
|
@ -613,27 +619,52 @@ export default class MessagesPuppeteer {
|
||||||
}
|
}
|
||||||
|
|
||||||
async _getMessagesUnsafe(chatID) {
|
async _getMessagesUnsafe(chatID) {
|
||||||
// TODO Also handle "decrypting" state
|
// TODO Consider making a wrapper for pausing/resuming the msg list observers
|
||||||
|
this.log("Ensuring msg list observer is removed")
|
||||||
|
const hadMsgListObserver = await this.page.evaluate(
|
||||||
|
() => window.__mautrixController.removeMsgListObserver())
|
||||||
|
this.log(hadMsgListObserver ? "Observer was already removed" : "Removed observer")
|
||||||
|
|
||||||
// TODO Handle unloaded messages. Maybe scroll up
|
// TODO Handle unloaded messages. Maybe scroll up
|
||||||
// TODO This will mark the chat as "read"!
|
// TODO This will mark the chat as "read"!
|
||||||
await this._switchChat(chatID)
|
await this._switchChat(chatID)
|
||||||
const messages = await this.page.evaluate(() =>
|
const messages = await this.page.evaluate(
|
||||||
window.__mautrixController.parseMessageList())
|
mostRecentMessage => window.__mautrixController.parseMessageList(mostRecentMessage),
|
||||||
return this._filterMessages(chatID, messages)
|
this.mostRecentMessages.get(chatID))
|
||||||
|
// Doing this before restoring the observer since it updates minID
|
||||||
|
const filteredMessages = await this._processMessages(chatID, messages)
|
||||||
|
|
||||||
|
if (hadMsgListObserver) {
|
||||||
|
this.log("Restoring msg list observer")
|
||||||
|
await this.page.evaluate(
|
||||||
|
mostRecentMessage => window.__mautrixController.addMsgListObserver(mostRecentMessage),
|
||||||
|
this.mostRecentMessages.get(chatID))
|
||||||
|
} else {
|
||||||
|
this.log("Not restoring msg list observer, as there never was one")
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredMessages
|
||||||
}
|
}
|
||||||
|
|
||||||
_filterMessages(chatID, messages) {
|
async _processMessages(chatID, messages) {
|
||||||
|
// TODO Probably don't need minID filtering if Puppeteer context handles it now
|
||||||
const minID = this.mostRecentMessages.get(chatID) || 0
|
const minID = this.mostRecentMessages.get(chatID) || 0
|
||||||
const filtered_messages = messages.filter(msg => msg.id > minID && !this.sentMessageIDs.has(msg.id))
|
const filteredMessages = messages.filter(msg => msg.id > minID && !this.sentMessageIDs.has(msg.id))
|
||||||
|
|
||||||
if (filtered_messages.length > 0) {
|
if (filteredMessages.length > 0) {
|
||||||
const newFirstID = filtered_messages[0].id
|
const newFirstID = filteredMessages[0].id
|
||||||
const newLastID = filtered_messages[filtered_messages.length - 1].id
|
const newLastID = filteredMessages[filteredMessages.length - 1].id
|
||||||
this.mostRecentMessages.set(chatID, newLastID)
|
this.mostRecentMessages.set(chatID, newLastID)
|
||||||
const range = newFirstID === newLastID ? newFirstID : `${newFirstID}-${newLastID}`
|
const range = newFirstID === newLastID ? newFirstID : `${newFirstID}-${newLastID}`
|
||||||
this.log(`Loaded ${messages.length} messages in ${chatID}, got ${filtered_messages.length} newer than ${minID} (${range})`)
|
this.log(`Loaded ${messages.length} messages in ${chatID}, got ${filteredMessages.length} newer than ${minID} (${range})`)
|
||||||
|
|
||||||
|
for (const message of filteredMessages) {
|
||||||
|
message.chat_id = chatID
|
||||||
|
}
|
||||||
|
return filteredMessages
|
||||||
|
} else {
|
||||||
|
return []
|
||||||
}
|
}
|
||||||
return filtered_messages
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async _processChatListChangeUnsafe(chatID) {
|
async _processChatListChangeUnsafe(chatID) {
|
||||||
|
@ -647,7 +678,6 @@ export default class MessagesPuppeteer {
|
||||||
|
|
||||||
if (this.client) {
|
if (this.client) {
|
||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
message.chat_id = chatID
|
|
||||||
await this.client.sendMessage(message).catch(err =>
|
await this.client.sendMessage(message).catch(err =>
|
||||||
this.error("Failed to send message", message.id, "to client:", err))
|
this.error("Failed to send message", message.id, "to client:", err))
|
||||||
}
|
}
|
||||||
|
@ -740,9 +770,14 @@ export default class MessagesPuppeteer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async _receiveExpiry(button) {
|
_onLoggedOut() {
|
||||||
this.log("Something expired, clicking OK button to continue")
|
this.log("Got logged out!")
|
||||||
this.page.click(button).catch(err =>
|
this.stopObserving()
|
||||||
this.error("Failed to dismiss expiry dialog:", err))
|
if (this.client) {
|
||||||
|
this.client.sendLoggedOut().catch(err =>
|
||||||
|
this.error("Failed to send logout notice to client:", err))
|
||||||
|
} else {
|
||||||
|
this.log("No client connected, not sending logout notice")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue