From bb3d7057b3d56d804758c1e187b7c371f83c9d0a Mon Sep 17 00:00:00 2001
From: Andrew Ferrazzutti <fair@miscworks.net>
Date: Sun, 6 Mar 2022 03:14:59 -0500
Subject: [PATCH] Implement logging out from the bridge

But there is no API for logging out of KakaoTalk
---
 matrix_appservice_kakaotalk/commands/auth.py | 49 +++++++++++++---
 matrix_appservice_kakaotalk/user.py          | 27 +++++----
 node/src/client.js                           | 59 ++++++++++++++------
 3 files changed, 100 insertions(+), 35 deletions(-)

diff --git a/matrix_appservice_kakaotalk/commands/auth.py b/matrix_appservice_kakaotalk/commands/auth.py
index 75e02be..36aab59 100644
--- a/matrix_appservice_kakaotalk/commands/auth.py
+++ b/matrix_appservice_kakaotalk/commands/auth.py
@@ -24,7 +24,7 @@ from mautrix.util.signed_token import sign_token
 from ..kt.client import Client as KakaoTalkClient
 from ..kt.client.errors import DeviceVerificationRequired, IncorrectPasscode, IncorrectPassword, CommandException
 
-#from .. import puppet as pu
+from .. import puppet as pu
 from .typehint import CommandEvent
 
 SECTION_AUTH = HelpSection("Authentication", 10, "")
@@ -53,7 +53,7 @@ try_again_or_cancel = "Try again, or say `$cmdprefix+sp cancel` to give up."
     help_args="[_email_]",
 )
 async def login(evt: CommandEvent) -> None:
-    if evt.sender.client:
+    if await evt.sender.is_logged_in():
         await evt.reply("You're already logged in")
         return
 
@@ -156,10 +156,43 @@ async def _handle_login_failure(evt: CommandEvent, e: Exception) -> None:
     await evt.reply(f"{message}: {e}")
 
 
-@command_handler(needs_auth=True, help_section=SECTION_AUTH, help_text="Log out of KakaoTalk")
+@command_handler(
+    needs_auth=True,
+    help_section=SECTION_AUTH,
+    help_text="Log out of KakaoTalk (and optionally change your virtual device ID for next login)",
+    help_args="[--reset-device]",
+)
 async def logout(evt: CommandEvent) -> None:
-    #puppet = await pu.Puppet.get_by_ktid(evt.sender.ktid)
-    await evt.sender.logout()
-    #if puppet.is_real_user:
-    #    await puppet.switch_mxid(None, None)
-    await evt.reply("Successfully logged out")
+    if len(evt.args) >= 1:
+        if evt.args[0] == "--reset-device":
+            reset_device = True
+        else:
+            await evt.reply("**Usage:** `$cmdprefix+sp logout [--reset-device]`")
+            return
+    else:
+        reset_device = False
+
+    puppet = await pu.Puppet.get_by_ktid(evt.sender.ktid)
+    await evt.sender.logout(reset_device=reset_device)
+    if puppet.is_real_user:
+        await puppet.switch_mxid(None, None)
+
+    message = "Successfully logged out"
+    if reset_device:
+        message += (
+            ", and your next login will use a different device ID.\n\n"
+            "The old device must be manually de-registered from the KakaoTalk app."
+        )
+    await evt.reply(message)
+
+
+@command_handler(needs_auth=False, help_section=SECTION_AUTH, help_text="Change your virtual device ID for next login")
+async def reset_device(evt: CommandEvent) -> None:
+    if await evt.sender.is_logged_in():
+        await evt.reply("This command requires you to be logged out.")
+    else:
+        await evt.sender.logout(reset_device=True)
+        await evt.reply(
+            "Your next login will use a different device ID.\n\n"
+            "The old device must be manually de-registered from the KakaoTalk app."
+        )
diff --git a/matrix_appservice_kakaotalk/user.py b/matrix_appservice_kakaotalk/user.py
index b897844..082dc55 100644
--- a/matrix_appservice_kakaotalk/user.py
+++ b/matrix_appservice_kakaotalk/user.py
@@ -172,7 +172,7 @@ class User(DBUser, BaseUser):
 
     @property
     def has_state(self) -> bool:
-        return self.uuid and self.ktid and self.access_token and self.refresh_token
+        return bool(self.uuid and self.ktid and self.access_token and self.refresh_token)
 
     # region Database getters
 
@@ -233,10 +233,13 @@ class User(DBUser, BaseUser):
 
     async def get_uuid(self, force: bool = False) -> str:
         if self.uuid is None or force:
-            self.uuid = await Client.generate_uuid(await self.get_all_uuids())
+            self.uuid = await self._generate_uuid()
             await self.save()
         return self.uuid
 
+    async def _generate_uuid(self) -> str:
+        return await Client.generate_uuid(await self.get_all_uuids())
+
     # endregion
 
     @property
@@ -369,8 +372,7 @@ class User(DBUser, BaseUser):
         finally:
             self._is_refreshing = False
 
-    async def logout(self, remove_ktid: bool = True) -> bool:
-        # TODO Remove tokens too?
+    async def logout(self, *, remove_ktid: bool = True, reset_device: bool = False) -> bool:
         ok = True
         self.stop_listen()
         if self.has_state:
