Compare commits
14 Commits
95d08e5aeb
...
a9c7bfe046
Author | SHA1 | Date | |
---|---|---|---|
a9c7bfe046 | |||
fe6df88a4b | |||
aa5c066552 | |||
587ec98f3e | |||
33a8218eee | |||
59c7f1fd2e | |||
af296510aa | |||
b2f9298817 | |||
38d307c684 | |||
b9cf30e9e6 | |||
73f8792b75 | |||
2be6a761b6 | |||
164bb7ce10 | |||
3bf49123f5 |
10
ROADMAP.md
10
ROADMAP.md
@ -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
|
||||
|
@ -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:
|
||||
|
@ -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("> "):
|
||||
line = line[len("> ") :]
|
||||
output.append("<blockquote>")
|
||||
blockquote = True
|
||||
elif blockquote:
|
||||
if line.startswith(">"):
|
||||
line = line[len(">") :]
|
||||
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:
|
||||
|
@ -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:
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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(
|
||||
|
@ -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 }
|
||||
|
Loading…
Reference in New Issue
Block a user