Inbound message attachments, starting with images

This commit is contained in:
Andrew Ferrazzutti 2022-03-24 19:32:46 -04:00
parent e099886eb1
commit 256c4d429a
15 changed files with 613 additions and 42 deletions

View File

@ -21,7 +21,7 @@ from asyncpg import Record
from attr import dataclass, field
from mautrix.types import EventID, RoomID
from mautrix.util.async_db import Database
from mautrix.util.async_db import Database, Scheme
from ..kt.types.bson import Long
@ -101,6 +101,30 @@ class Message:
"VALUES ($1, $2, $3, $4, $5, $6, $7)"
)
@classmethod
async def bulk_create(
cls,
ktid: str,
kt_chat: int,
kt_receiver: int,
event_ids: list[EventID],
timestamp: int,
mx_room: RoomID,
) -> None:
if not event_ids:
return
columns = [col.strip('"') for col in cls.columns.split(", ")]
records = [
(mxid, mx_room, ktid, index, kt_chat, kt_receiver, timestamp)
for index, mxid in enumerate(event_ids)
]
async with cls.db.acquire() as conn, conn.transaction():
if cls.db.scheme == Scheme.POSTGRES:
await conn.copy_records_to_table("message", records=records, columns=columns)
else:
await conn.executemany(cls._insert_query, records)
async def insert(self) -> None:
q = self._insert_query
await self.db.execute(

View File

@ -23,7 +23,7 @@ with any other potential backend.
from __future__ import annotations
from typing import TYPE_CHECKING, cast, Type, Optional, Union
from contextlib import asynccontextmanager
import logging
import urllib.request
@ -65,6 +65,12 @@ if TYPE_CHECKING:
from ...user import User
@asynccontextmanager
async def sandboxed_get(url: URL) -> _RequestContextManager:
async with ClientSession() as sess, sess.get(url) as resp:
yield resp
# TODO Consider defining an interface for this, with node/native backend as swappable implementations
# TODO If no state is stored, consider using free functions instead of classmethods
class Client:
@ -154,6 +160,7 @@ class Client:
self,
url: Union[str, URL],
headers: Optional[dict[str, str]] = None,
sandbox: bool = False,
**kwargs,
) -> _RequestContextManager:
# TODO Is auth ever needed?
@ -163,6 +170,8 @@ class Client:
**(headers or {}),
}
url = URL(url)
if sandbox:
return sandboxed_get(url)
return self.http.get(url, headers=headers, **kwargs)
# endregion

View File