@@ -385,12 +387,15 @@ class User(DBUser, BaseUser):
             await self.client.stop()
             self.client = None
 
-        if remove_ktid:
-            if self.ktid:
-                #await UserPortal.delete_all(self.ktid)
-                del self.by_ktid[self.ktid]
-                self.ktid = None
-            self.uuid = None
+        if self.ktid and remove_ktid:
+            #await UserPortal.delete_all(self.ktid)
+            del self.by_ktid[self.ktid]
+            self.ktid = None
+
+        if reset_device:
+            self.uuid = await self._generate_uuid()
+        self.access_token = None
+        self.refresh_token = None
 
         await self.save()
         return ok
@@ -583,7 +588,7 @@ class User(DBUser, BaseUser):
         self.log.info("TODO: stop_listen")
 
     async def on_logged_in(self, oauth_credential: OAuthCredential) -> None:
-        self.log.debug(f"Successfully logged in as {oauth_credential.uuid}")
+        self.log.debug(f"Successfully logged in as {oauth_credential.userId}")
         self.oauth_credential = oauth_credential
         self.client = Client(self, log=self.log.getChild("ktclient"))
         await self.save()
diff --git a/node/src/client.js b/node/src/client.js
index 509ea1e..1e34195 100644
--- a/node/src/client.js
+++ b/node/src/client.js
@@ -28,6 +28,7 @@ import {
 
 
 class UserClient {
+	static #initializing = false
 
 	#talkClient = new TalkClient()
 	get talkClient() { return this.#talkClient }
@@ -37,16 +38,28 @@ class UserClient {
 	get serviceClient() { return this.#serviceClient }
 
 	/**
-	 * @param {string} mxid The ID of the associated Matrix user
-	 * @param {OAuthCredential} credential The tokens that API calls may use
+	 * DO NOT CONSTRUCT DIRECTLY. Callers should use {@link UserClient#create} instead.
+	 * @param {string} mxid
+	 * @param {OAuthCredential} credential
 	 */
 	constructor(mxid, credential) {
+		if (!UserClient.#initializing) {
+			throw new Error("Private constructor")
+		}
+		UserClient.#initializing = false
+
 		this.mxid = mxid
 		this.credential = credential
 	}
 
+	/**
+	 * @param {string} mxid The ID of the associated Matrix user
+	 * @param {OAuthCredential} credential The tokens that API calls may use
+	 */
 	static async create(mxid, credential) {
+		this.#initializing = true
 		const userClient = new UserClient(mxid, credential)
+
 		userClient.#serviceClient = await ServiceApiClient.create(this.credential)
 		return userClient
 	}
@@ -194,15 +207,26 @@ export default class PeerClient {
 	/**
 	 * Checked lookup of a UserClient for a given mxid.
 	 * @param {string} mxid
+	 * @returns {UserClient}
 	 */
 	#getUser(mxid) {
-		/** @type {UserClient} */
 		const userClient = this.userClients.get(mxid)
 		if (userClient === undefined) {
 			throw new Error(`Could not find user ${mxid}`)
 		}
 		return userClient
 	}
+
+	/**
+	 * Get the service client for the specified user ID, or create
+	 * and return a new service client if no user ID is provided.
+	 * @param {string} mxid
+	 * @param {OAuthCredential} oauth_credential
+	 */
+	async #getServiceClient(mxid, oauth_credential) {
+		return this.userClients.get(mxid)?.serviceClient ||
+			await ServiceApiClient.create(oauth_credential)
+	}
 	
 	/**
 	 * @param {Object} req
@@ -270,9 +294,7 @@ export default class PeerClient {
 	 * @param {OAuthCredential} req.oauth_credential
 	 */
 	getOwnProfile = async (req) => {
-		const serviceClient =
-			this.userClients.get(req.mxid)?.serviceClient ||
-			await ServiceApiClient.create(req.oauth_credential)
+		const serviceClient = await this.#getServiceClient(req.mxid, req.oauth_credential)
 		return await serviceClient.requestMyProfile()
 	}
 
@@ -283,10 +305,8 @@ export default class PeerClient {
 	 * @param {Long} req.user_id
 	 */
 	getProfile = async (req) => {
-		const serviceClient =
-			this.userClients.get(mxid)?.serviceClient ||
-			await ServiceApiClient.create(req.oauth_credential)
-		return await serviceClient.requestProfile(user_id)
+		const serviceClient = await this.#getServiceClient(req.mxid, req.oauth_credential)
+		return await serviceClient.requestProfile(req.user_id)
 	}
 
 	/**
@@ -310,6 +330,16 @@ export default class PeerClient {
 		})
 	}
 
+	/**
+	 * @param {Object} req
+	 * @param {string} req.mxid
+	 */
+	handleStop = async (req) => {
+		this.#getUser(req.mxid).close()
+		this.userClients.delete(req.mxid)
+		return this.#voidCommandResult
+	}
+
 	#makeCommandResult(result) {
 		return {
 			success: true,
@@ -318,12 +348,9 @@ export default class PeerClient {
 		}
 	}
 
-	/**
-	 * @param {Object} req
-	 * @param {string} req.mxid
-	 */
-	handleStop = async (req) => {
-		this.#getUser(req.mxid).close()
+	#voidCommandResult = {
+		success: true,
+		status: 0,
 	}
 
 	handleUnknownCommand = () => {