diff --git a/matrix_puppeteer_line/commands/auth.py b/matrix_puppeteer_line/commands/auth.py
index 324f452..b013d48 100644
--- a/matrix_puppeteer_line/commands/auth.py
+++ b/matrix_puppeteer_line/commands/auth.py
@@ -13,7 +13,7 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from typing import Optional, AsyncGenerator, Tuple
+from typing import Optional, AsyncGenerator, Tuple, TYPE_CHECKING
import io
import qrcode
@@ -26,8 +26,13 @@ from .typehint import CommandEvent
SECTION_AUTH = HelpSection("Authentication", 10, "")
+from ..db import LoginCredential
-async def login_prep(evt: CommandEvent, login_type: str) -> bool:
+if TYPE_CHECKING:
+ from ..user import User
+
+
+async def _login_prep(evt: CommandEvent, login_type: str) -> bool:
status = await evt.sender.client.start()
if status.is_logged_in:
await evt.reply("You're already logged in")
@@ -49,16 +54,36 @@ async def login_prep(evt: CommandEvent, login_type: str) -> bool:
}
return True
-async def login_do(evt: CommandEvent, gen: AsyncGenerator[Tuple[str, str], None]) -> None:
+async def _login_do(
+ gen: AsyncGenerator[Tuple[str, str], None],
+ *,
+ evt: Optional[CommandEvent] = None,
+ sender: Optional["User"] = None,
+) -> bool:
qr_event_id: Optional[EventID] = None
pin_event_id: Optional[EventID] = None
failure = False
+
+ if not evt and not sender:
+ raise ValueError("Must set either a CommandEvent or a User")
+ if evt:
+ sender = evt.sender
+ az = evt.az
+ room_id = evt.room_id
+ else:
+ az = sender.az
+ room_id = sender.notice_room
+ if not room_id:
+ sender.log.warning("Cannot auto-loggin: must have a notice room to do so")
+ return False
+
async for item in gen:
if item[0] == "qr":
message = "Open LINE on your smartphone 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)
+ if evt:
+ content.set_reply(evt.event_id)
+ await az.intent.send_message(room_id, content)
url = item[1]
buffer = io.BytesIO()
@@ -66,61 +91,131 @@ async def login_do(evt: CommandEvent, gen: AsyncGenerator[Tuple[str, str], None]
size = image.pixel_size
image.save(buffer, "PNG")
qr = buffer.getvalue()
- mxc = await evt.az.intent.upload_media(qr, "image/png", "login-qr.png", len(qr))
+ mxc = await az.intent.upload_media(qr, "image/png", "login-qr.png", len(qr))
content = MediaMessageEventContent(body=url, url=mxc, msgtype=MessageType.IMAGE,
info=ImageInfo(mimetype="image/png", size=len(qr),
width=size, height=size))
if qr_event_id:
content.set_edit(qr_event_id)
- await evt.az.intent.send_message(evt.room_id, content)
+ await az.intent.send_message(room_id, content)
else:
- qr_event_id = await evt.az.intent.send_message(evt.room_id, content)
+ qr_event_id = await az.intent.send_message(room_id, content)
elif item[0] == "pin":
pin = item[1]
message = f"Enter this PIN in LINE on your smartphone:\n{pin}"
content = TextMessageEventContent(body=message, msgtype=MessageType.NOTICE)
if pin_event_id:
content.set_edit(pin_event_id)
- await evt.az.intent.send_message(evt.room_id, content)
+ await az.intent.send_message(room_id, content)
else:
- pin_event_id = await evt.az.intent.send_message(evt.room_id, content)
+ pin_event_id = await az.intent.send_message(room_id, content)
elif item[0] == "login_success":
- await evt.reply("Successfully logged in, waiting for LINE to load...")
+ await az.intent.send_notice(room_id, "Successfully logged in, waiting for LINE to load...")
elif item[0] in ("login_failure", "error"):
# TODO Handle errors differently?
failure = True
reason = item[1]
if reason:
- content = TextMessageEventContent(body=reason, msgtype=MessageType.NOTICE)
- await evt.az.intent.send_message(evt.room_id, content)
+ await az.intent.send_notice(room_id, reason)
# else: pass
- if not failure and evt.sender.command_status:
- await evt.reply("LINE loading complete")
- await evt.sender.sync()
+ login_success = not failure and sender.command_status
+ if login_success:
+ await az.intent.send_notice(room_id, "LINE loading complete")
+ await sender.sync()
# else command was cancelled or failed. Don't post message about it, "cancel" command or failure did already
- evt.sender.command_status = None
+ sender.command_status = None
+ return login_success
@command_handler(needs_auth=False, management_only=True, help_section=SECTION_AUTH,
help_text="Log into LINE via QR code")
async def login_qr(evt: CommandEvent) -> None:
- if not await login_prep(evt, "qr"):
+ if not await _login_prep(evt, "qr"):
return
gen = evt.sender.client.login(evt.sender)
- await login_do(evt, gen)
+ await _login_do(gen, evt=evt)
@command_handler(needs_auth=False, management_only=True, help_section=SECTION_AUTH,
- help_text="Log into LINE via email/password",
- help_args="<_email_> <_password_>")
+ help_text="Log into LINE via email/password, and optionally save credentials for auto-login",
+ help_args="[--save] <_email_> <_password_>")
async def login_email(evt: CommandEvent) -> None:
await evt.az.intent.redact(evt.room_id, evt.event_id)
+ if evt.args and evt.args[0] == "--save":
+ save = True
+ evt.args.pop(0)
+ else:
+ save = False
if len(evt.args) != 2:
- await evt.reply("Usage: `$cmdprefix+sp login-email `")
+ await evt.reply("Usage: `$cmdprefix+sp login-email [--save] `")
return
- if not await login_prep(evt, "email"):
+ if not await _login_prep(evt, "email"):
return
await evt.reply("Logging in...")
+ login_data = {
+ "email": evt.args[0],
+ "password": evt.args[1]
+ }
gen = evt.sender.client.login(
- evt.sender,
- login_data=dict(email=evt.args[0], password=evt.args[1]))
- await login_do(evt, gen)
+ evt.sender,
+ login_data=login_data
+ )
+ login_success = await _login_do(gen, evt=evt)
+ if login_success and save:
+ if not evt.sender.notice_room:
+ await evt.reply("WARNING: You do not have a notice room, but auto-login requires one.")
+ await _save_password_helper(evt)
+
+async def auto_login(sender: "User") -> bool:
+ status = await sender.client.start()
+ if status.is_logged_in:
+ return True
+ if sender.command_status is not None:
+ return False
+ creds = await LoginCredential.get_by_mxid(sender.mxid)
+ if not creds:
+ return False
+ sender.command_status = {
+ "action": "Login",
+ "login_type": "email",
+ }
+ gen = sender.client.login(
+ sender,
+ login_data={
+ "email": creds.email,
+ "password": creds.password,
+ }
+ )
+ return await _login_do(gen, sender=sender)
+
+@command_handler(needs_auth=True, management_only=True, help_section=SECTION_AUTH,
+ help_text="Remember email/password credentials for auto-login",
+ help_args="<_email_> <_password_>")
+async def save_password(evt: CommandEvent) -> None:
+ await evt.az.intent.redact(evt.room_id, evt.event_id)
+ if len(evt.args) != 2:
+ await evt.reply("Usage: `$cmdprefix+sp save_password `")
+ return
+ await _save_password_helper(evt)
+
+async def _save_password_helper(evt: CommandEvent) -> None:
+ creds = await LoginCredential.get_by_mxid(evt.sender.mxid)
+ if creds:
+ creds.email = evt.args[0]
+ creds.password = evt.args[1]
+ await creds.update()
+ else:
+ await LoginCredential(evt.sender.mxid, email=evt.args[0], password=evt.args[1]).insert()
+ await evt.reply("Login email/password saved, and will be used to log you back in if your LINE connection ends.")
+
+@command_handler(needs_auth=False, management_only=True, help_section=SECTION_AUTH,
+ help_text="Delete saved email/password credentials")
+async def forget_password(evt: CommandEvent) -> None:
+ creds = await LoginCredential.get_by_mxid(evt.sender.mxid)
+ if not creds:
+ await evt.reply("The bridge wasn't storing your email/password, so there was nothing to forget.")
+ else:
+ await creds.delete()
+ await evt.reply(
+ "This bridge is no longer storing your email/password. \n"
+ "You will have to log in manually the next time your LINE connection ends."
+ )
diff --git a/matrix_puppeteer_line/db/__init__.py b/matrix_puppeteer_line/db/__init__.py
index dff5ef7..c7517bf 100644
--- a/matrix_puppeteer_line/db/__init__.py
+++ b/matrix_puppeteer_line/db/__init__.py
@@ -9,11 +9,23 @@ from .message import Message
from .media import Media
from .receipt import Receipt
from .receipt_reaction import ReceiptReaction
+from .login_credential import LoginCredential
def init(db: Database) -> None:
- for table in (User, Puppet, Stranger, Portal, Message, Media, Receipt, ReceiptReaction):
+ for table in (User, Puppet, Stranger, Portal, Message, Media, Receipt, ReceiptReaction, LoginCredential):
table.db = db
-__all__ = ["upgrade_table", "User", "Puppet", "Stranger", "Portal", "Message", "Media", "Receipt", "ReceiptReaction"]
+__all__ = [
+ "upgrade_table",
+ "User",
+ "Puppet",
+ "Stranger",
+ "Portal",
+ "Message",
+ "Media",
+ "Receipt",
+ "ReceiptReaction",
+ "LoginCredential"
+]
diff --git a/matrix_puppeteer_line/db/login_credential.py b/matrix_puppeteer_line/db/login_credential.py
new file mode 100644
index 0000000..091f068
--- /dev/null
+++ b/matrix_puppeteer_line/db/login_credential.py
@@ -0,0 +1,54 @@
+# matrix-puppeteer-line - A very hacky Matrix-LINE bridge based on running LINE's Chrome extension in Puppeteer
+# Copyright (C) 2020-2022 Tulir Asokan, Andrew Ferrazzutti
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+from typing import Optional, ClassVar, TYPE_CHECKING
+
+from attr import dataclass
+
+from mautrix.types import UserID
+from mautrix.util.async_db import Database
+
+fake_db = Database("") if TYPE_CHECKING else None
+
+
+@dataclass
+class LoginCredential:
+ db: ClassVar[Database] = fake_db
+
+ mxid: UserID
+ email: str
+ password: str
+
+ async def insert(self) -> None:
+ q = ("INSERT INTO login_credential (mxid, email, password) "
+ "VALUES ($1, $2, $3)")
+ await self.db.execute(q, self.mxid, self.email, self.password)
+
+ async def update(self) -> None:
+ await self.db.execute("UPDATE login_credential SET email=$2, password=$3 WHERE mxid=$1",
+ self.mxid, self.email, self.password)
+
+ @classmethod
+ async def get_by_mxid(cls, mxid: UserID) -> Optional["LoginCredential"]:
+ q = ("SELECT mxid, email, password "
+ "FROM login_credential WHERE mxid=$1")
+ row = await cls.db.fetchrow(q, mxid)
+ if not row:
+ return None
+ return cls(**row)
+
+ async def delete(self) -> None:
+ await self.db.execute("DELETE FROM login_credential WHERE mxid=$1",
+ self.mxid)
diff --git a/matrix_puppeteer_line/db/upgrade.py b/matrix_puppeteer_line/db/upgrade.py
index 7a6617c..0b322cb 100644
--- a/matrix_puppeteer_line/db/upgrade.py
+++ b/matrix_puppeteer_line/db/upgrade.py
@@ -169,4 +169,17 @@ async def upgrade_latest_read_receipts(conn: Connection) -> None:
@upgrade_table.register(description="Allow messages with no mxid")
async def upgrade_nomxid_msgs(conn: Connection) -> None:
- await conn.execute("ALTER TABLE message ALTER COLUMN mxid DROP NOT NULL")
\ No newline at end of file
+ await conn.execute("ALTER TABLE message ALTER COLUMN mxid DROP NOT NULL")
+
+
+@upgrade_table.register(description="Allow storing email/password login credentials")
+async def upgrade_login_credentials(conn: Connection) -> None:
+ await conn.execute("""CREATE TABLE IF NOT EXISTS login_credential (
+ mxid TEXT PRIMARY KEY,
+ email TEXT NOT NULL,
+ password TEXT NOT NULL,
+
+ FOREIGN KEY (mxid)
+ REFERENCES "user" (mxid)
+ ON DELETE CASCADE
+ )""")
\ No newline at end of file
diff --git a/matrix_puppeteer_line/user.py b/matrix_puppeteer_line/user.py
index f1fa3c8..e7efa15 100644
--- a/matrix_puppeteer_line/user.py
+++ b/matrix_puppeteer_line/user.py
@@ -22,6 +22,8 @@ from mautrix.types import UserID, RoomID
from mautrix.appservice import AppService, IntentAPI
from mautrix.util.opt_prometheus import Gauge
+from .commands.auth import auto_login
+
from .db import User as DBUser, Portal as DBPortal, Message as DBMessage, Receipt as DBReceipt
from .config import Config
from .rpc import Client, Message, Receipt
@@ -117,6 +119,8 @@ class User(DBUser, BaseUser):
if state.is_logged_in:
await self.send_bridge_notice("Already logged in to LINE")
self.loop.create_task(self._try_sync())
+ elif await auto_login(self):
+ await self.send_bridge_notice("Auto-logged in to LINE")
else:
await self.send_bridge_notice("Ready to log in to LINE")