From 84e6a5829db5ec1f97b864dfa49a352c5d803bce Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Wed, 6 Apr 2022 12:49:23 -0400 Subject: [PATCH] Outgoing mentions --- .../formatter/from_matrix.py | 78 +++++++++++++++++-- .../kt/client/client.py | 4 +- .../kt/types/request.py | 5 +- matrix_appservice_kakaotalk/portal.py | 3 +- node/src/client.js | 7 +- 5 files changed, 79 insertions(+), 18 deletions(-) diff --git a/matrix_appservice_kakaotalk/formatter/from_matrix.py b/matrix_appservice_kakaotalk/formatter/from_matrix.py index 94ad91f..e023219 100644 --- a/matrix_appservice_kakaotalk/formatter/from_matrix.py +++ b/matrix_appservice_kakaotalk/formatter/from_matrix.py @@ -18,29 +18,63 @@ from __future__ import annotations from typing import NamedTuple from mautrix.appservice import IntentAPI -from mautrix.types import MessageEventContent, RelationType, RoomID +from mautrix.types import Format, MessageEventContent, RelationType, RoomID, UserID +from mautrix.util.formatter import ( + EntityString, + EntityType, + MarkdownString, + MatrixParser, + SimpleEntity, +) from mautrix.util.logging import TraceLogger from ..kt.types.bson import Long -from ..kt.types.chat.attachment.reply import ReplyAttachment +from ..kt.types.chat.attachment import ReplyAttachment, MentionStruct from ..kt.client.types import TO_MSGTYPE_MAP -from .. import puppet as pu +from .. import puppet as pu, user as u from ..db import Message as DBMessage class SendParams(NamedTuple): text: str - # TODO Mentions + mentions: list[MentionStruct] | None reply_to: ReplyAttachment +class KakaoTalkFormatString(EntityString[SimpleEntity, EntityType], MarkdownString): + def format(self, entity_type: EntityType, **kwargs) -> KakaoTalkFormatString: + if entity_type == EntityType.USER_MENTION: + self.entities.append( + SimpleEntity( + type=entity_type, + offset=0, + length=len(self.text), + extra_info={"user_id": kwargs["user_id"]}, + ) + ) + self.text = f"@{self.text}" + return self + + +class ToKakaoTalkParser(MatrixParser[KakaoTalkFormatString]): + fs = KakaoTalkFormatString + + +async def _get_id_from_mxid(mxid: UserID) -> Long | None: + user = await u.User.get_by_mxid(mxid, create=False) + if user and user.ktid: + return user.ktid + else: + puppet = await pu.Puppet.get_by_mxid(mxid, create=False) + return puppet.ktid if puppet else None + + async def matrix_to_kakaotalk( content: MessageEventContent, room_id: RoomID, log: TraceLogger, intent: IntentAPI ) -> SendParams: # NOTE By design, this *throws* if user intent can't be matched (i.e. if a reply can't be created) - # TODO Mentions if content.relates_to.rel_type == RelationType.REPLY: message = await DBMessage.get_by_mxid(content.relates_to.event_id, room_id) if not message: @@ -53,7 +87,7 @@ async def matrix_to_kakaotalk( except: log.exception(f"Failed to find Matrix event for reply target {message.mxid}") raise - kt_sender = pu.Puppet.get_id_from_mxid(mx_event.sender) + kt_sender = await _get_id_from_mxid(mx_event.sender) if kt_sender is None: raise ValueError( f"Found no KakaoTalk user ID for reply target sender {mx_event.sender}" @@ -75,8 +109,36 @@ async def matrix_to_kakaotalk( # TODO Check if source message needs to be formatted src_message=mx_event.content.body, src_type=TO_MSGTYPE_MAP[mx_event.content.msgtype], - src_userId=Long(kt_sender), + src_userId=kt_sender, ) else: reply_to = None - return SendParams(text=content.body, reply_to=reply_to) + if content.get("format", None) == Format.HTML and content["formatted_body"]: + parsed = await ToKakaoTalkParser().parse(content["formatted_body"]) + text = parsed.text + mentions_by_user: dict[Long, MentionStruct] = {} + # Make sure to not create remote mentions for any remote user not in the room + if parsed.entities: + joined_members = set(await intent.get_room_members(room_id)) + last_offset = 0 + at = 0 + for mention in sorted(parsed.entities, key=lambda entity: entity.offset): + mxid = mention.extra_info["user_id"] + if mxid not in joined_members: + continue + ktid = await _get_id_from_mxid(mxid) + if ktid is None: + continue + at += text[last_offset:mention.offset+1].count("@") + last_offset = mention.offset+1 + mention = mentions_by_user.setdefault(ktid, MentionStruct( + at=[], + len=mention.length, + user_id=ktid, + )) + mention.at.append(at) + mentions = list(mentions_by_user.values()) + else: + text = content.body + mentions = None + return SendParams(text=text, mentions=mentions, reply_to=reply_to) diff --git a/matrix_appservice_kakaotalk/kt/client/client.py b/matrix_appservice_kakaotalk/kt/client/client.py index 55ac9cc..99fc427 100644 --- a/matrix_appservice_kakaotalk/kt/client/client.py +++ b/matrix_appservice_kakaotalk/kt/client/client.py @@ -41,7 +41,7 @@ from ..types.api.struct import FriendListStruct from ..types.bson import Long from ..types.client.client_session import LoginResult from ..types.chat import Chatlog, KnownChatType -from ..types.chat.attachment import ReplyAttachment +from ..types.chat.attachment import MentionStruct, ReplyAttachment from ..types.oauth import OAuthCredential, OAuthInfo from ..types.packet.chat.kickout import KnownKickoutType, KickoutRes from ..types.request import ( @@ -279,6 +279,7 @@ class Client: channel_props: ChannelProps, text: str, reply_to: ReplyAttachment | None, + mentions: list[MentionStruct] | None, ) -> Chatlog: return await self._api_user_request_result( Chatlog, @@ -286,6 +287,7 @@ class Client: channel_props=channel_props.serialize(), text=text, reply_to=reply_to.serialize() if reply_to is not None else None, + mentions=[m.serialize() for m in mentions] if mentions is not None else None, ) async def send_media( diff --git a/matrix_appservice_kakaotalk/kt/types/request.py b/matrix_appservice_kakaotalk/kt/types/request.py index b945326..64145c6 100644 --- a/matrix_appservice_kakaotalk/kt/types/request.py +++ b/matrix_appservice_kakaotalk/kt/types/request.py @@ -94,15 +94,12 @@ ResultType = TypeVar("ResultType", bound=Serializable) def ResultListType(result_type: Type[ResultType]): class _ResultListType(list[result_type], Serializable): - def __init__(self, iterable: Iterable[result_type]=()): - list.__init__(self, (result_type.deserialize(x) for x in iterable)) - def serialize(self) -> list[JSON]: return [v.serialize() for v in self] @classmethod def deserialize(cls, data: list[JSON]) -> "_ResultListType": - return cls(data) + return [result_type.deserialize(item) for item in data] return _ResultListType diff --git a/matrix_appservice_kakaotalk/portal.py b/matrix_appservice_kakaotalk/portal.py index c21b952..e4c3019 100644 --- a/matrix_appservice_kakaotalk/portal.py +++ b/matrix_appservice_kakaotalk/portal.py @@ -811,9 +811,8 @@ class Portal(DBPortal, BasePortal): chatlog = await sender.client.send_message( self.channel_props, text=converted.text, - # TODO - #mentions=converted.mentions, reply_to=converted.reply_to, + mentions=converted.mentions, ) except CommandException as e: self.log.debug(f"Error handling Matrix message {event_id}: {e!s}") diff --git a/node/src/client.js b/node/src/client.js index b214eaa..bc28579 100644 --- a/node/src/client.js +++ b/node/src/client.js @@ -26,6 +26,7 @@ import { /** @typedef {import("node-kakao").OAuthCredential} OAuthCredential */ /** @typedef {import("node-kakao").ChannelType} ChannelType */ /** @typedef {import("node-kakao").ReplyAttachment} ReplyAttachment */ +/** @typedef {import("node-kakao").MentionStruct} MentionStruct */ /** @typedef {import("node-kakao/dist/talk").TalkChannelList} TalkChannelList */ import chat from "node-kakao/chat" @@ -503,12 +504,12 @@ export default class PeerClient { } /** - * TODO Mentions * @param {Object} req * @param {string} req.mxid * @param {Object} req.channel_props * @param {string} req.text * @param {?ReplyAttachment} req.reply_to + * @param {?MentionStruct[]} req.mentions */ sendChat = async (req) => { const talkChannel = await this.#getUserChannel(req.mxid, req.channel_props) @@ -516,7 +517,7 @@ export default class PeerClient { return await talkChannel.sendChat({ text: req.text, type: !!req.reply_to ? KnownChatType.REPLY : KnownChatType.TEXT, - attachment: req.reply_to, + attachment: !req.mentions ? req.reply_to : {...req.reply_to, mentions: req.mentions}, }) } @@ -525,7 +526,7 @@ export default class PeerClient { * @param {string} req.mxid * @param {Object} req.channel_props * @param {int} req.type - * @param {[number]} req.data + * @param {number[]} req.data * @param {string} req.name * @param {?int} req.width * @param {?int} req.height