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

View File

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

View File

@ -24,60 +24,8 @@ from ..kt.types.chat.attachment.mention import MentionStruct
from .. import puppet as pu, user as u 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"} MENTION_REGEX = re.compile(r"@(\d+)\u2063(.+?)\u2063")
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
async def kakaotalk_to_matrix(msg: str | None, mentions: list[MentionStruct] | None) -> TextMessageEventContent: 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.appservice import IntentAPI
from mautrix.types import Format, MessageEventContent, RelationType, RoomID, UserID from mautrix.types import Format, MessageEventContent, RelationType, RoomID, UserID
from mautrix.util import utf16_surrogate
from mautrix.util.formatter import ( from mautrix.util.formatter import (
EntityString, EntityString,
EntityType, EntityType,
@ -46,6 +47,7 @@ class SendParams(NamedTuple):
class KakaoTalkFormatString(EntityString[SimpleEntity, EntityType], MarkdownString): class KakaoTalkFormatString(EntityString[SimpleEntity, EntityType], MarkdownString):
def format(self, entity_type: EntityType, **kwargs) -> KakaoTalkFormatString: def format(self, entity_type: EntityType, **kwargs) -> KakaoTalkFormatString:
prefix = suffix = ""
if entity_type == EntityType.USER_MENTION: if entity_type == EntityType.USER_MENTION:
self.entities.append( self.entities.append(
SimpleEntity( SimpleEntity(
@ -55,7 +57,32 @@ class KakaoTalkFormatString(EntityString[SimpleEntity, EntityType], MarkdownStri
extra_info={"user_id": kwargs["user_id"]}, 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 return self
@ -126,8 +153,8 @@ async def matrix_to_kakaotalk(
else: else:
reply_to = None reply_to = None
if content.get("format", None) == Format.HTML and content["formatted_body"] and content.msgtype.is_text: if content.get("format", None) == Format.HTML and content["formatted_body"] and content.msgtype.is_text:
parsed = await ToKakaoTalkParser().parse(content["formatted_body"]) parsed = await ToKakaoTalkParser().parse(utf16_surrogate.add(content["formatted_body"]))
text = parsed.text text = utf16_surrogate.remove(parsed.text)
mentions_by_user: dict[Long, MentionStruct] = {} mentions_by_user: dict[Long, MentionStruct] = {}
# Make sure to not create remote mentions for any remote user not in the room # Make sure to not create remote mentions for any remote user not in the room
if parsed.entities: if parsed.entities:

View File

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

View File

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

View File

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

View File

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

View File

@ -157,7 +157,8 @@ class MatrixHandler(BaseMatrixHandler):
if not user.is_connected: if not user.is_connected:
return return
message = await DBMessage.get_by_mxid(event_id, portal.mxid) 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( async def handle_ephemeral_event(
self, evt: ReceiptEvent | Event self, evt: ReceiptEvent | Event

View File

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

View File

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

View File

@ -412,7 +412,8 @@ class User(DBUser, BaseUser):
try: try:
login_result = await self.client.connect() login_result = await self.client.connect()
await self.on_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 return True
except AuthenticationRequired as e: except AuthenticationRequired as e:
await self.send_bridge_notice( await self.send_bridge_notice(

View File

@ -634,12 +634,13 @@ export default class PeerClient {
*/ */
sendChat = async (req) => { sendChat = async (req) => {
const talkChannel = await this.#getUserChannel(req.mxid, req.channel_props) const talkChannel = await this.#getUserChannel(req.mxid, req.channel_props)
const res = await talkChannel.sendChat({
return await talkChannel.sendChat({
text: req.text, text: req.text,
type: !!req.reply_to ? KnownChatType.REPLY : KnownChatType.TEXT, type: !!req.reply_to ? KnownChatType.REPLY : KnownChatType.TEXT,
attachment: !req.mentions ? req.reply_to : {...req.reply_to, mentions: req.mentions}, 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) => { sendMedia = async (req) => {
const talkChannel = await this.#getUserChannel(req.mxid, req.channel_props) const talkChannel = await this.#getUserChannel(req.mxid, req.channel_props)
const res = await talkChannel.sendMedia(req.type, {
return await talkChannel.sendMedia(req.type, {
data: Uint8Array.from(req.data), data: Uint8Array.from(req.data),
name: req.name, name: req.name,
width: req.width, width: req.width,
height: req.height, height: req.height,
ext: req.ext, 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_chat: this.sendChat,
send_media: this.sendMedia, send_media: this.sendMedia,
delete_chat: this.deleteChat, delete_chat: this.deleteChat,
mark_read: this.markRead,
}[req.command] || this.handleUnknownCommand }[req.command] || this.handleUnknownCommand
} }
const resp = { id: req.id } const resp = { id: req.id }