@ -13,7 +13,6 @@
#
# 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 .attachment import *
from .chat_type import *
from .chat import *
"""

View File

@ -19,8 +19,7 @@ from attr import dataclass
from mautrix.types import SerializableAttrs
from ..bson import Long
from .mention import MentionStruct
from .mention import *
@dataclass(kw_only=True)
@ -28,9 +27,28 @@ class Attachment(SerializableAttrs):
shout: Optional[bool] = None
mentions: Optional[list[MentionStruct]] = None
urls: Optional[list[str]] = None
url: Optional[str] = None # NOTE Added since this may have replaced urls
@dataclass
class PathAttachment(Attachment):
path: str
s: int
from .media import *
from .reply import *
from .media import *
from .emoticon import *
#from .voip import *
from .contact import *
from .map import *
from .post import *
from .openlink import *
__all__ = [
"Attachment",
"PathAttachment",
]
# TODO What about the import *s?

View File

@ -0,0 +1,42 @@
# 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 typing import Union
from attr import dataclass
from ...bson import Long
from . import Attachment
@dataclass
class ProfileAttachment(Attachment):
userId: Union[int, Long]
nickName: str
fullProfileImageUrl: str
profileImageUrl: str
statusMessage: str
@dataclass
class ContactAttachment(Attachment):
name: str
url: str
__all__ = [
"ProfileAttachment",
"ContactAttachment",
]

View File

@ -0,0 +1,37 @@
# 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 typing import Optional
from attr import dataclass
from . import Attachment
@dataclass
class EmoticonAttachment(Attachment):
path: str
name: str
type: str
alt: str
s: Optional[int] = None
sound: Optional[str] = None
width: Optional[int] = None
height: Optional[int] = None
__all__ = [
"EmoticonAttachment",
]

View File

@ -0,0 +1,31 @@
# 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 attr import dataclass
from . import Attachment
@dataclass
class MapAttachment(Attachment):
lat: int
lng: int
a: str
c: bool
__all__ = [
"MapAttachment",
]

View File

@ -0,0 +1,93 @@
# 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 typing import Optional
from attr import dataclass
from . import Attachment
@dataclass
class MediaKeyAttachment(Attachment):
k: str
url: str
s: int
@dataclass
class PhotoAttachment(Attachment):
w: int
h: int
thumbnailUrl: str
thumbnailWidth: int
thumbnailHeight: int
cs: str
mt: str
@dataclass
class MultiPhotoAttachment(Attachment):
kl: list[str]
wl: list[int]
hl: list[int]
csl: list[str]
imageUrls: list[str]
thumbnailUrls: list[str]
thumbnailWidths: list[int]
thumbnailHeights: list[int]
sl: int
@dataclass
class VideoAttachment(Attachment):
tk: str
w: int
h: int
cs: str
d: int
@dataclass
class FileAttachment(Attachment):
name: str
size: int
expire: int
cs: str
@dataclass
class AudioAttachment(Attachment):
d: int
expire: Optional[int] = None
@dataclass
class LongTextAttachment(Attachment):
path: str
k: str
s: int
sd: bool
__all__ = [
"MediaKeyAttachment",
"PhotoAttachment",
"MultiPhotoAttachment",
"VideoAttachment",
"FileAttachment",
"AudioAttachment",
"LongTextAttachment",
]

View File

@ -19,7 +19,7 @@ from attr import dataclass
from mautrix.types import SerializableAttrs
from ..bson import Long
from ...bson import Long
@dataclass
@ -27,3 +27,8 @@ class MentionStruct(SerializableAttrs):
at: list[int]
len: int
user_id: Union[Long, int]
__all__ = [
"MentionStruct",
]

View File

@ -0,0 +1,30 @@
# 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 attr import dataclass
from .post import PostAttachment
@dataclass
class OpenScheduleAttachment(PostAttachment):
scheduleId: int
title: str
eventAt: int
__all__ = [
"OpenScheduleAttachment",
]

View File

@ -0,0 +1,141 @@
# 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 typing import Optional, Union
from enum import IntEnum
from attr import dataclass
from mautrix.types import SerializableAttrs
from ...bson import Long
from . import Attachment
from .emoticon import EmoticonAttachment
class KnownPostItemType(IntEnum):
TEXT = 1
FOOTER = 2
HEADER = 3
EMOTICON = 4
IMAGE = 5
VIDEO = 6
FILE = 7
SCHEDULE = 8
VOTE = 9
SCRAP = 10
PostItemType = Union[KnownPostItemType, int]
class KnownPostSubItemType(IntEnum):
pass
PostSubItemType = Union[KnownPostSubItemType, int]
class KnownPostFooterStyle(IntEnum):
ARTICLE = 1
SCHEDULE = 2
SCHEDULE_ANSWER = 3
VOTE = 4
VOTE_RESULT = 5
PostFooterStyle = Union[KnownPostFooterStyle, int]
class PostItem:
@dataclass
class Unknown(SerializableAttrs):
t: PostItemType
@dataclass(kw_only=True)
class Text(Unknown):
t = KnownPostItemType.TEXT
ct: str
jct: str
@dataclass(kw_only=True)
class Header(Unknown):
t = KnownPostItemType.HEADER
st: int
@dataclass
class UDict(SerializableAttrs):
id: Union[int, Long]
u: Optional[UDict]
@dataclass(kw_only=True)
class Image(Unknown):
t = KnownPostItemType.IMAGE
tt: Optional[str] = None
th: list[str]
g: Optional[bool] = None
@dataclass(kw_only=True)
class Emoticon(Unknown):
t = KnownPostItemType.EMOTICON
ct: EmoticonAttachment
@dataclass(kw_only=True)
class Vote(Unknown):
t = KnownPostItemType.VOTE
st: int
tt: str
ittpe: Optional[str] = None
its: list[dict]
@dataclass(kw_only=True)
class Video(Unknown):
t = KnownPostItemType.VIDEO
th: str
@dataclass(kw_only=True)
class File(Unknown):
t = KnownPostItemType.FILE
tt: str
c: int
@dataclass(kw_only=True)
class Footer(Unknown):
t = KnownPostItemType.FOOTER
st: PostFooterStyle
url: str
@dataclass(kw_only=True)
class PostAttachment(Attachment):
subtype: Optional[PostSubItemType] = None
os: list[PostItem.Unknown]
@dataclass
class VoteAttachment(PostAttachment):
voteId: int
title: str
__all__ = [
"KnownPostItemType",
"PostItemType",
"KnownPostSubItemType",
"PostSubItemType",
"KnownPostFooterStyle",
"PostFooterStyle",
"PostItem",
"PostAttachment",
"VoteAttachment",
]

View File

@ -0,0 +1,40 @@
# 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 typing import Optional
from attr import dataclass
from ...bson import Long
from . import Attachment
from ..chat_type import ChatType
from .mention import MentionStruct
@dataclass(kw_only=True)
class ReplyAttachment(Attachment):
attach_only: bool
attach_type: int
src_linkId: Optional[Long] = None
src_logId: Long
src_mentions: list[MentionStruct]
src_message: str
src_type: ChatType
src_userId: Long
__all__ = [
"ReplyAttachment",
]

View File

@ -13,16 +13,30 @@
#
# 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 typing import Optional, Union
from typing import Optional, Union, Type
from attr import dataclass
from mautrix.types import SerializableAttrs
from mautrix.types import SerializableAttrs, JSON
from ..bson import Long
from ..user.channel_user import ChannelUser
from ..attachment import Attachment
from .chat_type import ChatType
from .attachment import (
Attachment,
PhotoAttachment,
VideoAttachment,
ContactAttachment,
AudioAttachment,
EmoticonAttachment,
OpenScheduleAttachment,
VoteAttachment,
MapAttachment,
ProfileAttachment,
FileAttachment,
PostAttachment,
ReplyAttachment,
)
from .chat_type import ChatType, KnownChatType
@dataclass
@ -35,6 +49,36 @@ class Chat(ChatTypeComponent):
attachment: Optional[Attachment] = None
supplement: Optional[dict] = None
@classmethod
def deserialize(cls, data: JSON) -> "Chat":
if "attachment" in data:
attachment = _attachment_type_map.get(int(data["type"]), Attachment).deserialize(data["attachment"])
del data["attachment"]
else:
attachment = None
obj = super().deserialize(data)
obj.attachment = attachment
return obj
# TODO More
_attachment_type_map: dict[KnownChatType, Type[Attachment]] = {
KnownChatType.PHOTO: PhotoAttachment,
KnownChatType.VIDEO: VideoAttachment,
KnownChatType.CONTACT: ContactAttachment,
KnownChatType.AUDIO: AudioAttachment,
KnownChatType.DITEMEMOTICON: EmoticonAttachment,
KnownChatType.SCHEDULE: OpenScheduleAttachment,
KnownChatType.VOTE: VoteAttachment,
KnownChatType.MAP: MapAttachment,
KnownChatType.PROFILE: ProfileAttachment,
KnownChatType.FILE: FileAttachment,
KnownChatType.POST: PostAttachment,
KnownChatType.REPLY: ReplyAttachment,
KnownChatType.OPEN_SCHEDULE: OpenScheduleAttachment,
KnownChatType.OPEN_VOTE: VoteAttachment,
KnownChatType.OPEN_POST: PostAttachment,
}
@dataclass
class TypedChat(Chat, ChatTypeComponent):

View File

@ -15,7 +15,16 @@
# 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 typing import (
TYPE_CHECKING,
Any,
AsyncGenerator,
Awaitable,
Callable,
Pattern,
cast,
)
from io import BytesIO
import asyncio
import re
import time
@ -24,9 +33,13 @@ from mautrix.appservice import IntentAPI
from mautrix.bridge import BasePortal, NotificationDisabler, async_getter_lock
from mautrix.errors import MatrixError
from mautrix.types import (
AudioInfo,
ContentURI,
EncryptedFile,
EventID,
EventType,
FileInfo,
ImageInfo,
LocationMessageEventContent,
MediaMessageEventContent,
Membership,
@ -35,7 +48,9 @@ from mautrix.types import (
RoomID,
TextMessageEventContent,
UserID,
VideoInfo,
)
from mautrix.util import ffmpeg, magic, variation_selector
from mautrix.util.message_send_checkpoint import MessageSendCheckpointStatus
from mautrix.util.simple_lock import SimpleLock
@ -50,7 +65,10 @@ from .formatter import kakaotalk_to_matrix, matrix_to_kakaotalk
from .kt.types.bson import Long
from .kt.types.channel.channel_info import ChannelInfo
from .kt.types.channel.channel_type import KnownChannelType, ChannelType
from .kt.types.chat.chat import Chatlog
from .kt.types.chat import Chatlog, KnownChatType
from .kt.types.chat.attachment import (
PhotoAttachment,
)
from .kt.client.types import UserInfoUnion, PortalChannelInfo, ChannelProps
from .kt.client.errors import CommandException
@ -158,6 +176,18 @@ class Portal(DBPortal, BasePortal):
NotificationDisabler.puppet_cls = p.Puppet
NotificationDisabler.config_enabled = cls.config["bridge.backfill.disable_notifications"]
# TODO More
cls._message_handler_type_map: dict[
KnownChatType,
Callable[[Portal, u.User, IntentAPI, Chatlog], Awaitable[list[EventID]]]
] = {
KnownChatType.TEXT: cls._handle_remote_text,
KnownChatType.PHOTO: cls._handle_remote_photo,
#KnownChatType.VIDEO: cls._handle_remote_video,
#KnownChatType.AUDIO: cls._handle_remote_audio,
#KnownChatType.FILE: cls._handle_remote_file,
}
# region DB conversion
async def delete(self) -> None:
@ -268,9 +298,8 @@ class Portal(DBPortal, BasePortal):
await self.save()
return info
"""
@classmethod
async def _reupload_kt_file(
async def _reupload_remote_file(
cls,
url: str,
source: u.User,
@ -278,20 +307,19 @@ class Portal(DBPortal, BasePortal):
*,
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:
# TODO Referer header?
async with source.client.get(url, 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)
mime = magic.mimetype(data)
if convert_audio and mime != "audio/ogg":
data = await ffmpeg.convert_bytes(
data, ".ogg", output_args=("-c:a", "libopus"), input_mime=mime
@ -312,7 +340,6 @@ class Portal(DBPortal, BasePortal):
if decryption_info:
decryption_info.url = url
return url, info, decryption_info
"""
async def _update_name(self, name: str) -> bool:
if not name:
@ -859,10 +886,9 @@ class Portal(DBPortal, BasePortal):
source: u.User,
sender: p.Puppet,
message: Chatlog,
reply_to: Chatlog | None = None,
) -> None:
try:
await self._handle_remote_message(source, sender, message, reply_to)
await self._handle_remote_message(source, sender, message)
except Exception:
self.log.exception(
"Error handling KakaoTalk message %s",
@ -874,7 +900,6 @@ class Portal(DBPortal, BasePortal):
source: u.User,
sender: p.Puppet,
message: Chatlog,
reply_to: Chatlog | None = None,
) -> None:
self.log.debug(f"Handling KakaoTalk event {message.logId}")
if not self.mxid:
@ -896,25 +921,59 @@ class Portal(DBPortal, BasePortal):
await intent.ensure_joined(self.mxid)
self._backfill_leave.add(intent)
if message.attachment:
self.log.info("TODO: _handle_remote_message attachments")
if message.supplement:
self.log.info("TODO: _handle_remote_message supplements")
if message.text:
content = await kakaotalk_to_matrix(message.text)
event_id = await self._send_message(intent, content, timestamp=message.sendAt)
await DBMessage(
mxid=event_id,
mx_room=self.mxid,
ktid=message.logId,
index=0,
kt_chat=self.ktid,
kt_receiver=self.kt_receiver,
timestamp=message.sendAt,
).insert()
await self._send_delivery_receipt(event_id)
else:
event_ids = []
handler = self._message_handler_type_map.get(message.type)
if not handler:
self.log.warning(f"No handler for message type {message.type}, falling back to text")
handler = Portal._handle_remote_text
event_ids += await handler(self, source, intent, message)
if not event_ids:
self.log.warning(f"Unhandled KakaoTalk message {message.logId}")
return
event_ids = [event_id for event_id in event_ids if event_id]
self.log.debug(f"Handled KakaoTalk message {message.logId} -> {event_ids}")
await DBMessage.bulk_create(
ktid=message.logId,
kt_chat=self.ktid,
kt_receiver=self.kt_receiver,
mx_room=self.mxid,
timestamp=message.sendAt,
event_ids=event_ids,
)
await self._send_delivery_receipt(event_ids[-1])
async def _handle_remote_text(self, source: u.User, intent: IntentAPI, message: Chatlog) -> list[EventID]:
# TODO Handle mentions properly
content = await kakaotalk_to_matrix(message.text)
# TODO Replies
return [await self._send_message(intent, content, timestamp=message.sendAt)]
async def _handle_remote_photo(self, source: u.User, intent: IntentAPI, message: Chatlog) -> list[EventID]:
assert message.attachment
assert message.attachment.url or message.attachment.urls
url = message.attachment.url or message.attachment.urls[0]
assert isinstance(message.attachment, PhotoAttachment)
info = ImageInfo(
width=message.attachment.w,
height=message.attachment.h,
)
# TODO Animated images?
mxc, additional_info, decryption_info = await self._reupload_remote_file(
url,
source,
intent,
encrypt=self.encrypted,
find_size=False,
)
info.size = additional_info.size
info.mimetype = additional_info.mimetype
content = MediaMessageEventContent(
url=mxc, file=decryption_info, msgtype=MessageType.IMAGE, body=message.text, info=info
)
if not content:
return []
# TODO Replies
return [await self._send_message(intent, content, timestamp=message.sendAt)]
# TODO Many more remote handlers

View File

@ -606,7 +606,6 @@ class User(DBUser, BaseUser):
await portal.backfill_lock.wait(evt.logId)
if not puppet.name:
portal.schedule_resync(self, puppet)
# TODO reply_to
await portal.handle_remote_message(self, puppet, evt)
# TODO Many more handlers