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