Compare commits
8 Commits
6623dd46c0
...
5ae5970ef0
Author | SHA1 | Date | |
---|---|---|---|
5ae5970ef0 | |||
9a82db2257 | |||
63fe843724 | |||
6a828eb0b3 | |||
1eecf8da85 | |||
3b40d6971f | |||
a7cafbf367 | |||
e44536f9f2 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -21,3 +21,4 @@ puppet/extension_files
|
||||
*.db
|
||||
*.pickle
|
||||
*.bak
|
||||
/.mypy_cache/
|
||||
|
@ -47,9 +47,7 @@
|
||||
* [x] Message deletion/hiding
|
||||
* [ ] Message reactions
|
||||
* [x] Message history
|
||||
* [ ] Read receipts
|
||||
* [ ] On backfill
|
||||
* [x] On live event
|
||||
* [x] Read receipts
|
||||
* [x] Admin status
|
||||
* [ ] Membership actions
|
||||
* [ ] Invite
|
||||
|
@ -22,9 +22,17 @@ from mautrix.errors import MForbidden
|
||||
#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 ..kt.client.errors import (
|
||||
AnotherLogonDetected,
|
||||
CommandException,
|
||||
DeviceVerificationRequired,
|
||||
IncorrectPasscode,
|
||||
IncorrectPassword,
|
||||
)
|
||||
|
||||
from .. import puppet as pu
|
||||
from ..db import LoginCredential
|
||||
|
||||
from .typehint import CommandEvent
|
||||
|
||||
SECTION_AUTH = HelpSection("Authentication", 10, "")
|
||||
@ -46,28 +54,47 @@ send_password = "Please send your password here to log in"
|
||||
missing_email = "Please use `$cmdprefix+sp login <email>` to log in here"
|
||||
try_again_or_cancel = "Try again, or say `$cmdprefix+sp cancel` to give up."
|
||||
|
||||
_CMD_CONTINUE_LOGIN = "continue-login"
|
||||
|
||||
|
||||
@command_handler(
|
||||
needs_auth=False,
|
||||
management_only=True,
|
||||
help_section=SECTION_AUTH,
|
||||
help_text="Log in to KakaoTalk",
|
||||
help_args="[_email_]",
|
||||
help_text="Log in to KakaoTalk. Optionally save your password so the bridge may use it when needed to restore your login session",
|
||||
help_args="[--save] <_email_>",
|
||||
)
|
||||
async def login(evt: CommandEvent) -> None:
|
||||
if await evt.sender.is_logged_in():
|
||||
await evt.reply("You're already logged in")
|
||||
return
|
||||
|
||||
email = evt.args[0] if len(evt.args) > 0 else None
|
||||
save = len(evt.args) > 0 and evt.args[0] == "--save"
|
||||
email = evt.args[0 if not save else 1] if len(evt.args) > 0 else None
|
||||
|
||||
if email:
|
||||
evt.sender.command_status = {
|
||||
"action": "Login",
|
||||
"room_id": evt.room_id,
|
||||
"next": enter_password,
|
||||
"next": _enter_password,
|
||||
"email": email,
|
||||
"save": save,
|
||||
"forced": evt.sender.force_login,
|
||||
}
|
||||
try:
|
||||
creds = await LoginCredential.get_by_mxid(evt.sender.mxid)
|
||||
except:
|
||||
evt.log.exception("Exception while looking for saved password")
|
||||
creds = None
|
||||
if creds and creds.email == email:
|
||||
await evt.reply("Logging in with saved password")
|
||||
await _login_with_password(
|
||||
evt,
|
||||
evt.sender.command_status.pop("email"),
|
||||
creds.password,
|
||||
evt.sender.command_status.pop("forced"),
|
||||
)
|
||||
return
|
||||
|
||||
""" TODO Implement web login
|
||||
if evt.bridge.public_website:
|
||||
@ -94,65 +121,146 @@ async def login(evt: CommandEvent) -> None:
|
||||
if not email:
|
||||
await evt.reply(f"{missing_email}.")
|
||||
else:
|
||||
await evt.reply(f"{send_password}.")
|
||||
save_warning = (
|
||||
" NOTE: With `--save`, the bridge will store your KakaoTalk password **unencrypted** until you discard it with `$cmdprefix+sp forget-password`."
|
||||
" If you would rather not have the bridge save your password, type `$cmdprefix+sp cancel`, then `$cmdprefix+sp login <email>` without `--save`."
|
||||
) if save else ""
|
||||
await evt.reply(f"{send_password}.{save_warning}")
|
||||
|
||||
|
||||
async def enter_password(evt: CommandEvent) -> None:
|
||||
async def _enter_password(evt: CommandEvent) -> None:
|
||||
try:
|
||||
await evt.az.intent.redact(evt.room_id, evt.event_id)
|
||||
except MForbidden:
|
||||
pass
|
||||
|
||||
await evt.mark_read()
|
||||
assert evt.sender.command_status
|
||||
await _login_with_password(
|
||||
evt,
|
||||
evt.sender.command_status.pop("email"),
|
||||
evt.content.body,
|
||||
evt.sender.command_status.pop("forced"),
|
||||
)
|
||||
|
||||
async def _login_with_password(evt: CommandEvent, email: str, password: str, forced: bool) -> None:
|
||||
req = {
|
||||
"uuid": await evt.sender.get_uuid(),
|
||||
"form": {
|
||||
"email": evt.sender.command_status["email"],
|
||||
"password": evt.content.body,
|
||||
}
|
||||
"email": email,
|
||||
"password": password,
|
||||
},
|
||||
"forced": forced,
|
||||
}
|
||||
await _try_login(evt, req)
|
||||
|
||||
async def _try_login(evt: CommandEvent, req: dict) -> None:
|
||||
save = (
|
||||
evt.sender.command_status.get("save", False)
|
||||
if evt.sender.command_status
|
||||
else False
|
||||
)
|
||||
try:
|
||||
await _do_login(evt, req)
|
||||
except DeviceVerificationRequired:
|
||||
await evt.reply(
|
||||
"Open KakaoTalk on your smartphone. It should show a device registration passcode. "
|
||||
"Enter that passcode here."
|
||||
)
|
||||
await _do_login(evt, req, save)
|
||||
except DeviceVerificationRequired as e:
|
||||
if evt.sender.command_status and evt.sender.command_status.get("next") != _enter_dv_code:
|
||||
await evt.reply(
|
||||
"Open KakaoTalk on your smartphone. It should show a device registration passcode. "
|
||||
"Enter that passcode here."
|
||||
)
|
||||
evt.sender.command_status = {
|
||||
"action": "Login",
|
||||
"room_id": evt.room_id,
|
||||
"next": _enter_dv_code,
|
||||
"save": save,
|
||||
"req": req,
|
||||
}
|
||||
else:
|
||||
evt.log.error("Device registration still required after having just registered")
|
||||
await _handle_login_failure(evt, e)
|
||||
except AnotherLogonDetected as e:
|
||||
if not req["forced"]:
|
||||
await evt.reply(
|
||||
"You are currently logged in to KakaoTalk on a PC or another bridge. "
|
||||
"In order to log in to this bridge, you will be forced to log out from your other session. "
|
||||
f"To proceed, type `$cmdprefix+sp {_CMD_CONTINUE_LOGIN}`. Otherwise, type `$cmdprefix+sp cancel`."
|
||||
)
|
||||
evt.sender.command_status = {
|
||||
"action": "Login",
|
||||
"room_id": evt.room_id,
|
||||
"next": _force_login,
|
||||
"save": save,
|
||||
"req": req,
|
||||
}
|
||||
else:
|
||||
evt.log.error("Failed to force-login while logged in elsewhere")
|
||||
await _handle_login_failure(evt, e)
|
||||
except IncorrectPassword:
|
||||
await evt.reply(f"Incorrect password. {try_again_or_cancel}")
|
||||
evt.sender.command_status = {
|
||||
"action": "Login",
|
||||
"room_id": evt.room_id,
|
||||
"next": enter_dv_code,
|
||||
"req": req,
|
||||
"next": _enter_password,
|
||||
"email": req["form"]["email"],
|
||||
"save": save,
|
||||
"forced": req["forced"],
|
||||
}
|
||||
except IncorrectPassword:
|
||||
await evt.reply(f"Incorrect password. {try_again_or_cancel}")
|
||||
#except OAuthException as e:
|
||||
# await evt.reply(f"Error from KakaoTalk:\n\n> {e}")
|
||||
except Exception as e:
|
||||
await _handle_login_failure(evt, e)
|
||||
|
||||
|
||||
async def enter_dv_code(evt: CommandEvent) -> None:
|
||||
async def _enter_dv_code(evt: CommandEvent) -> None:
|
||||
assert evt.sender.command_status
|
||||
req: dict = evt.sender.command_status["req"]
|
||||
req: dict = evt.sender.command_status.pop("req")
|
||||
passcode = evt.content.body
|
||||
await evt.mark_read()
|
||||
try:
|
||||
await KakaoTalkClient.register_device(passcode, **req)
|
||||
await _do_login(evt, req)
|
||||
except IncorrectPasscode:
|
||||
await evt.reply(f"Incorrect device registration passcode. {try_again_or_cancel}")
|
||||
#except OAuthException as e:
|
||||
# await evt.reply(f"Error from KakaoTalk:\n\n> {e}")
|
||||
except Exception as e:
|
||||
await _handle_login_failure(evt, e)
|
||||
else:
|
||||
await _try_login(evt, req)
|
||||
|
||||
|
||||
async def _do_login(evt: CommandEvent, req: dict) -> None:
|
||||
async def _force_login(evt: CommandEvent) -> None:
|
||||
command = evt.content.body
|
||||
if command != _CMD_CONTINUE_LOGIN:
|
||||
await evt.reply(
|
||||
f"Unknown action `{command}`. "
|
||||
f"Use `$cmdprefix+sp {_CMD_CONTINUE_LOGIN}` or `$cmdprefix+sp cancel`."
|
||||
)
|
||||
return
|
||||
assert evt.sender.command_status
|
||||
evt.sender.command_status["forced"] = True
|
||||
await evt.mark_read()
|
||||
await _try_login(evt, evt.sender.command_status.pop("req"))
|
||||
|
||||
|
||||
async def _do_login(evt: CommandEvent, req: dict, save: bool) -> None:
|
||||
oauth_credential = await KakaoTalkClient.login(**req)
|
||||
await evt.sender.on_logged_in(oauth_credential)
|
||||
evt.sender.command_status = None
|
||||
await evt.reply("Successfully logged in")
|
||||
try:
|
||||
if save:
|
||||
email = req["form"]["email"]
|
||||
password = req["form"]["password"]
|
||||
creds = await LoginCredential.get_by_mxid(evt.sender.mxid)
|
||||
if creds:
|
||||
evt.log.warning("Found pre-existing login credentials, so updating them now")
|
||||
creds.email = email
|
||||
creds.password = password
|
||||
await creds.save()
|
||||
else:
|
||||
await LoginCredential(evt.sender.mxid, email, password).insert()
|
||||
else:
|
||||
await LoginCredential.delete_by_mxid(evt.sender.mxid)
|
||||
except:
|
||||
evt.log.exception("Error updating saved credentials after successful login")
|
||||
|
||||
async def _handle_login_failure(evt: CommandEvent, e: Exception) -> None:
|
||||
evt.sender.command_status = None
|
||||
@ -165,6 +273,24 @@ async def _handle_login_failure(evt: CommandEvent, e: Exception) -> None:
|
||||
await evt.reply(f"{message}: {e}")
|
||||
|
||||
|
||||
@command_handler(
|
||||
needs_auth=True,
|
||||
management_only=True,
|
||||
help_section=SECTION_AUTH,
|
||||
help_text="Delete saved login password, if it was saved"
|
||||
)
|
||||
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 password, so there was nothing to forget.")
|
||||
else:
|
||||
await creds.delete()
|
||||
await evt.reply(
|
||||
"This bridge is no longer storing your password. "
|
||||
"If you get logged out unexpectedly, you will have to manually log back in."
|
||||
)
|
||||
|
||||
|
||||
@command_handler(
|
||||
needs_auth=True,
|
||||
help_section=SECTION_AUTH,
|
||||
@ -206,3 +332,30 @@ async def reset_device(evt: CommandEvent) -> None:
|
||||
"Your next login will use a different device ID.\n\n"
|
||||
"The old device must be manually de-registered from the KakaoTalk app."
|
||||
)
|
||||
|
||||
|
||||
@command_handler(
|
||||
needs_auth=False,
|
||||
help_section=SECTION_AUTH,
|
||||
help_text="When logging in, automatically log out of any other existing KakaoTalk session"
|
||||
)
|
||||
async def enable_forced_login(evt: CommandEvent) -> None:
|
||||
if evt.sender.force_login:
|
||||
await evt.reply("Forced login is already enabled.")
|
||||
return
|
||||
evt.sender.force_login = True
|
||||
await evt.sender.save()
|
||||
await evt.reply("Forced login is now enabled.")
|
||||
|
||||
@command_handler(
|
||||
needs_auth=False,
|
||||
help_section=SECTION_AUTH,
|
||||
help_text="When logging in, ask before logging out of another existing KakaoTalk session, if one exists"
|
||||
)
|
||||
async def disable_forced_login(evt: CommandEvent) -> None:
|
||||
if not evt.sender.force_login:
|
||||
await evt.reply("Forced login is already disabled.")
|
||||
return
|
||||
evt.sender.force_login = False
|
||||
await evt.sender.save()
|
||||
await evt.reply("Forced login is now disabled.")
|
||||
|
@ -45,6 +45,12 @@ async def disconnect(evt: CommandEvent) -> None:
|
||||
if not evt.sender.is_connected:
|
||||
await evt.reply("You are already disconnected from KakaoTalk chats")
|
||||
return
|
||||
if not evt.sender.config["bridge.remain_logged_in_on_disconnect"]:
|
||||
await evt.reply(
|
||||
"This instance of the KakaoTalk bridge does not allow being disconnected from KakaoTalk chats while logged in. "
|
||||
"So, to disconnect, you must log out entirely with the `logout` command."
|
||||
)
|
||||
return
|
||||
await evt.mark_read()
|
||||
await evt.sender.client.disconnect()
|
||||
await evt.reply("Successfully disconnected from KakaoTalk chats. To reconnect, use the `sync` command.")
|
||||
@ -108,7 +114,7 @@ async def sync(evt: CommandEvent) -> None:
|
||||
return
|
||||
|
||||
await evt.mark_read()
|
||||
if await evt.sender.connect_and_sync(sync_count):
|
||||
if await evt.sender.connect_and_sync(sync_count, force_sync=True):
|
||||
await evt.reply("Sync complete")
|
||||
else:
|
||||
await evt.reply("Sync failed")
|
||||
|
@ -98,31 +98,19 @@ class Config(BaseBridgeConfig):
|
||||
copy("bridge.backfill.initial_limit")
|
||||
copy("bridge.backfill.missed_limit")
|
||||
copy("bridge.backfill.disable_notifications")
|
||||
""" TODO
|
||||
copy("bridge.periodic_reconnect.interval")
|
||||
copy("bridge.periodic_reconnect.always")
|
||||
copy("bridge.periodic_reconnect.min_connected_time")
|
||||
"""
|
||||
copy("bridge.resync_max_disconnected_time")
|
||||
copy("bridge.remain_logged_in_on_disconnect")
|
||||
copy("bridge.allow_token_relogin")
|
||||
copy("bridge.reconnect_on_token_relogin")
|
||||
copy("bridge.sync_on_startup")
|
||||
copy("bridge.temporary_disconnect_notices")
|
||||
copy("bridge.disable_bridge_notices")
|
||||
copy("bridge.resend_bridge_info")
|
||||
copy("bridge.mute_bridging")
|
||||
copy("bridge.tag_only_on_create")
|
||||
copy("bridge.sandbox_media_download")
|
||||
|
||||
copy_dict("bridge.permissions")
|
||||
|
||||
""" TODO
|
||||
for key in (
|
||||
"bridge.periodic_reconnect.interval",
|
||||
):
|
||||
value = base.get(key, None)
|
||||
if isinstance(value, list) and len(value) != 2:
|
||||
raise ValueError(f"{key} must only be a list of two items")
|
||||
"""
|
||||
|
||||
copy("rpc.connection.type")
|
||||
if base["rpc.connection.type"] == "unix":
|
||||
copy("rpc.connection.path")
|
||||
|
@ -20,10 +20,11 @@ from .portal import Portal
|
||||
from .puppet import Puppet
|
||||
from .upgrade import upgrade_table
|
||||
from .user import User
|
||||
from .login_credential import LoginCredential
|
||||
|
||||
|
||||
def init(db: Database) -> None:
|
||||
for table in (Portal, Message, User, Puppet):
|
||||
for table in (Portal, Message, User, Puppet, LoginCredential):
|
||||
table.db = db
|
||||
|
||||
|
||||
@ -34,4 +35,5 @@ __all__ = [
|
||||
"Portal",
|
||||
"Puppet",
|
||||
"User",
|
||||
"LoginCredential",
|
||||
]
|
||||
|
68
matrix_appservice_kakaotalk/db/login_credential.py
Normal file
68
matrix_appservice_kakaotalk/db/login_credential.py
Normal file
@ -0,0 +1,68 @@
|
||||
# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge.
|
||||
# Copyright (C) 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 <https://www.gnu.org/licenses/>.
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from asyncpg import Record
|
||||
from attr import dataclass
|
||||
|
||||
from mautrix.types import UserID
|
||||
from mautrix.util.async_db import Database
|
||||
|
||||
fake_db = Database.create("") if TYPE_CHECKING else None
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoginCredential:
|
||||
db: ClassVar[Database] = fake_db
|
||||
|
||||
mxid: UserID
|
||||
email: str
|
||||
password: str
|
||||
|
||||
@classmethod
|
||||
async def get_by_mxid(cls, mxid: UserID) -> LoginCredential | None:
|
||||
q = "SELECT mxid, email, password FROM login_credential WHERE mxid=$1"
|
||||
row = await cls.db.fetchrow(q, mxid)
|
||||
return cls(**row) if row else None
|
||||
|
||||
@classmethod
|
||||
async def delete_by_mxid(cls, mxid: UserID) -> None:
|
||||
await cls.db.execute("DELETE FROM login_credential WHERE mxid=$1", mxid)
|
||||
|
||||
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)
|
||||
|
||||
def get_form(self) -> dict[str, str]:
|
||||
return {
|
||||
"email": self.email,
|
||||
"password": self.password,
|
||||
}
|
||||
|
||||
async def delete(self) -> None:
|
||||
await self.delete_by_mxid(self.mxid)
|
||||
|
||||
async def save(self) -> None:
|
||||
q = """
|
||||
UPDATE login_credential SET email=$2, password=$3
|
||||
WHERE mxid=$1
|
||||
"""
|
||||
await self.db.execute(q, self.mxid, self.email, self.password)
|
@ -36,7 +36,7 @@ class Message:
|
||||
mx_room: RoomID
|
||||
ktid: Long | None = field(converter=to_optional_long)
|
||||
index: int
|
||||
kt_chat: Long = field(converter=Long)
|
||||
kt_channel: Long = field(converter=Long)
|
||||
kt_receiver: Long = field(converter=Long)
|
||||
timestamp: int
|
||||
|
||||
@ -48,18 +48,18 @@ class Message:
|
||||
def _from_optional_row(cls, row: Record | None) -> Message | None:
|
||||
return cls._from_row(row) if row is not None else None
|
||||
|
||||
columns = 'mxid, mx_room, ktid, "index", kt_chat, kt_receiver, timestamp'
|
||||
columns = 'mxid, mx_room, ktid, "index", kt_channel, kt_receiver, timestamp'
|
||||
|
||||
@classmethod
|
||||
async def get_all_by_ktid(cls, ktid: int, kt_receiver: int) -> list[Message]:
|
||||
q = f"SELECT {cls.columns} FROM message WHERE ktid=$1 AND kt_receiver=$2"
|
||||
rows = await cls.db.fetch(q, ktid, kt_receiver)
|
||||
async def get_all_by_ktid(cls, ktid: int, kt_channel: int, kt_receiver: int) -> list[Message]:
|
||||
q = f"SELECT {cls.columns} FROM message WHERE ktid=$1 AND kt_channel=$2 AND kt_receiver=$3"
|
||||
rows = await cls.db.fetch(q, ktid, kt_channel, kt_receiver)
|
||||
return [cls._from_row(row) for row in rows if row]
|
||||
|
||||
@classmethod
|
||||
async def get_by_ktid(cls, ktid: int, kt_receiver: int, index: int = 0) -> Message | None:
|
||||
q = f'SELECT {cls.columns} FROM message WHERE ktid=$1 AND kt_receiver=$2 AND "index"=$3'
|
||||
row = await cls.db.fetchrow(q, ktid, kt_receiver, index)
|
||||
async def get_by_ktid(cls, ktid: int, kt_channel: int, kt_receiver: int, index: int = 0) -> Message | None:
|
||||
q = f'SELECT {cls.columns} FROM message WHERE ktid=$1 AND kt_channel=$2 AND kt_receiver=$3 AND "index"=$4'
|
||||
row = await cls.db.fetchrow(q, ktid, kt_channel, kt_receiver, index)
|
||||
return cls._from_optional_row(row)
|
||||
|
||||
@classmethod
|
||||
@ -73,30 +73,39 @@ class Message:
|
||||
return cls._from_optional_row(row)
|
||||
|
||||
@classmethod
|
||||
async def get_most_recent(cls, kt_chat: int, kt_receiver: int) -> Message | None:
|
||||
async def get_most_recent(cls, kt_channel: int, kt_receiver: int) -> Message | None:
|
||||
q = (
|
||||
f"SELECT {cls.columns} "
|
||||
"FROM message WHERE kt_chat=$1 AND kt_receiver=$2 AND ktid IS NOT NULL "
|
||||
"ORDER BY timestamp DESC LIMIT 1"
|
||||
"FROM message WHERE kt_channel=$1 AND kt_receiver=$2 AND ktid IS NOT NULL "
|
||||
"ORDER BY ktid DESC LIMIT 1"
|
||||
)
|
||||
row = await cls.db.fetchrow(q, kt_chat, kt_receiver)
|
||||
row = await cls.db.fetchrow(q, kt_channel, kt_receiver)
|
||||
return cls._from_optional_row(row)
|
||||
|
||||
@classmethod
|
||||
async def get_closest_before(
|
||||
cls, kt_chat: int, kt_receiver: int, ktid: Long
|
||||
cls, kt_channel: int, kt_receiver: int, ktid: int
|
||||
) -> Message | None:
|
||||
q = (
|
||||
f"SELECT {cls.columns} "
|
||||
"FROM message WHERE kt_chat=$1 AND kt_receiver=$2 AND ktid<=$3 AND "
|
||||
" ktid IS NOT NULL "
|
||||
"FROM message WHERE kt_channel=$1 AND kt_receiver=$2 AND ktid<=$3 "
|
||||
"ORDER BY ktid DESC LIMIT 1"
|
||||
)
|
||||
row = await cls.db.fetchrow(q, kt_chat, kt_receiver, ktid)
|
||||
row = await cls.db.fetchrow(q, kt_channel, kt_receiver, ktid)
|
||||
return cls._from_optional_row(row)
|
||||
|
||||
@classmethod
|
||||
async def get_all_since(cls, kt_channel: int, kt_receiver: int, since_ktid: Long | None) -> list[Message]:
|
||||
q = (
|
||||
f"SELECT {cls.columns} "
|
||||
"FROM message WHERE kt_channel=$1 AND kt_receiver=$2 AND ktid>=$3 "
|
||||
"ORDER BY ktid"
|
||||
)
|
||||
rows = await cls.db.fetch(q, kt_channel, kt_receiver, since_ktid or 0)
|
||||
return [cls._from_row(row) for row in rows if row]
|
||||
|
||||
_insert_query = (
|
||||
'INSERT INTO message (mxid, mx_room, ktid, "index", kt_chat, kt_receiver, '
|
||||
'INSERT INTO message (mxid, mx_room, ktid, "index", kt_channel, kt_receiver, '
|
||||
" timestamp) "
|
||||
"VALUES ($1, $2, $3, $4, $5, $6, $7)"
|
||||
)
|
||||
@ -105,7 +114,7 @@ class Message:
|
||||
async def bulk_create(
|
||||
cls,
|
||||
ktid: Long,
|
||||
kt_chat: Long,
|
||||
kt_channel: Long,
|
||||
kt_receiver: Long,
|
||||
event_ids: list[EventID],
|
||||
timestamp: int,
|
||||
@ -115,7 +124,7 @@ class Message:
|
||||
return []
|
||||
columns = [col.strip('"') for col in cls.columns.split(", ")]
|
||||
records = [
|
||||
(mxid, mx_room, ktid, index, kt_chat, kt_receiver, timestamp)
|
||||
(mxid, mx_room, ktid, index, kt_channel, kt_receiver, timestamp)
|
||||
for index, mxid in enumerate(event_ids)
|
||||
]
|
||||
async with cls.db.acquire() as conn, conn.transaction():
|
||||
@ -134,7 +143,7 @@ class Message:
|
||||
self.mx_room,
|
||||
self.ktid,
|
||||
self.index,
|
||||
self.kt_chat,
|
||||
self.kt_channel,
|
||||
self.kt_receiver,
|
||||
self.timestamp,
|
||||
)
|
||||
|
@ -23,7 +23,7 @@ from attr import dataclass, field
|
||||
from mautrix.types import ContentURI, RoomID, UserID
|
||||
from mautrix.util.async_db import Database
|
||||
|
||||
from ..kt.types.bson import Long
|
||||
from ..kt.types.bson import Long, to_optional_long
|
||||
from ..kt.types.channel.channel_type import ChannelType
|
||||
|
||||
fake_db = Database.create("") if TYPE_CHECKING else None
|
||||
@ -45,6 +45,7 @@ class Portal:
|
||||
name_set: bool
|
||||
topic_set: bool
|
||||
avatar_set: bool
|
||||
fully_read_kt_chat: Long | None = field(converter=to_optional_long)
|
||||
relay_user_id: UserID | None
|
||||
|
||||
@classmethod
|
||||
@ -55,43 +56,32 @@ class Portal:
|
||||
def _from_optional_row(cls, row: Record | None) -> Portal | None:
|
||||
return cls._from_row(row) if row is not None else None
|
||||
|
||||
_columns = (
|
||||
"ktid, kt_receiver, kt_type, mxid, name, description, photo_id, avatar_url, encrypted, "
|
||||
"name_set, avatar_set, fully_read_kt_chat, relay_user_id"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def get_by_ktid(cls, ktid: int, kt_receiver: int) -> Portal | None:
|
||||
q = """
|
||||
SELECT ktid, kt_receiver, kt_type, mxid, name, description, photo_id, avatar_url, encrypted,
|
||||
name_set, avatar_set, relay_user_id
|
||||
FROM portal WHERE ktid=$1 AND kt_receiver=$2
|
||||
"""
|
||||
q = f"SELECT {cls._columns} FROM portal WHERE ktid=$1 AND kt_receiver=$2"
|
||||
row = await cls.db.fetchrow(q, ktid, kt_receiver)
|
||||
return cls._from_optional_row(row)
|
||||
|
||||
@classmethod
|
||||
async def get_by_mxid(cls, mxid: RoomID) -> Portal | None:
|
||||
q = """
|
||||
SELECT ktid, kt_receiver, kt_type, mxid, name, description, photo_id, avatar_url, encrypted,
|
||||
name_set, avatar_set, relay_user_id
|
||||
FROM portal WHERE mxid=$1
|
||||
"""
|
||||
q = f"SELECT {cls._columns} FROM portal WHERE mxid=$1"
|
||||
row = await cls.db.fetchrow(q, mxid)
|
||||
return cls._from_optional_row(row)
|
||||
|
||||
@classmethod
|
||||
async def get_all_by_receiver(cls, kt_receiver: int) -> list[Portal]:
|
||||
q = """
|
||||
SELECT ktid, kt_receiver, kt_type, mxid, name, description, photo_id, avatar_url, encrypted,
|
||||
name_set, avatar_set, relay_user_id
|
||||
FROM portal WHERE kt_receiver=$1
|
||||
"""
|
||||
q = f"SELECT {cls._columns} FROM portal WHERE kt_receiver=$1"
|
||||
rows = await cls.db.fetch(q, kt_receiver)
|
||||
return [cls._from_row(row) for row in rows if row]
|
||||
|
||||
@classmethod
|
||||
async def all(cls) -> list[Portal]:
|
||||
q = """
|
||||
SELECT ktid, kt_receiver, kt_type, mxid, name, description, photo_id, avatar_url, encrypted,
|
||||
name_set, avatar_set, relay_user_id
|
||||
FROM portal
|
||||
"""
|
||||
q = f"SELECT {cls._columns} FROM portal"
|
||||
rows = await cls.db.fetch(q)
|
||||
return [cls._from_row(row) for row in rows if row]
|
||||
|
||||
@ -109,25 +99,24 @@ class Portal:
|
||||
self.encrypted,
|
||||
self.name_set,
|
||||
self.avatar_set,
|
||||
self.fully_read_kt_chat,
|
||||
self.relay_user_id,
|
||||
)
|
||||
|
||||
_args = "$" + ", $".join(str(i + 1) for i in range(_columns.count(',') + 1))
|
||||
async def insert(self) -> None:
|
||||
q = """
|
||||
INSERT INTO portal (ktid, kt_receiver, kt_type, mxid, name, description, photo_id, avatar_url,
|
||||
encrypted, name_set, avatar_set, relay_user_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
"""
|
||||
q = f"INSERT INTO portal ({self._columns}) VALUES ({self._args})"
|
||||
await self.db.execute(q, *self._values)
|
||||
|
||||
async def delete(self) -> None:
|
||||
q = "DELETE FROM portal WHERE ktid=$1 AND kt_receiver=$2"
|
||||
await self.db.execute(q, self.ktid, self.kt_receiver)
|
||||
|
||||
_nonkey_column_asgns = ", ".join(
|
||||
map(lambda t: (i:=t[0], word:=t[1], f"{word}=${i + 3}")[-1],
|
||||
enumerate(_columns.split(", ")[2:])
|
||||
)
|
||||
)
|
||||
async def save(self) -> None:
|
||||
q = """
|
||||
UPDATE portal SET kt_type=$3, mxid=$4, name=$5, description=$6, photo_id=$7, avatar_url=$8,
|
||||
encrypted=$9, name_set=$10, avatar_set=$11, relay_user_id=$12
|
||||
WHERE ktid=$1 AND kt_receiver=$2
|
||||
"""
|
||||
q = f"UPDATE portal SET {self._nonkey_column_asgns} WHERE ktid=$1 AND kt_receiver=$2"
|
||||
await self.db.execute(q, *self._values)
|
||||
|
@ -19,3 +19,5 @@ upgrade_table = UpgradeTable()
|
||||
|
||||
from . import v01_initial_revision
|
||||
from . import v02_channel_meta
|
||||
from . import v03_user_connection
|
||||
from . import v04_read_receipt_sync
|
||||
|
@ -0,0 +1,38 @@
|
||||
# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge.
|
||||
# Copyright (C) 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 <https://www.gnu.org/licenses/>.
|
||||
from mautrix.util.async_db import Connection
|
||||
|
||||
from . import upgrade_table
|
||||
|
||||
|
||||
@upgrade_table.register(description="Track login credentials, connections, and force-login preferences")
|
||||
async def upgrade_v3(conn: Connection) -> None:
|
||||
await conn.execute('ALTER TABLE "user" ADD COLUMN force_login BOOLEAN NOT NULL DEFAULT false')
|
||||
await conn.execute('ALTER TABLE "user" ADD COLUMN was_connected BOOLEAN NOT NULL DEFAULT false')
|
||||
# Just for now, assume that logged in = connected
|
||||
await conn.execute('UPDATE "user" SET was_connected=true WHERE access_token IS NOT NULL')
|
||||
|
||||
await conn.execute(
|
||||
"""CREATE TABLE login_credential (
|
||||
mxid TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
|
||||
FOREIGN KEY (mxid)
|
||||
REFERENCES "user" (mxid)
|
||||
ON DELETE CASCADE
|
||||
)"""
|
||||
)
|
@ -0,0 +1,52 @@
|
||||
# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge.
|
||||
# Copyright (C) 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 <https://www.gnu.org/licenses/>.
|
||||
from mautrix.util.async_db import Connection, Scheme
|
||||
|
||||
from . import upgrade_table
|
||||
|
||||
|
||||
@upgrade_table.register(description="Fix message table and add tracking to assist with backfilling read receipts")
|
||||
async def upgrade_v4(conn: Connection, scheme: Scheme) -> None:
|
||||
if scheme != Scheme.SQLITE:
|
||||
await conn.execute("ALTER TABLE message RENAME COLUMN kt_chat TO kt_channel")
|
||||
await conn.execute("ALTER TABLE message DROP CONSTRAINT message_pkey")
|
||||
await conn.execute('ALTER TABLE message ADD PRIMARY KEY (ktid, kt_channel, kt_receiver, "index")')
|
||||
else:
|
||||
await conn.execute(
|
||||
"""CREATE TABLE message_v4 (
|
||||
mxid TEXT NOT NULL,
|
||||
mx_room TEXT NOT NULL,
|
||||
ktid BIGINT,
|
||||
kt_receiver BIGINT NOT NULL,
|
||||
"index" SMALLINT NOT NULL,
|
||||
kt_channel BIGINT NOT NULL,
|
||||
timestamp BIGINT NOT NULL,
|
||||
PRIMARY KEY (ktid, kt_channel, kt_receiver, "index"),
|
||||
FOREIGN KEY (kt_channel, kt_receiver) REFERENCES portal(ktid, kt_receiver)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
UNIQUE (mxid, mx_room)
|
||||
)"""
|
||||
)
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO message_v4 (mxid, mx_room, ktid, kt_receiver, "index", kt_channel, timestamp)
|
||||
SELECT mxid, mx_room, ktid, kt_receiver, "index", kt_chat, timestamp FROM message
|
||||
"""
|
||||
)
|
||||
await conn.execute("DROP TABLE message")
|
||||
await conn.execute("ALTER TABLE message_v4 RENAME TO message")
|
||||
|
||||
await conn.execute("ALTER TABLE portal ADD COLUMN fully_read_kt_chat BIGINT")
|
@ -33,6 +33,8 @@ class User:
|
||||
db: ClassVar[Database] = fake_db
|
||||
|
||||
mxid: UserID
|
||||
force_login: bool
|
||||
was_connected: bool
|
||||
ktid: Long | None = field(converter=to_optional_long)
|
||||
uuid: str | None
|
||||
access_token: str | None
|
||||
@ -47,7 +49,7 @@ class User:
|
||||
def _from_optional_row(cls, row: Record | None) -> User | None:
|
||||
return cls._from_row(row) if row is not None else None
|
||||
|
||||
_columns = "mxid, ktid, uuid, access_token, refresh_token, notice_room"
|
||||
_columns = "mxid, force_login, was_connected, ktid, uuid, access_token, refresh_token, notice_room"
|
||||
|
||||
@classmethod
|
||||
async def all_logged_in(cls) -> List[User]:
|
||||
@ -76,6 +78,8 @@ class User:
|
||||
def _values(self):
|
||||
return (
|
||||
self.mxid,
|
||||
self.force_login,
|
||||
self.was_connected,
|
||||
self.ktid,
|
||||
self.uuid,
|
||||
self.access_token,
|
||||
@ -85,8 +89,8 @@ class User:
|
||||
|
||||
async def insert(self) -> None:
|
||||
q = """
|
||||
INSERT INTO "user" (mxid, ktid, uuid, access_token, refresh_token, notice_room)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
INSERT INTO "user" (mxid, force_login, was_connected, ktid, uuid, access_token, refresh_token, notice_room)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
"""
|
||||
await self.db.execute(q, *self._values)
|
||||
|
||||
@ -95,7 +99,7 @@ class User:
|
||||
|
||||
async def save(self) -> None:
|
||||
q = """
|
||||
UPDATE "user" SET ktid=$2, uuid=$3, access_token=$4, refresh_token=$5, notice_room=$6
|
||||
UPDATE "user" SET force_login=$2, was_connected=$3, ktid=$4, uuid=$5, access_token=$6, refresh_token=$7, notice_room=$8
|
||||
WHERE mxid=$1
|
||||
"""
|
||||
await self.db.execute(q, *self._values)
|
||||
|
@ -175,23 +175,22 @@ bridge:
|
||||
# If using double puppeting, should notifications be disabled
|
||||
# while the initial backfill is in progress?
|
||||
disable_notifications: false
|
||||
# TODO Implement this
|
||||
#periodic_reconnect:
|
||||
# # Interval in seconds in which to automatically reconnect all users.
|
||||
# # This may prevent KakaoTalk from "switching servers".
|
||||
# # Set to -1 to disable periodic reconnections entirely.
|
||||
# # Set to a list of two items to randomize the interval (min, max).
|
||||
# interval: -1
|
||||
# # Should even disconnected users be reconnected?
|
||||
# always: false
|
||||
# # Only reconnect if the user has been connected for longer than this value
|
||||
# min_connected_time: 0
|
||||
# The number of seconds that a disconnection can last without triggering an automatic re-sync
|
||||
# and missed message backfilling when reconnecting.
|
||||
# Set to 0 to always re-sync, or -1 to never re-sync automatically.
|
||||
# TODO Actually use this setting
|
||||
resync_max_disconnected_time: 5
|
||||
# Should the bridge do a resync on startup?
|
||||
# Should users remain logged in after being disconnected from chatroom updates?
|
||||
# This is a convenience feature, but might make the bridge look more suspicious to KakaoTalk.
|
||||
remain_logged_in_on_disconnect: true
|
||||
# May the bridge restore user logins with session tokens instead of requiring a password?
|
||||
# This is a convenience feature, but might make the bridge look more suspicious to KakaoTalk.
|
||||
# Note that password-based login will be tried first for users who have saved their password.
|
||||
allow_token_relogin: true
|
||||
# Should the bridge connect users to chatroom updates after a token-based login?
|
||||
# This will disconnect any KakaoTalk PC/bridge sessions that were started since the last connection.
|
||||
# This is a convenience feature, but might make the bridge look more suspicious to KakaoTalk.
|
||||
reconnect_on_token_relogin: true
|
||||
# Should the bridge do a resync for connected users on startup?
|
||||
sync_on_startup: true
|
||||
# Whether or not temporary disconnections should send notices to the notice room.
|
||||
# If this is false, disconnections will never send messages and connections will only send
|
||||
@ -203,8 +202,6 @@ bridge:
|
||||
# This field will automatically be changed back to false after it,
|
||||
# except if the config file is not writable.
|
||||
resend_bridge_info: false
|
||||
# When using double puppeting, should muted chats be muted in Matrix?
|
||||
mute_bridging: false
|
||||
# Whether or not mute status and tags should only be bridged when the portal room is created.
|
||||
tag_only_on_create: true
|
||||
# If set to true, downloading media from the CDN will use a plain aiohttp client without the usual headers or
|
||||
|
@ -67,6 +67,7 @@ from .types import (
|
||||
ChannelProps,
|
||||
PortalChannelInfo,
|
||||
PortalChannelParticipantInfo,
|
||||
Receipt,
|
||||
SettingsStruct,
|
||||
UserInfoUnion,
|
||||
)
|
||||
@ -137,13 +138,19 @@ class Client:
|
||||
return cls._api_request_void("register_device", passcode=passcode, **req)
|
||||
|
||||
@classmethod
|
||||
def login(cls, **req: JSON) -> Awaitable[OAuthCredential]:
|
||||
def login(cls, uuid: str, form: JSON, forced: bool) -> Awaitable[OAuthCredential]:
|
||||
"""
|
||||
Obtain a session token by logging in with user-provided credentials.
|
||||
Must have first called register_device with these credentials.
|
||||
"""
|
||||
# NOTE Actually returns an auth LoginData, but this only needs an OAuthCredential
|
||||
return cls._api_request_result(OAuthCredential, "login", **req)
|
||||
return cls._api_request_result(
|
||||
OAuthCredential,
|
||||
"login",
|
||||
uuid=uuid,
|
||||
form=form,
|
||||
forced=forced,
|
||||
)
|
||||
|
||||
# endregion
|
||||
|
||||
@ -309,6 +316,14 @@ class Client:
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
def get_read_receipts(self, channel_props: ChannelProps, unread_chat_ids: list[Long]) -> Awaitable[list[Receipt]]:
|
||||
return self._api_user_request_result(
|
||||
ResultListType(Receipt),
|
||||
"get_read_receipts",
|
||||
channel_props=channel_props.serialize(),
|
||||
unread_chat_ids=[c.serialize() for c in unread_chat_ids],
|
||||
)
|
||||
|
||||
def list_friends(self) -> Awaitable[FriendListStruct]:
|
||||
return self._api_user_request_result(
|
||||
FriendListStruct,
|
||||
|
@ -14,13 +14,13 @@
|
||||
# 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/>.
|
||||
"""Internal helpers for error handling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import NoReturn, Type
|
||||
|
||||
from .errors import (
|
||||
CommandException,
|
||||
AnotherLogonDetected,
|
||||
AuthenticationRequired,
|
||||
DeviceVerificationRequired,
|
||||
IncorrectPasscode,
|
||||
@ -61,7 +61,7 @@ _error_code_class_map: dict[KnownAuthStatusCode | KnownDataStatusCode | int, Typ
|
||||
#KnownAuthStatusCode.EXCEED_DAILY_REQUEST_LIMIT_VOICECALL: "Daily voicecall limit exceeded",
|
||||
#KnownAuthStatusCode.EXCEED_DAILY_REQUEST_LIMIT_WITHOUT_TOKEN: "Daily tokenless request limit exceeded",
|
||||
KnownAuthStatusCode.DEVICE_NOT_REGISTERED: DeviceVerificationRequired,
|
||||
#KnownAuthStatusCode.ANOTHER_LOGON: "Another logon detected",
|
||||
KnownAuthStatusCode.ANOTHER_LOGON: AnotherLogonDetected,
|
||||
#KnownAuthStatusCode.DEVICE_REGISTER_FAILED: "Device registration failed",
|
||||
#KnownAuthStatusCode.INVALID_DEVICE_REGISTER: "Invalid device",
|
||||
KnownAuthStatusCode.INVALID_PASSCODE: IncorrectPasscode,
|
||||
|
@ -14,7 +14,6 @@
|
||||
# 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/>.
|
||||
"""Helper functions & types for status codes for the KakaoTalk API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..types.api.auth_api_client import KnownAuthStatusCode
|
||||
@ -48,6 +47,9 @@ class OAuthException(CommandException):
|
||||
class DeviceVerificationRequired(OAuthException):
|
||||
pass
|
||||
|
||||
class AnotherLogonDetected(OAuthException):
|
||||
pass
|
||||
|
||||
class IncorrectPassword(OAuthException):
|
||||
pass
|
||||
|
||||
|
@ -66,6 +66,11 @@ def deserialize_user_info_union(data: JSON) -> UserInfoUnion:
|
||||
setattr(UserInfoUnion, "deserialize", deserialize_user_info_union)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Receipt(SerializableAttrs):
|
||||
userId: Long
|
||||
chatId: Long
|
||||
|
||||
@dataclass
|
||||
class PortalChannelParticipantInfo(SerializableAttrs):
|
||||
participants: list[UserInfoUnion]
|
||||
|
@ -180,6 +180,7 @@ class Portal(DBPortal, BasePortal):
|
||||
name_set: bool = False,
|
||||
topic_set: bool = False,
|
||||
avatar_set: bool = False,
|
||||
fully_read_kt_chat: Long | None = None,
|
||||
relay_user_id: UserID | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
@ -195,6 +196,7 @@ class Portal(DBPortal, BasePortal):
|
||||
name_set,
|
||||
topic_set,
|
||||
avatar_set,
|
||||
fully_read_kt_chat,
|
||||
relay_user_id,
|
||||
)
|
||||
self.log = self.log.getChild(self.ktid_log)
|
||||
@ -750,17 +752,23 @@ class Portal(DBPortal, BasePortal):
|
||||
|
||||
info = await self.update_info(source, info)
|
||||
|
||||
# TODO Sync read receipts?
|
||||
await self._sync_read_receipts(source)
|
||||
|
||||
"""
|
||||
async def _sync_read_receipts(self, receipts: list[None]) -> None:
|
||||
async def _sync_read_receipts(self, source: u.User) -> None:
|
||||
messages = await DBMessage.get_all_since(self.ktid, self.kt_receiver, self.fully_read_kt_chat)
|
||||
receipts = await source.client.get_read_receipts(
|
||||
self.channel_props,
|
||||
[m.ktid for m in messages if m.ktid]
|
||||
)
|
||||
if not receipts:
|
||||
return
|
||||
for receipt in receipts:
|
||||
message = await DBMessage.get_closest_before(
|
||||
self.ktid, self.kt_receiver, receipt.timestamp
|
||||
self.ktid, self.kt_receiver, receipt.chatId
|
||||
)
|
||||
if not message:
|
||||
continue
|
||||
puppet = await p.Puppet.get_by_ktid(receipt.actor.id, create=False)
|
||||
puppet = await p.Puppet.get_by_ktid(receipt.userId, create=False)
|
||||
if not puppet:
|
||||
continue
|
||||
try:
|
||||
@ -771,7 +779,10 @@ class Portal(DBPortal, BasePortal):
|
||||
f"as read by {puppet.intent.mxid}",
|
||||
exc_info=True,
|
||||
)
|
||||
"""
|
||||
fully_read_kt_chat = min(receipt.chatId for receipt in receipts)
|
||||
if not self.fully_read_kt_chat or self.fully_read_kt_chat < fully_read_kt_chat:
|
||||
self.fully_read_kt_chat = fully_read_kt_chat
|
||||
await self.save()
|
||||
|
||||
async def create_matrix_room(
|
||||
self, source: u.User, info: PortalChannelInfo | None = None
|
||||
@ -830,7 +841,7 @@ class Portal(DBPortal, BasePortal):
|
||||
self, source: u.User, info: PortalChannelInfo | None = None
|
||||
) -> RoomID:
|
||||
if self.mxid:
|
||||
await self._update_matrix_room(source, info)
|
||||
await self._update_matrix_room(source, info=info)
|
||||
return self.mxid
|
||||
|
||||
self.log.debug(f"Creating Matrix room")
|
||||
@ -947,11 +958,10 @@ class Portal(DBPortal, BasePortal):
|
||||
if info.channel_info:
|
||||
try:
|
||||
await self.backfill(source, is_initial=True, channel_info=info.channel_info)
|
||||
# NOTE This also syncs read receipts
|
||||
except Exception:
|
||||
self.log.exception("Failed to backfill new portal")
|
||||
|
||||
# TODO Sync read receipts?
|
||||
|
||||
return self.mxid
|
||||
|
||||
# endregion
|
||||
@ -1061,7 +1071,7 @@ class Portal(DBPortal, BasePortal):
|
||||
mx_room=self.mxid,
|
||||
ktid=ktid,
|
||||
index=0,
|
||||
kt_chat=self.ktid,
|
||||
kt_channel=self.ktid,
|
||||
kt_receiver=self.kt_receiver,
|
||||
timestamp=int(time.time() * 1000),
|
||||
)
|
||||
@ -1397,7 +1407,7 @@ class Portal(DBPortal, BasePortal):
|
||||
async def _add_kakaotalk_reply(
|
||||
self, content: MessageEventContent, reply_to: ReplyAttachment
|
||||
) -> None:
|
||||
message = await DBMessage.get_by_ktid(reply_to.src_logId, self.kt_receiver)
|
||||
message = await DBMessage.get_by_ktid(reply_to.src_logId, *self.ktid_full)
|
||||
if not message:
|
||||
self.log.warning(
|
||||
f"Couldn't find reply target {reply_to.src_logId} to bridge reply metadata to Matrix"
|
||||
@ -1488,7 +1498,7 @@ class Portal(DBPortal, BasePortal):
|
||||
# TODO Might have to handle remote reactions on messages created by bulk_create
|
||||
await DBMessage.bulk_create(
|
||||
ktid=chat.logId,
|
||||
kt_chat=self.ktid,
|
||||
kt_channel=self.ktid,
|
||||
kt_receiver=self.kt_receiver,
|
||||
mx_room=self.mxid,
|
||||
timestamp=chat.sendAt,
|
||||
@ -1705,7 +1715,7 @@ class Portal(DBPortal, BasePortal):
|
||||
) -> None:
|
||||
if not self.mxid:
|
||||
return
|
||||
for message in await DBMessage.get_all_by_ktid(chat_id, self.kt_receiver):
|
||||
for message in await DBMessage.get_all_by_ktid(chat_id, *self.ktid_full):
|
||||
try:
|
||||
await sender.intent_for(self).redact(
|
||||
message.mx_room, message.mxid, timestamp=timestamp
|
||||
@ -1856,6 +1866,7 @@ class Portal(DBPortal, BasePortal):
|
||||
self.log.trace("Leaving room with %s post-backfill", intent.mxid)
|
||||
await intent.leave_room(self.mxid)
|
||||
self.log.info("Backfilled %d messages through %s", len(chats), source.mxid)
|
||||
self._sync_read_receipts(source)
|
||||
|
||||
# region Database getters
|
||||
|
||||
|
@ -35,10 +35,16 @@ from mautrix.util.simple_lock import SimpleLock
|
||||
|
||||
from . import portal as po, puppet as pu
|
||||
from .config import Config
|
||||
from .db import User as DBUser
|
||||
from .db import User as DBUser, LoginCredential
|
||||
|
||||
from .kt.client import Client
|
||||
from .kt.client.errors import AuthenticationRequired, ResponseError
|
||||
from .kt.client.errors import (
|
||||
AuthenticationRequired,
|
||||
AnotherLogonDetected,
|
||||
IncorrectPassword,
|
||||
OAuthException,
|
||||
ResponseError,
|
||||
)
|
||||
from .kt.client.types import PortalChannelInfo, SettingsStruct
|
||||
from .kt.types.bson import Long
|
||||
from .kt.types.channel.channel_info import ChannelInfo, NormalChannelInfo, NormalChannelData
|
||||
@ -107,6 +113,8 @@ class User(DBUser, BaseUser):
|
||||
def __init__(
|
||||
self,
|
||||
mxid: UserID,
|
||||
force_login: bool,
|
||||
was_connected: bool,
|
||||
ktid: Long | None = None,
|
||||
uuid: str | None = None,
|
||||
access_token: str | None = None,
|
||||
@ -115,6 +123,8 @@ class User(DBUser, BaseUser):
|
||||
) -> None:
|
||||
super().__init__(
|
||||
mxid=mxid,
|
||||
force_login=force_login,
|
||||
was_connected=was_connected,
|
||||
ktid=ktid,
|
||||
uuid=uuid,
|
||||
access_token=access_token,
|
||||
@ -277,7 +287,51 @@ class User(DBUser, BaseUser):
|
||||
async def _load_session(self, is_startup: bool) -> bool:
|
||||
if self._is_logged_in and is_startup:
|
||||
return True
|
||||
elif not self.has_state:
|
||||
if not self.was_connected and not self.config["bridge.remain_logged_in_on_disconnect"]:
|
||||
self.log.warning("Not logging in because last session was disconnected, and login+disconnection is forbidden by config")
|
||||
await self.push_bridge_state(
|
||||
BridgeStateEvent.LOGGED_OUT,
|
||||
error="logged-out",
|
||||
)
|
||||
return False
|
||||
latest_exc: Exception | None = None
|
||||
password_ok = False
|
||||
try:
|
||||
creds = await LoginCredential.get_by_mxid(self.mxid)
|
||||
except Exception as e:
|
||||
self.log.exception("Exception while looking for saved password")
|
||||
creds = None
|
||||
if creds:
|
||||
uuid = await self.get_uuid()
|
||||
form = creds.get_form()
|
||||
oauth_credential = None
|
||||
try:
|
||||
oauth_credential = await Client.login(uuid=uuid, form=form, forced=False)
|
||||
except IncorrectPassword as e:
|
||||
latest_exc = e
|
||||
except AnotherLogonDetected as e:
|
||||
if self.force_login:
|
||||
try:
|
||||
# Wait a moment to make it look like a user-initiated response
|
||||
await asyncio.sleep(2)
|
||||
oauth_credential = await Client.login(uuid=uuid, form=form, forced=True)
|
||||
except OAuthException as e:
|
||||
latest_exc = e
|
||||
if oauth_credential:
|
||||
self.oauth_credential = oauth_credential
|
||||
await self.save()
|
||||
password_ok = True
|
||||
else:
|
||||
try:
|
||||
await creds.delete()
|
||||
except:
|
||||
self.log.exception("Exception while deleting incorrect password")
|
||||
if password_ok or self.config["bridge.allow_token_relogin"] and self.has_state:
|
||||
if not password_ok:
|
||||
self.log.warning("Using token-based relogin after password-based relogin failed")
|
||||
elif latest_exc:
|
||||
raise latest_exc
|
||||
else:
|
||||
# If we have a user in the DB with no state, we can assume
|
||||
# KT logged us out and the bridge has restarted
|
||||
await self.push_bridge_state(
|
||||
@ -295,10 +349,10 @@ class User(DBUser, BaseUser):
|
||||
self._track_metric(METRIC_LOGGED_IN, True)
|
||||
self._is_logged_in = True
|
||||
self.is_connected = None
|
||||
asyncio.create_task(self.post_login(is_startup=is_startup))
|
||||
asyncio.create_task(self.post_login(is_startup=is_startup, is_token_login=not password_ok))
|
||||
return True
|
||||
|
||||
async def _send_reset_notice(self, e: AuthenticationRequired, edit: EventID | None = None) -> None:
|
||||
async def _send_reset_notice(self, e: OAuthException, edit: EventID | None = None) -> None:
|
||||
await self.send_bridge_notice(
|
||||
"Got authentication error from KakaoTalk:\n\n"
|
||||
f"> {e.message}\n\n"
|
||||
@ -333,8 +387,13 @@ class User(DBUser, BaseUser):
|
||||
self, event_id: EventID | None = None, retries: int = 3, is_startup: bool = False
|
||||
) -> None:
|
||||
try:
|
||||
await self._load_session(is_startup=is_startup)
|
||||
except AuthenticationRequired as e:
|
||||
if not await self._load_session(is_startup=is_startup) and self.has_state:
|
||||
await self.send_bridge_notice(
|
||||
"Logged out of KakaoTalk. Must use the `login` command to log back in.",
|
||||
important=True,
|
||||
)
|
||||
await self.logout(remove_ktid=False)
|
||||
except OAuthException as e:
|
||||
await self._send_reset_notice(e, edit=event_id)
|
||||
# TODO Throw a ResponseError on network failures
|
||||
except ResponseError as e:
|
||||
@ -383,6 +442,7 @@ class User(DBUser, BaseUser):
|
||||
|
||||
self._is_logged_in = False
|
||||
self.is_connected = None
|
||||
self.was_connected = False
|
||||
self._client = None
|
||||
|
||||
if self.ktid and remove_ktid:
|
||||
@ -392,7 +452,7 @@ class User(DBUser, BaseUser):
|
||||
|
||||
await self.save()
|
||||
|
||||
async def post_login(self, is_startup: bool) -> None:
|
||||
async def post_login(self, is_startup: bool, is_token_login: bool) -> None:
|
||||
self.log.info("Running post-login actions")
|
||||
assert self.ktid
|
||||
self._add_to_cache()
|
||||
@ -406,13 +466,19 @@ class User(DBUser, BaseUser):
|
||||
except Exception:
|
||||
self.log.exception("Failed to automatically enable custom puppet")
|
||||
|
||||
# TODO Check if things break when a live message comes in during syncing
|
||||
if self.config["bridge.sync_on_startup"] or not is_startup:
|
||||
sync_count = self.config["bridge.initial_chat_sync"]
|
||||
if not is_token_login or self.was_connected and self.config["bridge.reconnect_on_token_relogin"]:
|
||||
# TODO Check if things break when a live message comes in during syncing
|
||||
if self.config["bridge.sync_on_startup"] or not is_startup:
|
||||
sync_count = self.config["bridge.initial_chat_sync"]
|
||||
else:
|
||||
sync_count = None
|
||||
await self.connect_and_sync(sync_count, force_sync=False)
|
||||
else:
|
||||
sync_count = None
|
||||
# TODO Don't auto-connect on startup if user's last state was disconnected
|
||||
await self.connect_and_sync(sync_count)
|
||||
await self.send_bridge_notice(
|
||||
f"Logged into KakaoTalk. To connect to chatroom updates, use the `sync` command."
|
||||
)
|
||||
self.was_connected = False
|
||||
await self.save()
|
||||
|
||||
async def get_direct_chats(self) -> dict[UserID, list[RoomID]]:
|
||||
return {
|
||||
@ -422,12 +488,12 @@ class User(DBUser, BaseUser):
|
||||
} if self.ktid else {}
|
||||
|
||||
@async_time(METRIC_CONNECT_AND_SYNC)
|
||||
async def connect_and_sync(self, sync_count: int | None) -> bool:
|
||||
async def connect_and_sync(self, sync_count: int | None, force_sync: bool) -> bool:
|
||||
# TODO Look for a way to sync all channels without (re-)logging in
|
||||
try:
|
||||
login_result = await self.client.connect()
|
||||
await self.on_connect()
|
||||
if login_result:
|
||||
should_sync = await self.on_connect(force_sync)
|
||||
if login_result and should_sync:
|
||||
await self._sync_channels(login_result, sync_count)
|
||||
return True
|
||||
except AuthenticationRequired as e:
|
||||
@ -630,29 +696,31 @@ class User(DBUser, BaseUser):
|
||||
|
||||
# region KakaoTalk event handling
|
||||
|
||||
async def on_connect(self) -> None:
|
||||
self.is_connected = True
|
||||
self._track_metric(METRIC_CONNECTED, True)
|
||||
""" TODO Don't auto-resync channels if disconnection was too short
|
||||
async def on_connect(self, force_sync: bool) -> bool:
|
||||
now = time.monotonic()
|
||||
disconnected_at = self._connection_time
|
||||
max_delay = self.config["bridge.resync_max_disconnected_time"]
|
||||
first_connect = self.is_connected is None
|
||||
if not first_connect and disconnected_at + max_delay < now:
|
||||
duration = int(now - disconnected_at)
|
||||
self.is_connected = True
|
||||
self._track_metric(METRIC_CONNECTED, True)
|
||||
duration = int(now - disconnected_at)
|
||||
skip_sync = not first_connect and not force_sync and duration < max_delay
|
||||
if skip_sync:
|
||||
self.log.debug(f"Disconnection lasted {duration} seconds, not re-syncing channels...")
|
||||
"""
|
||||
if self.temp_disconnect_notices:
|
||||
elif self.temp_disconnect_notices:
|
||||
await self.send_bridge_notice("Connected to KakaoTalk chats")
|
||||
await self.push_bridge_state(BridgeStateEvent.CONNECTED)
|
||||
self.was_connected = True
|
||||
await self.save()
|
||||
return not skip_sync
|
||||
|
||||
async def on_disconnect(self, res: KickoutRes | None) -> None:
|
||||
self.is_connected = False
|
||||
self._track_metric(METRIC_CONNECTED, False)
|
||||
logout = not self.config["bridge.remain_logged_in_on_disconnect"]
|
||||
if res:
|
||||
logout = False
|
||||
if res.reason == KnownKickoutType.LOGIN_ANOTHER:
|
||||
reason_str = "Logged in from another desktop client."
|
||||
reason_str = "Logged in on a PC or another bridge."
|
||||
elif res.reason == KnownKickoutType.CHANGE_SERVER:
|
||||
# TODO Reconnect automatically instead
|
||||
reason_str = "KakaoTalk backend server changed."
|
||||
@ -661,12 +729,19 @@ class User(DBUser, BaseUser):
|
||||
logout = True
|
||||
else:
|
||||
reason_str = f"Unknown reason ({res.reason})."
|
||||
else:
|
||||
reason_str = None
|
||||
if not logout:
|
||||
# TODO What bridge state to push?
|
||||
self.was_connected = False
|
||||
await self.save()
|
||||
else:
|
||||
await self.logout()
|
||||
if reason_str:
|
||||
if not logout:
|
||||
reason_suffix = " To reconnect, use the `sync` command."
|
||||
# TODO What bridge state to push?
|
||||
reason_suffix = "To reconnect, use the `sync` command."
|
||||
else:
|
||||
reason_suffix = " You are now logged out."
|
||||
await self.logout()
|
||||
reason_suffix = "You are now logged out. To log back in, use the `login` command."
|
||||
await self.send_bridge_notice(f"Disconnected from KakaoTalk: {reason_str} {reason_suffix}")
|
||||
|
||||
async def on_error(self, error: JSON) -> None:
|
||||
@ -681,7 +756,7 @@ class User(DBUser, BaseUser):
|
||||
self.is_connected = False
|
||||
self._track_metric(METRIC_CONNECTED, False)
|
||||
self._client = None
|
||||
if self._is_logged_in:
|
||||
if self._is_logged_in and self.was_connected:
|
||||
if self.temp_disconnect_notices:
|
||||
await self.send_bridge_notice(
|
||||
"Disconnected from KakaoTalk: backend helper module exited. "
|
||||
@ -702,7 +777,7 @@ class User(DBUser, BaseUser):
|
||||
# Should also catch unlikely authentication errors
|
||||
self._logged_in_info = await self._client.start()
|
||||
self._logged_in_info_time = time.monotonic()
|
||||
asyncio.create_task(self.post_login(is_startup=True))
|
||||
asyncio.create_task(self.post_login(is_startup=True, is_token_login=False))
|
||||
|
||||
@async_time(METRIC_CHAT)
|
||||
async def on_chat(self, chat: Chatlog, channel_id: Long, channel_type: ChannelType) -> None:
|
||||
|
@ -520,6 +520,7 @@ export default class PeerClient {
|
||||
* @param {Object} req
|
||||
* @param {string} req.uuid
|
||||
* @param {Object} req.form
|
||||
* @param {boolean} req.forced
|
||||
* @returns The response of the login attempt, including obtained
|
||||
* credentials for subsequent token-based login. If a required device passcode
|
||||
* request failed, its status is stored here.
|
||||
@ -527,7 +528,7 @@ export default class PeerClient {
|
||||
handleLogin = async (req) => {
|
||||
// TODO Look for a logout API call
|
||||
const authClient = await this.#createAuthClient(req.uuid)
|
||||
const loginRes = await authClient.login(req.form, true)
|
||||
const loginRes = await authClient.login(req.form, req.forced)
|
||||
if (loginRes.status === KnownAuthStatusCode.DEVICE_NOT_REGISTERED) {
|
||||
const passcodeRes = await authClient.requestPasscode(req.form)
|
||||
if (!passcodeRes.success) {
|
||||
@ -688,14 +689,6 @@ export default class PeerClient {
|
||||
return await this.#getUser(req.mxid).serviceClient.requestMyProfile()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {string} req.mxid
|
||||
*/
|
||||
getOwnProfile = async (req) => {
|
||||
return await this.#getUser(req.mxid).serviceClient.requestMyProfile()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {string} req.mxid
|
||||
@ -800,6 +793,47 @@ export default class PeerClient {
|
||||
return res
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {string} req.mxid
|
||||
* @param {ChannelProps} req.channel_props
|
||||
* @param {[Long]} req.unread_chat_ids Must be in DECREASING order
|
||||
*/
|
||||
getReadReceipts = async (req) => {
|
||||
const talkChannel = await this.#getUserChannel(req.mxid, req.channel_props)
|
||||
// TODO Is any pre-syncing needed?
|
||||
const userCount = talkChannel.userCount
|
||||
if (userCount == 1) return makeCommandResult([])
|
||||
/** @type {Map<Long, Long> */
|
||||
const latestReceiptByUser = new Map()
|
||||
let fullyRead = false
|
||||
for (const chatId of req.unread_chat_ids) {
|
||||
const chatReaders = talkChannel.getReaders({ logId: chatId })
|
||||
for (const chatReader of chatReaders) {
|
||||
if (!latestReceiptByUser.has(chatReader.userId)) {
|
||||
latestReceiptByUser.set(chatReader.userId, chatId)
|
||||
if (latestReceiptByUser.size == userCount) {
|
||||
fullyRead = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (fullyRead) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} Receipt
|
||||
* @property {Long} userId
|
||||
* @property {Long} chatId
|
||||
*/
|
||||
/** @type {[Receipt]} */
|
||||
const receipts = []
|
||||
latestReceiptByUser.forEach((value, key) => receipts.push({ "userId": key, "chatId": value }))
|
||||
return makeCommandResult(receipts)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {string} req.mxid
|
||||
@ -1088,6 +1122,7 @@ export default class PeerClient {
|
||||
get_portal_channel_participant_info: this.getPortalChannelParticipantInfo,
|
||||
get_participants: this.getParticipants,
|
||||
get_chats: this.getChats,
|
||||
get_read_receipts: this.getReadReceipts,
|
||||
list_friends: this.listFriends,
|
||||
get_friend_dm_id: req => this.getFriendProperty(req, "directChatId"),
|
||||
get_memo_ids: this.getMemoIds,
|
||||
|
Loading…
x
Reference in New Issue
Block a user