matrix-appservice-kakaotalk/matrix_appservice_kakaotalk/portal.py

862 lines
30 KiB
Python

# 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, Any, AsyncGenerator, Pattern, cast
from collections import deque
import asyncio
import re
from mautrix.appservice import IntentAPI
from mautrix.bridge import BasePortal, NotificationDisabler, async_getter_lock
from mautrix.errors import MatrixError
from mautrix.types import (
ContentURI,
EventID,
EventType,
LocationMessageEventContent,
MediaMessageEventContent,
Membership,
MessageEventContent,
MessageType,
RoomID,
TextMessageEventContent,
UserID,
)
from mautrix.util.message_send_checkpoint import MessageSendCheckpointStatus
from mautrix.util.simple_lock import SimpleLock
from . import matrix as m, puppet as p, user as u
from .config import Config
from .db import (
Message as DBMessage,
Portal as DBPortal,
)
from .kt.types.bson import Long
from .kt.types.channel.channel_type import KnownChannelType, ChannelType
from .kt.types.user.channel_user_info import DisplayUserInfo
from .kt.client.types import PortalChannelInfo
if TYPE_CHECKING:
from .__main__ import KakaoTalkBridge
try:
from PIL import Image
except ImportError:
Image = None
try:
from mautrix.crypto.attachments import decrypt_attachment, encrypt_attachment
except ImportError:
decrypt_attachment = encrypt_attachment = None
geo_uri_regex: Pattern = re.compile(r"^geo:(-?\d+.\d+),(-?\d+.\d+)$")
class FakeLock:
async def __aenter__(self) -> None:
pass
async def __aexit__(self, exc_type, exc, tb) -> None:
pass
StateBridge = EventType.find("m.bridge", EventType.Class.STATE)
StateHalfShotBridge = EventType.find("uk.half-shot.bridge", EventType.Class.STATE)
class Portal(DBPortal, BasePortal):
invite_own_puppet_to_pm: bool = False
by_mxid: dict[RoomID, Portal] = {}
by_ktid: dict[tuple[Long, Long], Portal] = {}
matrix: m.MatrixHandler
config: Config
_main_intent: IntentAPI | None
_create_room_lock: asyncio.Lock
_dedup: deque[str]
_oti_dedup: dict[int, DBMessage]
_send_locks: dict[int, asyncio.Lock]
_noop_lock: FakeLock = FakeLock()
_typing: set[UserID]
backfill_lock: SimpleLock
_backfill_leave: set[IntentAPI] | None
def __init__(
self,
ktid: Long,
kt_receiver: Long,
kt_type: ChannelType,
mxid: RoomID | None = None,
name: str | None = None,
photo_id: str | None = None,
avatar_url: ContentURI | None = None,
encrypted: bool = False,
name_set: bool = False,
avatar_set: bool = False,
relay_user_id: UserID | None = None,
) -> None:
super().__init__(
ktid,
kt_receiver,
kt_type,
mxid,
name,
photo_id,
avatar_url,
encrypted,
name_set,
avatar_set,
relay_user_id,
)
self.log = self.log.getChild(self.ktid_log)
self._main_intent = None
self._create_room_lock = asyncio.Lock()
self._dedup = deque(maxlen=100)
self._oti_dedup = {}
self._send_locks = {}
self._typing = set()
self.backfill_lock = SimpleLock(
"Waiting for backfilling to finish before handling %s", log=self.log
)
self._backfill_leave = None
self._relay_user = None
@classmethod
def init_cls(cls, bridge: KakaoTalkBridge) -> None:
BasePortal.bridge = bridge
cls.az = bridge.az
cls.config = bridge.config
cls.loop = bridge.loop
cls.matrix = bridge.matrix
cls.invite_own_puppet_to_pm = cls.config["bridge.invite_own_puppet_to_pm"]
NotificationDisabler.puppet_cls = p.Puppet
NotificationDisabler.config_enabled = cls.config["bridge.backfill.disable_notifications"]
# region DB conversion
async def delete(self) -> None:
if self.mxid:
await DBMessage.delete_all_by_room(self.mxid)
self.by_ktid.pop(self.ktid_full, None)
self.by_mxid.pop(self.mxid, None)
await super().delete()
# endregion
# region Properties
@property
def ktid_full(self) -> tuple[Long, Long]:
return self.ktid, self.kt_receiver
@property
def ktid_log(self) -> str:
if self.is_direct:
return f"{self.ktid}<->{self.kt_receiver}"
return str(self.ktid)
@property
def is_direct(self) -> bool:
return KnownChannelType.is_direct(self.kt_type)
@property
def main_intent(self) -> IntentAPI:
if not self._main_intent:
raise ValueError("Portal must be postinit()ed before main_intent can be used")
return self._main_intent
# endregion
# region Chat info updating
async def update_info(
self,
source: u.User,
info: PortalChannelInfo,
force_save: bool = False,
) -> None:
changed = False
if not self.is_direct:
changed = any(
await asyncio.gather(
self._update_name(info.name),
# TODO
#self._update_photo(source, info.image),
)
)
changed = await self._update_participants(source, info.channel_info.displayUserList) or changed
if changed or force_save:
await self.update_bridge_info()
await self.save()
"""
@classmethod
async def _reupload_kt_file(
cls,
url: str,
source: u.User,
intent: IntentAPI,
*,
filename: str | None = None,
encrypt: bool = False,
referer: str = "messenger_thread_photo",
find_size: bool = False,
convert_audio: bool = False,
) -> tuple[ContentURI, FileInfo | VideoInfo | AudioInfo | ImageInfo, EncryptedFile | None]:
if not url:
raise ValueError("URL not provided")
headers = {"referer": f"fbapp://{source.state.application.client_id}/{referer}"}
sandbox = cls.config["bridge.sandbox_media_download"]
async with source.client.get(url, headers=headers, sandbox=sandbox) as resp:
length = int(resp.headers["Content-Length"])
if length > cls.matrix.media_config.upload_size:
raise ValueError("File not available: too large")
data = await resp.read()
mime = magic.from_buffer(data, mime=True)
if convert_audio and mime != "audio/ogg":
data = await ffmpeg.convert_bytes(
data, ".ogg", output_args=("-c:a", "libopus"), input_mime=mime
)
mime = "audio/ogg"
info = FileInfo(mimetype=mime, size=len(data))
if Image and mime.startswith("image/") and find_size:
with Image.open(BytesIO(data)) as img:
width, height = img.size
info = ImageInfo(mimetype=mime, size=len(data), width=width, height=height)
upload_mime_type = mime
decryption_info = None
if encrypt and encrypt_attachment:
data, decryption_info = encrypt_attachment(data)
upload_mime_type = "application/octet-stream"
filename = None
url = await intent.upload_media(data, mime_type=upload_mime_type, filename=filename)
if decryption_info:
decryption_info.url = url
return url, info, decryption_info
"""
async def _update_name(self, name: str) -> bool:
if not name:
self.log.warning("Got empty name in _update_name call")
return False
if self.name != name or not self.name_set:
self.log.trace("Updating name %s -> %s", self.name, name)
self.name = name
if self.mxid and (self.encrypted or not self.is_direct):
try:
await self.main_intent.set_room_name(self.mxid, self.name)
self.name_set = True
except Exception:
self.log.exception("Failed to set room name")
self.name_set = False
return True
return False
"""
async def _update_photo(self, source: u.User, photo: graphql.Picture) -> bool:
if self.is_direct and not self.encrypted:
return False
photo_id = self.get_photo_id(photo)
if self.photo_id != photo_id or not self.avatar_set:
self.photo_id = photo_id
if photo:
if self.photo_id != photo_id or not self.avatar_url:
# Reset avatar_url first in case the upload fails
self.avatar_url = None
self.avatar_url = await p.Puppet.reupload_avatar(
source,
self.main_intent,
photo.uri,
self.ktid,
use_graph=self.is_direct and (photo.height or 0) < 500,
)
else:
self.avatar_url = ContentURI("")
if self.mxid:
try:
await self.main_intent.set_room_avatar(self.mxid, self.avatar_url)
self.avatar_set = True
except Exception:
self.log.exception("Failed to set room avatar")
self.avatar_set = False
return True
return False
"""
async def _update_photo_from_puppet(self, puppet: p.Puppet) -> bool:
if self.photo_id == puppet.photo_id and self.avatar_set:
return False
self.photo_id = puppet.photo_id
if puppet.photo_mxc:
self.avatar_url = puppet.photo_mxc
elif self.photo_id:
profile = await self.main_intent.get_profile(puppet.default_mxid)
self.avatar_url = profile.avatar_url
puppet.photo_mxc = profile.avatar_url
else:
self.avatar_url = ContentURI("")
if self.mxid:
try:
await self.main_intent.set_room_avatar(self.mxid, self.avatar_url)
self.avatar_set = True
except Exception:
self.log.exception("Failed to set room avatar")
self.avatar_set = False
return True
"""
async def sync_per_room_nick(self, puppet: p.Puppet, name: str) -> None:
intent = puppet.intent_for(self)
content = MemberStateEventContent(
membership=Membership.JOIN,
avatar_url=puppet.photo_mxc,
displayname=name or puppet.name,
)
content[DOUBLE_PUPPET_SOURCE_KEY] = self.bridge.name
current_state = await intent.state_store.get_member(self.mxid, intent.mxid)
if not current_state or current_state.displayname != content.displayname:
self.log.debug(
"Syncing %s's per-room nick %s to the room",
puppet.ktid,
content.displayname,
)
await intent.send_state_event(
self.mxid, EventType.ROOM_MEMBER, content, state_key=intent.mxid
)
"""
async def _update_participants(self, source: u.User, participants: list[DisplayUserInfo]) -> bool:
changed = False
# TODO nick_map?
for participant in participants:
puppet = await p.Puppet.get_by_ktid(participant.userId)
await puppet.update_info(source, participant)
if self.is_direct and self.ktid == puppet.ktid and self.encrypted:
changed = await self._update_name(puppet.name) or changed
changed = await self._update_photo_from_puppet(puppet) or changed
if self.mxid:
if puppet.ktid != self.kt_receiver or puppet.is_real_user:
await puppet.intent_for(self).ensure_joined(self.mxid, bot=self.main_intent)
#if puppet.ktid in nick_map:
# await self.sync_per_room_nick(puppet, nick_map[puppet.ktid])
return changed
# endregion
# region Matrix room creation
async def update_matrix_room(self, source: u.User, info: PortalChannelInfo) -> None:
try:
await self._update_matrix_room(source, info)
except Exception:
self.log.exception("Failed to update portal")
def _get_invite_content(self, double_puppet: p.Puppet | None) -> dict[str, Any]:
invite_content = {}
if double_puppet:
invite_content["fi.mau.will_auto_accept"] = True
if self.is_direct:
invite_content["is_direct"] = True
return invite_content
async def _update_matrix_room(
self, source: u.User, info: PortalChannelInfo
) -> None:
puppet = await p.Puppet.get_by_custom_mxid(source.mxid)
await self.main_intent.invite_user(
self.mxid,
source.mxid,
check_cache=True,
extra_content=self._get_invite_content(puppet),
)
if puppet:
did_join = await puppet.intent.ensure_joined(self.mxid)
if did_join and self.is_direct:
await source.update_direct_chats({self.main_intent.mxid: [self.mxid]})
await self.update_info(source, info)
# TODO
#await self._sync_read_receipts(info.read_receipts.nodes)
"""
async def _sync_read_receipts(self, receipts: list[None]) -> None:
for receipt in receipts:
message = await DBMessage.get_closest_before(
self.ktid, self.kt_receiver, receipt.timestamp
)
if not message:
continue
puppet = await p.Puppet.get_by_ktid(receipt.actor.id, create=False)
if not puppet:
continue
try:
await puppet.intent_for(self).mark_read(message.mx_room, message.mxid)
except Exception:
self.log.warning(
f"Failed to mark {message.mxid} in {message.mx_room} "
f"as read by {puppet.intent.mxid}",
exc_info=True,
)
"""
async def create_matrix_room(
self, source: u.User, info: PortalChannelInfo
) -> RoomID | None:
if self.mxid:
try:
await self._update_matrix_room(source, info)
except Exception:
self.log.exception("Failed to update portal")
return self.mxid
async with self._create_room_lock:
try:
return await self._create_matrix_room(source, info)
except Exception:
self.log.exception("Failed to create portal")
return None
@property
def bridge_info_state_key(self) -> str:
return f"net.miscworks.kakaotalk://kakaotalk/{self.ktid}"
@property
def bridge_info(self) -> dict[str, Any]:
return {
"bridgebot": self.az.bot_mxid,
"creator": self.main_intent.mxid,
"protocol": {
"id": "kakaotalk",
"displayname": "KakaoTalk",
"avatar_url": self.config["appservice.bot_avatar"],
},
"channel": {
"id": str(self.ktid),
"displayname": self.name,
"avatar_url": self.avatar_url,
},
}
async def update_bridge_info(self) -> None:
if not self.mxid:
self.log.debug("Not updating bridge info: no Matrix room created")
return
try:
self.log.debug("Updating bridge info...")
await self.main_intent.send_state_event(
self.mxid, StateBridge, self.bridge_info, self.bridge_info_state_key
)
# TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec
await self.main_intent.send_state_event(
self.mxid, StateHalfShotBridge, self.bridge_info, self.bridge_info_state_key
)
except Exception:
self.log.warning("Failed to update bridge info", exc_info=True)
async def _create_matrix_room(
self, source: u.User, info: PortalChannelInfo
) -> RoomID | None:
if self.mxid:
await self._update_matrix_room(source, info)
return self.mxid
self.log.debug(f"Creating Matrix room")
name: str | None = None
initial_state = [
{
"type": str(StateBridge),
"state_key": self.bridge_info_state_key,
"content": self.bridge_info,
},
# TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec
{
"type": str(StateHalfShotBridge),
"state_key": self.bridge_info_state_key,
"content": self.bridge_info,
},
]
invites = []
if self.config["bridge.encryption.default"] and self.matrix.e2ee:
self.encrypted = True
initial_state.append(
{
"type": "m.room.encryption",
"content": {"algorithm": "m.megolm.v1.aes-sha2"},
}
)
if self.is_direct:
invites.append(self.az.bot_mxid)
await self.update_info(source=source, info=info)
if self.encrypted or not self.is_direct:
name = self.name
initial_state.append(
{
"type": str(EventType.ROOM_AVATAR),
"content": {"url": self.avatar_url},
}
)
# We lock backfill lock here so any messages that come between the room being created
# and the initial backfill finishing wouldn't be bridged before the backfill messages.
with self.backfill_lock:
creation_content = {}
if not self.config["bridge.federate_rooms"]:
creation_content["m.federate"] = False
self.mxid = await self.main_intent.create_room(
name=name,
is_direct=self.is_direct,
initial_state=initial_state,
invitees=invites,
creation_content=creation_content,
)
if not self.mxid:
raise Exception("Failed to create room: no mxid returned")
if self.encrypted and self.matrix.e2ee and self.is_direct:
try:
await self.az.intent.ensure_joined(self.mxid)
except Exception:
self.log.warning(f"Failed to add bridge bot to new private chat {self.mxid}")
await self.save()
self.log.debug(f"Matrix room created: {self.mxid}")
self.by_mxid[self.mxid] = self
puppet = await p.Puppet.get_by_custom_mxid(source.mxid)
await self.main_intent.invite_user(
self.mxid, source.mxid, extra_content=self._get_invite_content(puppet)
)
if puppet:
try:
if self.is_direct:
await source.update_direct_chats({self.main_intent.mxid: [self.mxid]})
await puppet.intent.join_room_by_id(self.mxid)
except MatrixError:
self.log.debug(
"Failed to join custom puppet into newly created portal",
exc_info=True,
)
if not self.is_direct:
await self._update_participants(source, info.channel_info.displayUserList)
try:
await self.backfill(source, is_initial=True, channel=info.channel_info)
except Exception:
self.log.exception("Failed to backfill new portal")
# TODO
#await self._sync_read_receipts(info.read_receipts.nodes)
return self.mxid
# endregion
# region Matrix event handling
def require_send_lock(self, user_id: Long) -> asyncio.Lock:
try:
lock = self._send_locks[user_id]
except KeyError:
lock = asyncio.Lock()
self._send_locks[user_id] = lock
return lock
def optional_send_lock(self, user_id: Long) -> asyncio.Lock | FakeLock:
try:
return self._send_locks[user_id]
except KeyError:
pass
return self._noop_lock
async def _send_delivery_receipt(self, event_id: EventID) -> None:
if event_id and self.config["bridge.delivery_receipts"]:
try:
await self.az.intent.mark_read(self.mxid, event_id)
except Exception:
self.log.exception(f"Failed to send delivery receipt for {event_id}")
async def _send_bridge_error(self, msg: str, thing: str = "message") -> None:
await self._send_message(
self.main_intent,
TextMessageEventContent(
msgtype=MessageType.NOTICE,
body=f"\u26a0 Your {thing} may not have been bridged: {msg}",
),
)
def _status_from_exception(self, e: Exception) -> MessageSendCheckpointStatus:
if isinstance(e, NotImplementedError):
return MessageSendCheckpointStatus.UNSUPPORTED
return MessageSendCheckpointStatus.PERM_FAILURE
async def handle_matrix_message(
self, sender: u.User, message: MessageEventContent, event_id: EventID
) -> None:
try:
await self._handle_matrix_message(sender, message, event_id)
except Exception as e:
self.log.exception(f"Failed to handle Matrix event {event_id}: {e}")
sender.send_remote_checkpoint(
self._status_from_exception(e),
event_id,
self.mxid,
EventType.ROOM_MESSAGE,
message.msgtype,
error=e,
)
await self._send_bridge_error(str(e))
else:
await self._send_delivery_receipt(event_id)
async def _handle_matrix_message(
self, orig_sender: u.User, message: MessageEventContent, event_id: EventID
) -> None:
if message.get_edit():
raise NotImplementedError("Edits are not supported by the KakaoTalk bridge.")
sender, is_relay = await self.get_relay_sender(orig_sender, f"message {event_id}")
if not sender:
raise Exception("not logged in")
elif not sender.has_state:
raise Exception("not connected to KakaoTalk")
elif is_relay:
await self.apply_relay_message_format(orig_sender, message)
if message.msgtype == MessageType.TEXT or message.msgtype == MessageType.NOTICE:
await self._handle_matrix_text(event_id, sender, message)
elif message.msgtype.is_media:
await self._handle_matrix_media(event_id, sender, message, is_relay)
# elif message.msgtype == MessageType.LOCATION:
# await self._handle_matrix_location(sender, message)
else:
raise NotImplementedError(f"Unsupported message type {message.msgtype}")
async def _handle_matrix_text(
self, event_id: EventID, sender: u.User, message: TextMessageEventContent
) -> None:
self.log.info("TODO: _handle_matrix_text")
async def _handle_matrix_media(
self, event_id: EventID, sender: u.User, message: MediaMessageEventContent, is_relay: bool
) -> None:
self.log.info("TODO: _handle_matrix_media")
async def _handle_matrix_location(
self, sender: u.User, message: LocationMessageEventContent
) -> str:
pass
# TODO
# match = geo_uri_regex.fullmatch(message.geo_uri)
# return await self.thread_for(sender).send_pinned_location(float(match.group(1)),
# float(match.group(2)))
async def handle_matrix_redaction(
self, sender: u.User, event_id: EventID, redaction_event_id: EventID
) -> None:
try:
await self._handle_matrix_redaction(sender, event_id, redaction_event_id)
except Exception as e:
self.log.exception(f"Failed to handle Matrix event {event_id}: {e}")
sender.send_remote_checkpoint(
self._status_from_exception(e),
event_id,
self.mxid,
EventType.ROOM_REDACTION,
error=e,
)
await self._send_bridge_error(str(e))
else:
await self._send_delivery_receipt(event_id)
async def _handle_matrix_redaction(
self, sender: u.User, event_id: EventID, redaction_event_id: EventID
) -> None:
self.log.info("TODO: _handle_matrix_redaction")
async def handle_matrix_reaction(
self, sender: u.User, event_id: EventID, reacting_to: EventID, reaction: str
) -> None:
self.log.info("TODO: handle_matrix_reaction")
async def handle_matrix_leave(self, user: u.User) -> None:
if self.is_direct:
self.log.info(f"{user.mxid} left private chat portal with {self.ktid}")
if user.ktid == self.kt_receiver:
self.log.info(
f"{user.mxid} was the recipient of this portal. Cleaning up and deleting..."
)
await self.cleanup_and_delete()
else:
self.log.debug(f"{user.mxid} left portal to {self.ktid}")
async def _set_typing(self, users: set[UserID], typing: bool) -> None:
self.log.info("TODO: _set_typing")
async def handle_matrix_typing(self, users: set[UserID]) -> None:
await asyncio.gather(
self._set_typing(users - self._typing, typing=True),
self._set_typing(self._typing - users, typing=False),
)
self._typing = users
async def enable_dm_encryption(self) -> bool:
ok = await super().enable_dm_encryption()
if ok:
try:
puppet = await p.Puppet.get_by_ktid(self.ktid)
await self.main_intent.set_room_name(self.mxid, puppet.name)
except Exception:
self.log.warning(f"Failed to set room name", exc_info=True)
return ok
# endregion
# region KakaoTalk event handling
async def _bridge_own_message_pm(
self, source: u.User, sender: p.Puppet, mid: str, invite: bool = True
) -> bool:
if self.is_direct and sender.ktid == source.ktid and not sender.is_real_user:
if self.invite_own_puppet_to_pm and invite:
await self.main_intent.invite_user(self.mxid, sender.mxid)
elif (
await self.az.state_store.get_membership(self.mxid, sender.mxid) != Membership.JOIN
):
self.log.warning(
f"Ignoring own {mid} in private chat because own puppet is not in room."
)
return False
return True
async def _add_kakaotalk_reply(
self, content: MessageEventContent, reply_to: None
) -> None:
self.log.info("TODO")
async def handle_remote_message(
self,
source: u.User,
sender: p.Puppet,
message: str,
reply_to: None = None,
) -> None:
try:
await self._handle_remote_message(source, sender, message, reply_to)
except Exception:
self.log.exception(
"Error handling Kakaotalk message <TODO: ID>"
)
async def _handle_remote_message(
self,
source: u.User,
sender: p.Puppet,
message: str,
reply_to: None = None,
) -> None:
self.log.info("TODO")
# TODO Many more remote handlers
# endregion
async def backfill(self, source: u.User, is_initial: bool, channel: PortalChannelInfo) -> None:
self.log.info("TODO: backfill")
# region Database getters
async def postinit(self) -> None:
self.by_ktid[self.ktid_full] = self
if self.mxid:
self.by_mxid[self.mxid] = self
self._main_intent = (
(await p.Puppet.get_by_ktid(self.ktid)).default_mxid_intent
if self.is_direct
else self.az.intent
)
@classmethod
@async_getter_lock
async def get_by_mxid(cls, mxid: RoomID) -> Portal | None:
try:
return cls.by_mxid[mxid]
except KeyError:
pass
portal = cast(cls, await super().get_by_mxid(mxid))
if portal:
await portal.postinit()
return portal
return None
@classmethod
@async_getter_lock
async def get_by_ktid(
cls,
ktid: Long,
*,
kt_receiver: Long = Long.ZERO,
create: bool = True,
kt_type: ChannelType | None = None,
) -> Portal | None:
if kt_type:
kt_receiver = kt_receiver if KnownChannelType.is_direct(kt_type) else Long.ZERO
ktid_full = (ktid, kt_receiver)
try:
return cls.by_ktid[ktid_full]
except KeyError:
pass
portal = cast(cls, await super().get_by_ktid(ktid, kt_receiver))
if portal:
await portal.postinit()
return portal
if kt_type and create:
portal = cls(ktid=ktid, kt_receiver=kt_receiver, kt_type=kt_type)
await portal.insert()
await portal.postinit()
return portal
return None
@classmethod
async def get_all_by_receiver(cls, kt_receiver: Long) -> AsyncGenerator[Portal, None]:
portals = await super().get_all_by_receiver(kt_receiver)
portal: Portal
for portal in portals:
try:
yield cls.by_ktid[(portal.ktid, portal.kt_receiver)]
except KeyError:
await portal.postinit()
yield portal
@classmethod
async def all(cls) -> AsyncGenerator[Portal, None]:
portals = await super().all()
portal: Portal
for portal in portals:
try:
yield cls.by_ktid[(portal.ktid, portal.kt_receiver)]
except KeyError:
await portal.postinit()
yield portal
# endregion