Compare commits

...

8 Commits

21 changed files with 626 additions and 176 deletions

1
.gitignore vendored
View File

@ -21,3 +21,4 @@ puppet/extension_files
*.db
*.pickle
*.bak
/.mypy_cache/

View File

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

View File

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

View File

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

View File

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

View File

@ -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",
]

View 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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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