Merge branch 'master' into systemd

This commit is contained in:
Cristian Le 2021-06-13 08:17:18 -04:00
commit 9c09285045
19 changed files with 1165 additions and 492 deletions

View File

@ -2,7 +2,7 @@
A very hacky Matrix-LINE bridge based on running LINE's Chrome extension in Puppeteer. A very hacky Matrix-LINE bridge based on running LINE's Chrome extension in Puppeteer.
Fork of [https://mau.dev/tulir/mautrix-amp/](mautrix-amp). Fork of [https://mau.dev/tulir/mautrix-amp/](mautrix-amp).
## Features & roadmap ## Features, roadmap, and limitations
[ROADMAP.md](ROADMAP.md) [ROADMAP.md](ROADMAP.md)
## Setup ## Setup

View File

@ -6,11 +6,13 @@
* [x] Images * [x] Images
* [ ] Files * [ ] Files
* [x] Stickers * [x] Stickers
* [x] Notification for message send failure
* [ ] Read receipts (currently eagerly-sent since message sync requires "reading" a chat) * [ ] Read receipts (currently eagerly-sent since message sync requires "reading" a chat)
* [ ] Room metadata changes * [ ] Room metadata changes
* [ ] Name * [ ] Name
* [ ] Avatar * [ ] Avatar
* [ ] Member events
* [ ] Invite
* [ ] Kick
* LINE → Matrix * LINE → Matrix
* [ ] Message content * [ ] Message content
* [x] Text * [x] Text
@ -20,45 +22,54 @@
* [ ] Location * [ ] Location
* [ ] Videos * [ ] Videos
* [x] Stickers * [x] Stickers
* [x] Sticons * [x] Emoji
* [x] Single
* [x] Multiple or mixed with text
* [x] EmojiOne
* [ ] Message unsend * [ ] Message unsend
* [ ] Read receipts * [ ] Read receipts
* [x] For most recently active chat * [x] For most recently active chat
* [ ] For any chat * [ ] For any chat
* [x] User metadata * [x] User metadata
* [ ] Name * [ ] Name
* [x] On initial sync * [x] On sync
* [ ] On change * [ ] On change
* [ ] Avatar * [ ] Avatar
* [x] On initial sync * [x] On sync
* [ ] On change * [ ] On change
* [ ] Chat metadata * [ ] Chat metadata
* [ ] Name * [ ] Name
* [x] On initial sync * [x] On sync
* [ ] On change * [ ] On change
* [ ] Icon * [ ] Icon
* [x] On initial sync * [x] On sync
* [ ] On change * [ ] On change
* [x] Message history * [ ] Message history
* [x] When creating portal * [x] When creating portal
* [x] Missed messages * [x] Missed messages
* [x] Message timestamps * [x] Message timestamps
* [ ] As many messages that are visible in LINE extension
* [x] Chat types * [x] Chat types
* [x] Direct chats * [x] Direct chats
* [x] Groups (named chats) * [x] Groups (named chats)
* [x] Rooms (unnamed chats / "multi-user direct chats") * [x] Rooms (unnamed chats / "multi-user direct chats")
* [ ] Membership actions * [ ] Membership actions
* [x] Add member * [ ] Join
* [x] Remove member * [x] When message is sent by new participant
* [ ] Block * [x] On sync
* [ ] At join time
* [ ] Leave
* [x] On sync
* [ ] At leave time
* [ ] Invite
* [ ] Remove
* [ ] Friend actions
* [ ] Add friend
* [ ] Block user
* [ ] Unblock user
* Misc * Misc
* [x] Automatic portal creation * [x] Automatic portal creation
* [x] At startup * [x] At startup
* [x] When receiving invite or message * [x] When receiving invite or message
* [ ] When sending message in new chat from LINE app * [ ] When sending message in new chat from LINE app
* [x] Notification for message send failure
* [ ] Provisioning API for logging in * [ ] Provisioning API for logging in
* [x] Use bridge bot for messages sent from LINE app (when double-puppeting is disabled and `bridge.invite_own_puppet_to_pm` is enabled) * [x] Use bridge bot for messages sent from LINE app (when double-puppeting is disabled and `bridge.invite_own_puppet_to_pm` is enabled)
* [x] Use own Matrix account for messages sent from LINE app (when double-puppeting is enabled) * [x] Use own Matrix account for messages sent from LINE app (when double-puppeting is enabled)
@ -66,8 +77,8 @@
* [ ] Multiple bridge users * [ ] Multiple bridge users
* [ ] Relay bridging * [ ] Relay bridging
## Missing features # Missing features
### Missing from LINE ## Missing from LINE
* Typing notifications * Typing notifications
* Message edits * Message edits
* Formatted messages * Formatted messages
@ -75,13 +86,22 @@
* Timestamped read receipts * Timestamped read receipts
* Read receipts between users other than yourself * Read receipts between users other than yourself
### Missing from LINE on Chrome ## Missing from LINE on Chrome
* Message redaction (delete/unsend)
* Replies
* Audio message sending
* Location sending
* Voice/video calls
* Unlimited message history * Unlimited message history
* Messages that are very old may not be available in LINE on Chrome at all, even after a full sync
* Voice/video calls
* No notification is sent when a call begins
* When a call ends, an automated message of "Your OS version doesn't support this feature" is sent as an ordinary text message from the user who began the call
* Message redaction (delete/unsend)
* But messages unsent from other LINE clients do disappear from LINE on Chrome
* Replies
* Appear as ordinary messages
* Mentions
* Appear as ordinary text
* Audio message sending
* But audio messages can be received
* Location sending
* But locations can be received
### Missing from matrix-puppeteer-line ## Missing from matrix-puppeteer-line
* TODO * TODO

View File

@ -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.

View File

@ -55,6 +55,11 @@ async def login_do(evt: CommandEvent, gen: AsyncGenerator[Tuple[str, str], None]
failure = False failure = False
async for item in gen: async for item in gen:
if item[0] == "qr": if item[0] == "qr":
message = "Open LINE on your primary device and scan this QR code:"
content = TextMessageEventContent(body=message, msgtype=MessageType.NOTICE)
content.set_reply(evt.event_id)
await evt.az.intent.send_message(evt.room_id, content)
url = item[1] url = item[1]
buffer = io.BytesIO() buffer = io.BytesIO()
image = qrcode.make(url) image = qrcode.make(url)
@ -69,7 +74,6 @@ async def login_do(evt: CommandEvent, gen: AsyncGenerator[Tuple[str, str], None]
content.set_edit(qr_event_id) content.set_edit(qr_event_id)
await evt.az.intent.send_message(evt.room_id, content) await evt.az.intent.send_message(evt.room_id, content)
else: else:
content.set_reply(evt.event_id)
qr_event_id = await evt.az.intent.send_message(evt.room_id, content) qr_event_id = await evt.az.intent.send_message(evt.room_id, content)
elif item[0] == "pin": elif item[0] == "pin":
pin = item[1] pin = item[1]
@ -79,9 +83,10 @@ async def login_do(evt: CommandEvent, gen: AsyncGenerator[Tuple[str, str], None]
content.set_edit(pin_event_id) content.set_edit(pin_event_id)
await evt.az.intent.send_message(evt.room_id, content) await evt.az.intent.send_message(evt.room_id, content)
else: else:
content.set_reply(evt.event_id)
pin_event_id = await evt.az.intent.send_message(evt.room_id, content) pin_event_id = await evt.az.intent.send_message(evt.room_id, content)
elif item[0] in ("failure", "error"): elif item[0] == "login_success":
await evt.reply("Successfully logged in, waiting for LINE to load...")
elif item[0] in ("login_failure", "error"):
# TODO Handle errors differently? # TODO Handle errors differently?
failure = True failure = True
reason = item[1] reason = item[1]
@ -91,7 +96,7 @@ async def login_do(evt: CommandEvent, gen: AsyncGenerator[Tuple[str, str], None]
# else: pass # else: pass
if not failure and evt.sender.command_status: if not failure and evt.sender.command_status:
await evt.reply("Successfully logged in") await evt.reply("LINE loading complete")
await evt.sender.sync() await evt.sender.sync()
# else command was cancelled or failed. Don't post message about it, "cancel" command or failure did already # else command was cancelled or failed. Don't post message about it, "cancel" command or failure did already
evt.sender.command_status = None evt.sender.command_status = None

View File

@ -73,6 +73,9 @@ 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.receive_stickers")
copy("bridge.use_sticker_events")
copy("bridge.emoji_scale_factor")
copy("bridge.command_prefix") copy("bridge.command_prefix")
copy("bridge.user") copy("bridge.user")

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

@ -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:

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

@ -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
)""")

View File

@ -45,7 +45,7 @@ appservice:
# Display name and avatar for bot. Set to "remove" to remove display name/avatar, leave empty # Display name and avatar for bot. Set to "remove" to remove display name/avatar, leave empty
# to leave display name/avatar as-is. # to leave display name/avatar as-is.
bot_displayname: LINE bridge bot bot_displayname: LINE bridge bot
bot_avatar: mxc://maunium.net/VuvevQiMRlOxuBVMBNEZZrxi bot_avatar: mxc://miscworks.net/uyHgFSLDDtATVGjtjWKwxrkK
# Community ID for bridged users (changes registration file) and rooms. # Community ID for bridged users (changes registration file) and rooms.
# Must be created manually. # Must be created manually.
@ -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"

View File

@ -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,7 +48,7 @@ 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)
@ -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"]
@ -131,6 +132,7 @@ class Portal(DBPortal, BasePortal):
elif ((message.get(self.bridge.real_user_content_key, elif ((message.get(self.bridge.real_user_content_key,
False) and await p.Puppet.get_by_custom_mxid(sender.mxid))): False) and await p.Puppet.get_by_custom_mxid(sender.mxid))):
self.log.debug(f"Ignoring puppet-sent message by confirmed puppet user {sender.mxid}") self.log.debug(f"Ignoring puppet-sent message by confirmed puppet user {sender.mxid}")
await self._send_delivery_receipt(event_id)
return return
# TODO deduplication of outgoing messages # TODO deduplication of outgoing messages
text = message.body text = message.body
@ -170,7 +172,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 +181,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,38 +206,66 @@ 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
await 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.
# TODO Open an E.W. issue for this
# TODO Test Element Android
# TODO Find & test other non-GIF formats for animated images
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( content = TextMessageEventContent(
msgtype=MessageType.NOTICE, msgtype=MessageType.NOTICE,
body="<unbridgeable media>") body=f"<{'sticker' if evt.image.is_sticker else 'image'}>")
event_id = await self._send_message(intent, content, timestamp=evt.timestamp) 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,7 +275,6 @@ 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]
@ -268,11 +291,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 +312,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,7 +324,15 @@ 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!
# Also keep track of strangers who leave / get blocked / become friends
# (maybe not here for all of that)
else:
content = TextMessageEventContent(
msgtype=MessageType.NOTICE,
body="<Unbridgeable message>")
event_id = await self._send_message(intent, content, timestamp=evt.timestamp)
if evt.is_outgoing and evt.receipt_count: if evt.is_outgoing and evt.receipt_count:
await self._handle_receipt(event_id, 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) msg = DBMessage(mxid=event_id, mx_room=self.mxid, mid=evt.id, chat_id=self.chat_id)
@ -321,46 +358,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.
self.log.debug(f"{'Did not find existing mxc URL for' if deduplicate else 'Not deduplicating'} {media_id}, uploading media now")
try:
resp = await source.client.read_image(media_url) resp = await source.client.read_image(media_url)
media_info = await self._reupload_remote_media(resp.data, intent, resp.mime, disable_encryption=True) except (RPCError, TypeError) as e:
await DBMedia(media_id=media_id, mxc=media_info.mxc).insert() self.log.warning(f"Failed to download remote media from chat {self.chat_id}: {e}")
self.log.debug(f"Uploaded media as {media_info.mxc}") 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 +415,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 +429,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
if participant.id != None:
puppet = await p.Puppet.get_by_mid(participant.id, client)
await puppet.update_info(participant, 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,17 +463,32 @@ 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:
try:
await self.main_intent.set_room_avatar(self.mxid, self.icon_mxc) 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
else:
self.log.debug(f"No need to update room icon of {self.name}, new icon has same path as old one")
return False return False
async def _update_participants(self, participants: List[Participant]) -> None: async def _update_participants(self, participants: List[Participant]) -> None:
@ -436,7 +496,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
@ -449,10 +514,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,20 +557,13 @@ 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
def bridge_info_state_key(self) -> str: def bridge_info_state_key(self) -> str:
return f"net.maunium.line://line/{self.chat_id}" return f"net.miscworks.line://line/{self.chat_id}"
@property @property
def bridge_info(self) -> Dict[str, Any]: def bridge_info(self) -> Dict[str, Any]:
@ -578,12 +636,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 +660,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 +697,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
@ -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:
self.log.warn(f"Not updating user avatar of {self.name}: new avatar exists, but in a form that cannot be uniquely identified")
return False return False
avatar_path = avatar.path if avatar else None else:
if avatar_path != self.avatar_path or not self.avatar_set: 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,10 +110,12 @@ 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
else:
self.log.debug(f"No need to update user avatar of {self.name}, new avatar has same path as old one")
return False return False
def _add_to_cache(self) -> None: def _add_to_cache(self) -> None:
@ -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']:

View File

@ -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"]
@ -113,8 +119,12 @@ class Client(RPCClient):
data.append(("pin", req["pin"])) data.append(("pin", req["pin"]))
event.set() event.set()
async def success_handler(req: LoginCommand) -> None:
data.append(("login_success", None))
event.set()
async def failure_handler(req: LoginCommand) -> None: async def failure_handler(req: LoginCommand) -> None:
data.append(("failure", req.get("reason"))) data.append(("login_failure", req.get("reason")))
event.set() event.set()
async def cancel_watcher() -> None: async def cancel_watcher() -> None:
@ -139,7 +149,8 @@ class Client(RPCClient):
self.add_event_handler("qr", qr_handler) self.add_event_handler("qr", qr_handler)
self.add_event_handler("pin", pin_handler) self.add_event_handler("pin", pin_handler)
self.add_event_handler("failure", failure_handler) self.add_event_handler("login_success", success_handler)
self.add_event_handler("login_failure", failure_handler)
try: try:
while True: while True:
await event.wait() await event.wait()
@ -152,4 +163,5 @@ class Client(RPCClient):
finally: finally:
self.remove_event_handler("qr", qr_handler) self.remove_event_handler("qr", qr_handler)
self.remove_event_handler("pin", pin_handler) self.remove_event_handler("pin", pin_handler)
self.remove_event_handler("failure", failure_handler) self.remove_event_handler("login_success", success_handler)
self.remove_event_handler("login_failure", failure_handler)

View File

@ -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

View File

@ -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 either \"login-qr\" or \"login-email\" 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

View File

@ -64,8 +64,8 @@ class ProvisioningAPI:
return None return None
for part in auth_parts: for part in auth_parts:
part = part.strip() part = part.strip()
if part.startswith("net.maunium.line.auth-"): if part.startswith("net.miscworks.line.auth-"):
return part[len("net.maunium.line.auth-"):] return part[len("net.miscworks.line.auth-"):]
return None return None
def check_token(self, request: web.Request) -> Awaitable['u.User']: def check_token(self, request: web.Request) -> Awaitable['u.User']:
@ -107,7 +107,7 @@ class ProvisioningAPI:
if status.is_logged_in: if status.is_logged_in:
raise web.HTTPConflict(text='{"error": "Already logged in"}', headers=self._headers) raise web.HTTPConflict(text='{"error": "Already logged in"}', headers=self._headers)
ws = web.WebSocketResponse(protocols=["net.maunium.line.login"]) ws = web.WebSocketResponse(protocols=["net.miscworks.line.login"])
await ws.prepare(request) await ws.prepare(request)
try: try:
async for url in user.client.login(): async for url in user.client.login():

View File

@ -135,15 +135,31 @@ export default class Client {
}) })
} }
sendFailure(reason) { sendLoginSuccess() {
this.log(`Sending failure to client${reason ? `: "${reason}"` : ""}`) this.log("Sending login success to client")
return this._write({ return this._write({
id: --this.notificationID, id: --this.notificationID,
command: "failure", command: "login_success",
})
}
sendLoginFailure(reason) {
this.log(`Sending login failure to client${reason ? `: "${reason}"` : ""}`)
return this._write({
id: --this.notificationID,
command: "login_failure",
reason, reason,
}) })
} }
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

View File

@ -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
@ -183,8 +183,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
@ -209,7 +207,7 @@ export default class MessagesPuppeteer {
} }
const result = await Promise.race([ const result = await Promise.race([
() => this.page.waitForSelector("#wrap_message_sync", {timeout: 2000}) () => this.page.waitForSelector("#mainApp:not(.MdNonDisp)", {timeout: 2000})
.then(value => { .then(value => {
loginSuccess = true loginSuccess = true
return value return value
@ -226,15 +224,16 @@ 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
if (!loginSuccess) { const messageSyncElement = loginSuccess ? await this.page.waitForSelector("#wrap_message_sync") : null
if (!loginSuccess || !messageSyncElement) {
this._sendLoginFailure(result) this._sendLoginFailure(result)
return return
} }
this._sendLoginSuccess()
this.log("Waiting for sync") this.log("Waiting for sync")
try { try {
await this.page.waitForFunction( await this.page.waitForFunction(
@ -245,14 +244,25 @@ export default class MessagesPuppeteer {
// TODO Sometimes it gets stuck at 99%...?? // TODO Sometimes it gets stuck at 99%...??
}, },
{timeout: 10000}, // Assume 10 seconds is long enough {timeout: 10000}, // Assume 10 seconds is long enough
result) messageSyncElement)
} 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 messageSyncElement.evaluate(e => e.innerText)
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 +308,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 +400,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 +408,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(JSON.stringify(this.mostRecentMessages))
} }
async readImage(imageUrl) { async readImage(imageUrl) {
@ -411,11 +424,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())
if (chatID) {
await this.page.evaluate( await this.page.evaluate(
() => window.__mautrixController.addMsgListObserver()) (mostRecentMessage) => window.__mautrixController.addMsgListObserver(mostRecentMessage),
this.mostRecentMessages.get(chatID))
}
} }
async stopObserving() { async stopObserving() {
@ -448,9 +465,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,42 +477,30 @@ 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 chatListInfo = await this.page.$eval(this._listItemSelector(chatID),
const chatListInfo = await chatListItem.evaluate(
(element, chatID) => window.__mautrixController.parseChatListItem(element, chatID), (element, chatID) => window.__mautrixController.parseChatListItem(element, chatID),
chatID) chatID)
@ -513,10 +519,10 @@ export default class MessagesPuppeteer {
let participants let participants
if (!isDirect) { if (!isDirect) {
this.log("Found multi-user chat, so clicking chat header to get participants") this.log("Found multi-user chat, so viewing it 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(
@ -534,7 +540,7 @@ export default class MessagesPuppeteer {
this.log("Found participants:") this.log("Found participants:")
for (const participant of participants) { for (const participant of participants) {
this.log(participant) this.log(JSON.stringify(participant))
} }
return {participants, ...chatListInfo} return {participants, ...chatListInfo}
} }
@ -546,6 +552,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,43 +604,65 @@ 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) {
message.chat_id = chatID
this.client.sendMessage(message).catch(err => 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))
} }
}
} else { } else {
this.log("No client connected, not sending messages") this.log("No client connected, not sending messages")
} }
} }
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")
} }
_filterMessages(chatID, messages) { return filteredMessages
const minID = this.mostRecentMessages.get(chatID) || 0 }
const filtered_messages = messages.filter(msg => msg.id > minID && !this.sentMessageIDs.has(msg.id))
if (filtered_messages.length > 0) { async _processMessages(chatID, messages) {
const newFirstID = filtered_messages[0].id // TODO Probably don't need minID filtering if Puppeteer context handles it now
const newLastID = filtered_messages[filtered_messages.length - 1].id const minID = this.mostRecentMessages.get(chatID) || 0
const filteredMessages = messages.filter(msg => msg.id > minID && !this.sentMessageIDs.has(msg.id))
if (filteredMessages.length > 0) {
const newFirstID = filteredMessages[0].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 +676,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))
} }
@ -729,20 +757,35 @@ export default class MessagesPuppeteer {
} }
} }
_sendLoginSuccess() {
this.error("Login success")
if (this.client) {
this.client.sendLoginSuccess().catch(err =>
this.error("Failed to send login success to client:", err))
} else {
this.log("No client connected, not sending login success")
}
}
_sendLoginFailure(reason) { _sendLoginFailure(reason) {
this.loginRunning = false this.loginRunning = false
this.error(`Login failure: ${reason ? reason : "cancelled"}`) this.error(`Login failure: ${reason ? reason : "cancelled"}`)
if (this.client) { if (this.client) {
this.client.sendFailure(reason).catch(err => this.client.sendLoginFailure(reason).catch(err =>
this.error("Failed to send failure reason to client:", err)) this.error("Failed to send login failure to client:", err))
} else { } else {
this.log("No client connected, not sending failure reason") this.log("No client connected, not sending login failure")
} }
} }
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")
}
} }
} }