Compare commits

...

14 Commits

Author SHA1 Message Date
a9c7bfe046 Update roadmap with missing edge cases and KT message types 2022-04-12 04:42:26 -04:00
fe6df88a4b Return IDs of bridged outgoing messages instead of the chats themselves
And set an optional property in AudioAttachment to be safe
2022-04-12 04:42:26 -04:00
aa5c066552 Prepend relayed media messages with a new message of who sent it
And keep its body text unchanged from its original
2022-04-12 04:42:26 -04:00
587ec98f3e Handle serializer errors on connect 2022-04-12 04:42:26 -04:00
33a8218eee Add missing None check for outbound read receipts 2022-04-12 04:42:26 -04:00
59c7f1fd2e Add missing command handler connection that I forgot about 2022-04-12 04:42:26 -04:00
af296510aa Reinstate outbound formatting 2022-04-12 04:42:26 -04:00
b2f9298817 Remove some apparently unused inbound formatting 2022-04-12 04:42:26 -04:00
38d307c684 Fix sometimes-broken inbound mentions 2022-04-12 04:42:26 -04:00
b9cf30e9e6 Don't log contents of what look to be inbound deleted chats 2022-04-12 04:42:26 -04:00
73f8792b75 Set history & join rules to match KakaoTalk behaviour 2022-04-12 04:42:26 -04:00
2be6a761b6 Don't bridge what look to be inbound deleted chats 2022-04-12 04:42:26 -04:00
164bb7ce10 Make emoticon alt-text property optional 2022-04-12 04:42:26 -04:00
3bf49123f5 Better node disconnect handling 2022-04-11 14:50:20 -04:00
13 changed files with 128 additions and 102 deletions

View File

@ -13,7 +13,12 @@
* [ ] Replies
* [x] In DMs
* [ ] In multi-user rooms
* [x] Mentions
* [ ] To relay users
* [ ] Mentions
* [x] In DMs
* [ ] In multi-user rooms
* [x] To relay users
* [ ] Polls
* [x] Message redactions<sup>[1]</sup>
* [ ] Message reactions
* [x] Read receipts
@ -36,8 +41,11 @@
* [x] Videos
* [x] Images
* [ ] Locations
* [ ] Links
* [x] Replies
* [x] Mentions
* [ ] Polls
* [ ] Posts
* [x] Message deletion/hiding
* [ ] Message reactions
* [x] Message history

View File

@ -246,10 +246,10 @@ bridge:
m.text: '<b>$sender_displayname</b>: $message'
m.notice: '<b>$sender_displayname<b>: $message'
m.emote: '* <b>$sender_displayname<b> $message'
m.file: '<b>$sender_displayname</b> sent a file'
m.image: '<b>$sender_displayname</b> sent an image'
m.audio: '<b>$sender_displayname</b> sent an audio file'
m.video: '<b>$sender_displayname</b> sent a video'
m.file: 'File from <b>$sender_displayname</b>: $message'
m.image: 'Image from <b>$sender_displayname</b>: $message'
m.audio: 'Audio from <b>$sender_displayname</b>: $message'
m.video: 'Video from <b>$sender_displayname</b>: $message'
m.location: '<b>$sender_displayname</b> sent a location'
rpc:

View File

@ -24,60 +24,8 @@ from ..kt.types.chat.attachment.mention import MentionStruct
from .. import puppet as pu, user as u
_START = r"^|\s"
_END = r"$|\s"
_TEXT_NO_SURROUNDING_SPACE = r"(?:[^\s].*?[^\s])|[^\s]"
COMMON_REGEX = re.compile(rf"({_START})([_~*])({_TEXT_NO_SURROUNDING_SPACE})\2({_END})")
INLINE_CODE_REGEX = re.compile(rf"({_START})(`)(.+?)`({_END})")
MENTION_REGEX = re.compile(r"@([0-9]{1,15})\u2063(.+?)\u2063")
tags = {"_": "em", "*": "strong", "~": "del", "`": "code"}
def _handle_match(html: str, match: Match, nested: bool) -> tuple[str, int]:
start, end = match.start(), match.end()
prefix, sigil, text, suffix = match.groups()
if nested:
text = _convert_formatting(text)
tag = tags[sigil]
# We don't want to include the whitespace suffix length, as that could be used as the
# whitespace prefix right after this formatting block.
pos = start + len(prefix) + (2 * len(tag) + 5) + len(text)
html = f"{html[:start]}{prefix}<{tag}>{text}</{tag}>{suffix}{html[end:]}"
return html, pos
def _convert_formatting(html: str) -> str:
pos = 0
while pos < len(html):
i_match = INLINE_CODE_REGEX.search(html, pos)
c_match = COMMON_REGEX.search(html, pos)
if i_match and c_match:
match = min(i_match, c_match, key=lambda match: match.start())
else:
match = i_match or c_match
if match:
html, pos = _handle_match(html, match, nested=match != i_match)
else:
break
return html
def _handle_blockquote(output: list[str], blockquote: bool, line: str) -> tuple[bool, str]:
if not blockquote and line.startswith("&gt; "):
line = line[len("&gt; ") :]
output.append("<blockquote>")
blockquote = True
elif blockquote:
if line.startswith("&gt;"):
line = line[len("&gt;") :]
if line.startswith(" "):
line = line[1:]
else:
output.append("</blockquote>")
blockquote = False
return blockquote, line
MENTION_REGEX = re.compile(r"@(\d+)\u2063(.+?)\u2063")
async def kakaotalk_to_matrix(msg: str | None, mentions: list[MentionStruct] | None) -> TextMessageEventContent:

View File

@ -19,6 +19,7 @@ from typing import NamedTuple
from mautrix.appservice import IntentAPI
from mautrix.types import Format, MessageEventContent, RelationType, RoomID, UserID
from mautrix.util import utf16_surrogate
from mautrix.util.formatter import (
EntityString,
EntityType,
@ -46,6 +47,7 @@ class SendParams(NamedTuple):
class KakaoTalkFormatString(EntityString[SimpleEntity, EntityType], MarkdownString):
def format(self, entity_type: EntityType, **kwargs) -> KakaoTalkFormatString:
prefix = suffix = ""
if entity_type == EntityType.USER_MENTION:
self.entities.append(
SimpleEntity(
@ -55,7 +57,32 @@ class KakaoTalkFormatString(EntityString[SimpleEntity, EntityType], MarkdownStri
extra_info={"user_id": kwargs["user_id"]},
)
)
self.text = f"@{self.text}"
return self
elif entity_type == EntityType.BOLD:
prefix = suffix = "*"
elif entity_type == EntityType.ITALIC:
prefix = suffix = "_"
elif entity_type == EntityType.STRIKETHROUGH:
prefix = suffix = "~"
elif entity_type == EntityType.URL:
if kwargs["url"] != self.text:
suffix = f" ({kwargs['url']})"
elif entity_type == EntityType.PREFORMATTED:
prefix = f"```{kwargs['language']}\n"
suffix = "\n```"
elif entity_type == EntityType.INLINE_CODE:
prefix = suffix = "`"
elif entity_type == EntityType.BLOCKQUOTE:
children = self.trim().split("\n")
children = [child.prepend("> ") for child in children]
return self.join(children, "\n")
elif entity_type == EntityType.HEADER:
prefix = "#" * kwargs["size"] + " "
else:
return self
self._offset_entities(len(prefix))
self.text = f"{prefix}{self.text}{suffix}"
return self
@ -126,8 +153,8 @@ async def matrix_to_kakaotalk(
else:
reply_to = None
if content.get("format", None) == Format.HTML and content["formatted_body"] and content.msgtype.is_text:
parsed = await ToKakaoTalkParser().parse(content["formatted_body"])
text = parsed.text
parsed = await ToKakaoTalkParser().parse(utf16_surrogate.add(content["formatted_body"]))
text = utf16_surrogate.remove(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:

View File

@ -240,13 +240,17 @@ class Client:
self.user.oauth_credential = oauth_info.credential
await self.user.save()
async def connect(self) -> LoginResult:
async def connect(self) -> LoginResult | None:
"""
Start a new talk session by providing a token obtained from a prior login.
Receive a snapshot of account state in response.
"""
login_result = await self._api_user_request_result(LoginResult, "connect")
assert self.user.ktid == login_result.userId, f"User ID mismatch: expected {self.user.ktid}, got {login_result.userId}"
try:
login_result = await self._api_user_request_result(LoginResult, "connect")
assert self.user.ktid == login_result.userId, f"User ID mismatch: expected {self.user.ktid}, got {login_result.userId}"
except SerializerError:
self.log.exception("Unable to deserialize login result, but connecting anyways")
login_result = None
# TODO Skip if handlers are already listening. But this is idempotent and thus probably safe
self._start_listen()
return login_result
@ -322,9 +326,9 @@ class Client:
text: str,
reply_to: ReplyAttachment | None,
mentions: list[MentionStruct] | None,
) -> Chatlog:
) -> Long:
return await self._api_user_request_result(
Chatlog,
Long,
"send_chat",
channel_props=channel_props.serialize(),
text=text,
@ -342,9 +346,9 @@ class Client:
width: int | None = None,
height: int | None = None,
ext: str | None = None,
) -> Chatlog:
) -> Long:
return await self._api_user_request_result(
Chatlog,
Long,
"send_media",
channel_props=channel_props.serialize(),
type=media_type,

View File

@ -30,6 +30,10 @@ class KnownChannelType(str, Enum):
def is_direct(cls, value: Union["KnownChannelType", str]) -> bool:
return value in [cls.DirectChat, cls.MemoChat]
@classmethod
def is_open(cls, value: Union["KnownChannelType", str]) -> bool:
return value in [cls.OM, cls.OD]
ChannelType = Union[KnownChannelType, str] # Substitute for ChannelType = "name1" | ... | "nameN" | str

View File

@ -25,7 +25,7 @@ class EmoticonAttachment(Attachment):
path: str
name: str
type: str
alt: str
alt: Optional[str] = None # NOTE Made optional
s: Optional[int] = None
sound: Optional[str] = None
width: Optional[int] = None

View File

@ -78,10 +78,11 @@ class FileAttachment(MediaKeyAttachment):
@dataclass
class AudioAttachment(MediaKeyAttachment):
# NOTE Changed superclass from Attachment
class AudioAttachment(Attachment):
url: str
d: int
expire: Optional[int] = None
s: Optional[int] = None # NOTE Optional for inbound
@dataclass

View File

@ -157,7 +157,8 @@ class MatrixHandler(BaseMatrixHandler):
if not user.is_connected:
return
message = await DBMessage.get_by_mxid(event_id, portal.mxid)
await user.client.mark_read(portal.channel_props, message.ktid)
if message:
await user.client.mark_read(portal.channel_props, message.ktid)
async def handle_ephemeral_event(
self, evt: ReceiptEvent | Event

View File

@ -40,6 +40,8 @@ from mautrix.types import (
EventType,
FileInfo,
ImageInfo,
JoinRule,
JoinRulesStateEventContent,
LocationMessageEventContent,
MediaInfo,
MediaMessageEventContent,
@ -198,6 +200,7 @@ class Portal(DBPortal, BasePortal):
KnownChatType.VIDEO: cls._handle_kakaotalk_video,
KnownChatType.AUDIO: cls._handle_kakaotalk_audio,
#KnownChatType.FILE: cls._handle_kakaotalk_file,
16385: cls._handle_kakaotalk_deleted,
}
# region DB conversion
@ -629,6 +632,17 @@ class Portal(DBPortal, BasePortal):
"content": self.bridge_info,
},
]
if KnownChannelType.is_open(info.channel_info.type):
initial_state.extend((
{
"type": str(EventType.ROOM_JOIN_RULES),
"content": JoinRulesStateEventContent(join_rule=JoinRule.PUBLIC).serialize(),
},
{
"type": "m.room.guest_access",
"content": {"guest_access": "forbidden"},
},
))
invites = []
if self.config["bridge.encryption.default"] and self.matrix.e2ee:
self.encrypted = True
@ -777,7 +791,12 @@ class Portal(DBPortal, BasePortal):
elif not sender.is_connected:
raise Exception("not connected to KakaoTalk chats")
elif is_relay:
await self.apply_relay_message_format(orig_sender, message)
if not message.msgtype.is_text:
intro_message = TextMessageEventContent(msgtype=MessageType.TEXT, body=message.body)
await self.apply_relay_message_format(orig_sender, intro_message)
await self._send_chat(sender, intro_message)
else:
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:
@ -787,6 +806,21 @@ class Portal(DBPortal, BasePortal):
else:
raise NotImplementedError(f"Unsupported message type {message.msgtype}")
async def _send_chat(
self, sender: u.User, message: TextMessageEventContent, event_id: EventID | None = None
) -> Long:
converted = await matrix_to_kakaotalk(message, self.mxid, self.log, self.main_intent)
try:
return await sender.client.send_chat(
self.channel_props,
text=converted.text,
reply_to=converted.reply_to,
mentions=converted.mentions,
)
except CommandException as e:
self.log.debug(f"Error handling Matrix message {event_id if event_id else '<extra>'}: {e!s}")
raise
async def _make_dbm(self, event_id: EventID, ktid: Long | None = None) -> DBMessage:
dbm = DBMessage(
mxid=event_id,
@ -803,19 +837,9 @@ class Portal(DBPortal, BasePortal):
async def _handle_matrix_text(
self, event_id: EventID, sender: u.User, message: TextMessageEventContent
) -> None:
converted = await matrix_to_kakaotalk(message, self.mxid, self.log, self.main_intent)
try:
chatlog = await sender.client.send_chat(
self.channel_props,
text=converted.text,
reply_to=converted.reply_to,
mentions=converted.mentions,
)
except CommandException as e:
self.log.debug(f"Error handling Matrix message {event_id}: {e!s}")
raise
await self._make_dbm(event_id, chatlog.logId)
self.log.debug(f"Handled Matrix message {event_id} -> {chatlog.logId}")
log_id = await self._send_chat(sender, message, event_id)
await self._make_dbm(event_id, log_id)
self.log.debug(f"Handled Matrix message {event_id} -> {log_id}")
sender.send_remote_checkpoint(
MessageSendCheckpointStatus.SUCCESS,
event_id,
@ -843,7 +867,7 @@ class Portal(DBPortal, BasePortal):
width = message.info.width
height = message.info.height
try:
chatlog = await sender.client.send_media(
log_id = await sender.client.send_media(
self.channel_props,
TO_MSGTYPE_MAP[message.msgtype],
data,
@ -855,8 +879,8 @@ class Portal(DBPortal, BasePortal):
except CommandException as e:
self.log.debug(f"Error uploading media for Matrix message {event_id}: {e!s}")
raise
await self._make_dbm(event_id, chatlog.logId)
self.log.debug(f"Handled Matrix message {event_id} -> {chatlog.logId}")
await self._make_dbm(event_id, log_id)
self.log.debug(f"Handled Matrix message {event_id} -> {log_id}")
sender.send_remote_checkpoint(
MessageSendCheckpointStatus.SUCCESS,
event_id,
@ -1086,7 +1110,7 @@ class Portal(DBPortal, BasePortal):
type_str = str(chat_type)
self.log.warning("No handler for chat type \"%s\" (%s)",
type_str,
f"text = {chat_text}" if chat_text is not None else "no text",
f"text = \"{chat_text}\"" if chat_text is not None else "no text",
)
if chat_text:
events = await self._handle_kakaotalk_text(
@ -1115,6 +1139,14 @@ class Portal(DBPortal, BasePortal):
self.log.info("Got feed message at %s: %s", timestamp, chat_text or "none")
return []
async def _handle_kakaotalk_deleted(
self,
timestamp: int,
**_
) -> list[EventID]:
self.log.info(f"Got deleted (?) message at {timestamp}")
return []
async def _handle_kakaotalk_text(
self,
intent: IntentAPI,

View File

@ -144,9 +144,9 @@ class RPCClient:
self._reader = r
self._writer = w
self._read_task = self.loop.create_task(self._try_read_loop())
await self._raw_request("register", peer_id=self.config["appservice.address"])
self._is_connected.set()
self._is_disconnected.clear()
await self.request("register", peer_id=self.config["appservice.address"])
async def disconnect(self) -> None:
async with self._connection_lock:
@ -258,9 +258,11 @@ class RPCClient:
try:
await self._read_loop()
except asyncio.CancelledError:
pass
return
except:
self.log.exception("Fatal error in read loop")
self.log.debug("Reader disconnected")
self._on_disconnect()
async def _read_loop(self) -> None:
while self._reader is not None and not self._reader.at_eof():
@ -275,9 +277,6 @@ class RPCClient:
except asyncio.LimitOverrunError as e:
self.log.warning(f"Buffer overrun: {e}")
line += await self._reader.read(self._reader._limit)
except ConnectionResetError:
if self._reader is not None:
raise
except asyncio.CancelledError:
raise
if not line:
@ -293,8 +292,6 @@ class RPCClient:
raise
except:
self.log.exception("Failed to handle incoming request %s", line_str)
self.log.debug("Reader disconnected")
self._on_disconnect()
async def _raw_request(self, command: str, is_secret: bool = False, **data: JSON) -> asyncio.Future[JSON]:
req_id = self._next_req_id

View File

@ -412,7 +412,8 @@ class User(DBUser, BaseUser):
try:
login_result = await self.client.connect()
await self.on_connect()
await self._sync_channels(login_result, sync_count)
if login_result:
await self._sync_channels(login_result, sync_count)
return True
except AuthenticationRequired as e:
await self.send_bridge_notice(

View File

@ -634,12 +634,13 @@ export default class PeerClient {
*/
sendChat = async (req) => {
const talkChannel = await this.#getUserChannel(req.mxid, req.channel_props)
return await talkChannel.sendChat({
const res = await talkChannel.sendChat({
text: req.text,
type: !!req.reply_to ? KnownChatType.REPLY : KnownChatType.TEXT,
attachment: !req.mentions ? req.reply_to : {...req.reply_to, mentions: req.mentions},
})
if (res.success) res.result = res.result.logId
return res
}
/**
@ -655,14 +656,15 @@ export default class PeerClient {
*/
sendMedia = async (req) => {
const talkChannel = await this.#getUserChannel(req.mxid, req.channel_props)
return await talkChannel.sendMedia(req.type, {
const res = await talkChannel.sendMedia(req.type, {
data: Uint8Array.from(req.data),
name: req.name,
width: req.width,
height: req.height,
ext: req.ext,
})
if (res.success) res.result = res.result.logId
return res
}
/**
@ -770,6 +772,7 @@ export default class PeerClient {
send_chat: this.sendChat,
send_media: this.sendMedia,
delete_chat: this.deleteChat,
mark_read: this.markRead,
}[req.command] || this.handleUnknownCommand
}
const resp = { id: req.id }