Module trappedbot.chat_functions
Chat functions
This file implements utility functions for - sending text messages - sending images - sending of other files like audio, video, text, PDFs, .doc, etc.
Expand source code
#!/usr/bin/env python3
"""Chat functions
This file implements utility functions for
- sending text messages
- sending images
- sending of other files like audio, video, text, PDFs, .doc, etc.
"""
import html
import os
import traceback
import typing
import aiofiles.os
import magic
from markdown import markdown
from nio import SendRetryError, UploadResponse
from PIL import Image
from nio.client.async_client import AsyncClient
from nio.events.room_events import RoomMessageText
from nio.rooms import MatrixRoom
from trappedbot.applogger import LOGGER
from trappedbot.mxutil import MessageFormat
def reply_fallback_html_from_message(
room_id: str, event_id: str, sender_mxid: str, sender_displayname: str, content: str
) -> str:
"""Generate reply fallback from a message"""
fallback = (
f"<mx-reply><blockquote>"
f"<a href='https://matrix.to/#/{room_id}/{event_id}'>In reply to</a> "
f"<a href='https://matrix.to/#/{sender_mxid}'>{sender_displayname}</a><br/>"
f"{content}"
f"</blockquote></mx-reply>"
)
return fallback
def reply_fallback_text_from_message(
sender_displayname: str,
content: str,
) -> str:
"""Generate a reply fallback from a text message"""
fallback = ""
for idx, line in enumerate(content):
if idx == 0:
fallback += f"> <{sender_displayname}> {line}"
else:
fallback += f"> {line}"
return fallback
async def send_text_to_room(
client: AsyncClient,
room_id: str,
message: str,
notice: bool = True,
format: typing.Optional[MessageFormat] = MessageFormat.NATURAL,
split: typing.Optional[str] = None,
replyto: typing.Optional[RoomMessageText] = None,
replyto_room: typing.Optional[MatrixRoom] = None,
):
"""Send text to a matrix room.
Arguments:
---------
client: The client to communicate with Matrix
room_id: The ID of the room to send the message to
message: The message content
notice: Whether the message should be sent with an
"m.notice" message type (will not ping users)
format: The format for the message
split: if set, split the message into multiple messages wherever
the string specified in split occurs
Defaults to None
"""
LOGGER.debug(f"send_text_to_room {room_id} {message}")
messages = []
if split:
for paragraph in message.split(split):
# strip again to get get rid of leading/trailing newlines and
# whitespaces left over from previous split
if paragraph.strip() != "":
messages.append(paragraph)
else:
messages.append(message)
for message in messages:
# Determine whether to ping room members or not
msgtype = "m.notice" if notice else "m.text"
content: typing.Dict[str, typing.Any] = {
"msgtype": msgtype,
"body": message,
}
if format == MessageFormat.FORMATTED:
content["format"] = "org.matrix.custom.html"
content["formatted_body"] = message
elif format == MessageFormat.MARKDOWN:
content["format"] = "org.matrix.custom.html"
content["formatted_body"] = markdown(message)
elif format == MessageFormat.CODE:
content["format"] = "org.matrix.custom.html"
content["formatted_body"] = "<pre><code>" + message + "\n</code></pre>\n"
# next line: work-around for Element on Android
content["body"] = "```\n" + message + "\n```" # to format it as code
else:
pass
if (replyto and not replyto_room) or (not replyto and replyto_room):
LOGGER.error(
f"send_text_to_room was passed only one of replyto and replyto_room, NOT sending message as reply"
)
elif replyto and replyto_room:
LOGGER.debug(f"send_text_to_room replying to message {replyto.event_id}")
# If there was no HTML-formatted body in the original message,
# build one from the unformatted body.
if (
not content.get("formatted_body")
or content.get("format") != "org.matrix.custom.html"
):
content["format"] = "org.matrix.custom.html"
content["formatted_body"] = html.escape(content["body"])
content["body"] = (
reply_fallback_text_from_message(replyto.sender, replyto.body)
+ content["body"]
)
content["formatted_body"] = (
reply_fallback_html_from_message(
replyto_room.canonical_alias or replyto_room.room_id,
replyto.event_id,
replyto.sender,
replyto_room.user_name(replyto.sender) or replyto.sender,
replyto.body,
)
+ content["formatted_body"]
)
content["m.relates_to"] = {
"m.in_reply_to": {
"event_id": replyto.event_id,
}
}
try:
await client.room_send(
room_id,
"m.room.message",
content,
ignore_unverified_devices=True,
)
except SendRetryError:
LOGGER.exception(f"Unable to send message response to {room_id}")
async def send_image_to_room(client, room_id, image):
"""Send image to single room.
Arguments:
---------
client (nio.AsyncClient): The client to communicate with Matrix
room_id (str): The ID of the room to send the message to
image (str): file name/path of image
"""
LOGGER.debug(f"send_image_to_room {room_id} {image}")
await send_image_to_rooms(client, [room_id], image)
async def send_image_to_rooms(client, rooms, image):
"""Send image to multiple rooms.
Arguments:
---------
client (nio.AsyncClient): The client to communicate with Matrix
rooms (list): list of room_id-s
image (str): file name/path of image
This is a working example for a JPG image.
"content": {
"body": "someimage.jpg",
"info": {
"size": 5420,
"mimetype": "image/jpeg",
"thumbnail_info": {
"w": 100,
"h": 100,
"mimetype": "image/jpeg",
"size": 2106
},
"w": 100,
"h": 100,
"thumbnail_url": "mxc://example.com/SomeStrangeThumbnailUriKey"
},
"msgtype": "m.image",
"url": "mxc://example.com/SomeStrangeUriKey"
}
"""
if not rooms:
LOGGER.info(
"No rooms are given. This should not happen. "
"This file is being droppend and NOT sent."
)
return
if not os.path.isfile(image):
LOGGER.debug(
f"File {image} is not a file. Doesn't exist or "
"is a directory."
"This file is being droppend and NOT sent."
)
return
mime_type = magic.from_file(image, mime=True) # e.g. "image/jpeg"
if not mime_type.startswith("image/"):
LOGGER.debug("Drop message because file does not have an image mime type.")
return
im = Image.open(image)
(width, height) = im.size # im.size returns (width,height) tuple
# first do an upload of image, then send URI of upload to room
file_stat = await aiofiles.os.stat(image)
async with aiofiles.open(image, "r+b") as f:
resp, maybe_keys = await client.upload(
f,
content_type=mime_type, # image/jpeg
filename=os.path.basename(image),
filesize=file_stat.st_size,
)
if isinstance(resp, UploadResponse):
LOGGER.debug("Image was uploaded successfully to server. ")
else:
LOGGER.debug(f"Failed to upload image. Failure response: {resp}")
content = {
"body": os.path.basename(image), # descriptive title
"info": {
"size": file_stat.st_size,
"mimetype": mime_type,
"thumbnail_info": None, # TODO
"w": width, # width in pixel
"h": height, # height in pixel
"thumbnail_url": None, # TODO
},
"msgtype": "m.image",
"url": resp.content_uri,
}
try:
for room_id in rooms:
await client.room_send(
room_id, message_type="m.room.message", content=content
)
LOGGER.debug(f'This image was sent: "{image}" to room "{room_id}".')
except Exception:
LOGGER.debug(
f"Image send of file {image} failed. " "Sorry. Here is the traceback."
)
LOGGER.debug(traceback.format_exc())
async def send_file_to_room(client, room_id, file):
"""Send file to single room.
Arguments:
---------
client (nio.AsyncClient): The client to communicate with Matrix
room_id (str): The ID of the room to send the file to
file (str): file name/path of file
"""
LOGGER.debug(f"send_file_to_room {room_id} {file}")
await send_file_to_rooms(client, [room_id], file)
async def send_file_to_rooms(client, rooms, file):
"""Send file to multiple rooms.
Upload file to server and then send link to rooms.
Works and tested for .pdf, .txt, .ogg, .wav.
All these file types are treated the same.
Do not use this function for images.
Use the send_image_to_room() function for images.
Matrix has types for audio and video (and image and file).
See: "msgtype" == "m.image", m.audio, m.video, m.file
Arguments:
---------
client (nio.AsyncClient): The client to communicate with Matrix
room_id (str): The ID of the room to send the file to
rooms (list): list of room_id-s
file (str): file name/path of file
This is a working example for a PDF file.
It can be viewed or downloaded from:
https://matrix.example.com/_matrix/media/r0/download/
example.com/SomeStrangeUriKey # noqa
{
"type": "m.room.message",
"sender": "@someuser:example.com",
"content": {
"body": "example.pdf",
"info": {
"size": 6301234,
"mimetype": "application/pdf"
},
"msgtype": "m.file",
"url": "mxc://example.com/SomeStrangeUriKey"
},
"origin_server_ts": 1595100000000,
"unsigned": {
"age": 1000,
"transaction_id": "SomeTxId01234567"
},
"event_id": "$SomeEventId01234567789Abcdef012345678",
"room_id": "!SomeRoomId:example.com"
}
"""
if not rooms:
LOGGER.info(
"No rooms are given. This should not happen. "
"This file is being droppend and NOT sent."
)
return
if not os.path.isfile(file):
LOGGER.debug(
f"File {file} is not a file. Doesn't exist or "
"is a directory."
"This file is being droppend and NOT sent."
)
return
# # restrict to "txt", "pdf", "mp3", "ogg", "wav", ...
# if not re.match("^.pdf$|^.txt$|^.doc$|^.xls$|^.mobi$|^.mp3$",
# os.path.splitext(file)[1].lower()):
# LOGGER.debug(f"File {file} is not a permitted file type. Should be "
# ".pdf, .txt, .doc, .xls, .mobi or .mp3 ... "
# f"[{os.path.splitext(file)[1].lower()}]"
# "This file is being droppend and NOT sent.")
# return
# 'application/pdf' "plain/text" "audio/ogg"
mime_type = magic.from_file(file, mime=True)
# if ((not mime_type.startswith("application/")) and
# (not mime_type.startswith("plain/")) and
# (not mime_type.startswith("audio/"))):
# LOGGER.debug(f"File {file} does not have an accepted mime type. "
# "Should be something like application/pdf. "
# f"Found mime type {mime_type}. "
# "This file is being droppend and NOT sent.")
# return
# first do an upload of file, see upload() in documentation
# http://matrix-nio.readthedocs.io/en/latest/nio.html#nio.AsyncClient.upload
# then send URI of upload to room
file_stat = await aiofiles.os.stat(file)
async with aiofiles.open(file, "r+b") as f:
resp, maybe_keys = await client.upload(
f,
content_type=mime_type, # application/pdf
filename=os.path.basename(file),
filesize=file_stat.st_size,
)
if isinstance(resp, UploadResponse):
LOGGER.debug(f"File was uploaded successfully to server. Response is: {resp}")
else:
LOGGER.info(
"Bot failed to upload. "
"Please retry. This could be temporary issue on your server. "
"Sorry."
)
LOGGER.info(
f'file="{file}"; mime_type="{mime_type}"; '
f'filessize="{file_stat.st_size}"'
f"Failed to upload: {resp}"
)
# determine msg_type:
if mime_type.startswith("audio/"):
msg_type = "m.audio"
elif mime_type.startswith("video/"):
msg_type = "m.video"
else:
msg_type = "m.file"
content = {
"body": os.path.basename(file), # descriptive title
"info": {
"size": file_stat.st_size,
"mimetype": mime_type,
}, # noqa
"msgtype": msg_type,
"url": resp.content_uri,
}
try:
for room_id in rooms:
await client.room_send(
room_id, message_type="m.room.message", content=content
)
LOGGER.debug(f'This file was sent: "{file}" to room "{room_id}".')
except Exception:
LOGGER.debug(f"File send of file {file} failed. Sorry. Here is the traceback.")
LOGGER.debug(traceback.format_exc())
Functions
def reply_fallback_html_from_message(room_id: str, event_id: str, sender_mxid: str, sender_displayname: str, content: str) ‑> str
-
Generate reply fallback from a message
Expand source code
def reply_fallback_html_from_message( room_id: str, event_id: str, sender_mxid: str, sender_displayname: str, content: str ) -> str: """Generate reply fallback from a message""" fallback = ( f"<mx-reply><blockquote>" f"<a href='https://matrix.to/#/{room_id}/{event_id}'>In reply to</a> " f"<a href='https://matrix.to/#/{sender_mxid}'>{sender_displayname}</a><br/>" f"{content}" f"</blockquote></mx-reply>" ) return fallback
def reply_fallback_text_from_message(sender_displayname: str, content: str) ‑> str
-
Generate a reply fallback from a text message
Expand source code
def reply_fallback_text_from_message( sender_displayname: str, content: str, ) -> str: """Generate a reply fallback from a text message""" fallback = "" for idx, line in enumerate(content): if idx == 0: fallback += f"> <{sender_displayname}> {line}" else: fallback += f"> {line}" return fallback
async def send_file_to_room(client, room_id, file)
-
Send file to single room.
Arguments:
client (nio.AsyncClient): The client to communicate with Matrix room_id (str): The ID of the room to send the file to file (str): file name/path of file
Expand source code
async def send_file_to_room(client, room_id, file): """Send file to single room. Arguments: --------- client (nio.AsyncClient): The client to communicate with Matrix room_id (str): The ID of the room to send the file to file (str): file name/path of file """ LOGGER.debug(f"send_file_to_room {room_id} {file}") await send_file_to_rooms(client, [room_id], file)
async def send_file_to_rooms(client, rooms, file)
-
Send file to multiple rooms.
Upload file to server and then send link to rooms. Works and tested for .pdf, .txt, .ogg, .wav. All these file types are treated the same.
Do not use this function for images. Use the send_image_to_room() function for images.
Matrix has types for audio and video (and image and file). See: "msgtype" == "m.image", m.audio, m.video, m.file
Arguments:
client (nio.AsyncClient): The client to communicate with Matrix room_id (str): The ID of the room to send the file to rooms (list): list of room_id-s file (str): file name/path of file
This is a working example for a PDF file. It can be viewed or downloaded from: https://matrix.example.com/_matrix/media/r0/download/ example.com/SomeStrangeUriKey # noqa { "type": "m.room.message", "sender": "@someuser:example.com", "content": { "body": "example.pdf", "info": { "size": 6301234, "mimetype": "application/pdf" }, "msgtype": "m.file", "url": "mxc://example.com/SomeStrangeUriKey" }, "origin_server_ts": 1595100000000, "unsigned": { "age": 1000, "transaction_id": "SomeTxId01234567" }, "event_id": "$SomeEventId01234567789Abcdef012345678", "room_id": "!SomeRoomId:example.com" }
Expand source code
async def send_file_to_rooms(client, rooms, file): """Send file to multiple rooms. Upload file to server and then send link to rooms. Works and tested for .pdf, .txt, .ogg, .wav. All these file types are treated the same. Do not use this function for images. Use the send_image_to_room() function for images. Matrix has types for audio and video (and image and file). See: "msgtype" == "m.image", m.audio, m.video, m.file Arguments: --------- client (nio.AsyncClient): The client to communicate with Matrix room_id (str): The ID of the room to send the file to rooms (list): list of room_id-s file (str): file name/path of file This is a working example for a PDF file. It can be viewed or downloaded from: https://matrix.example.com/_matrix/media/r0/download/ example.com/SomeStrangeUriKey # noqa { "type": "m.room.message", "sender": "@someuser:example.com", "content": { "body": "example.pdf", "info": { "size": 6301234, "mimetype": "application/pdf" }, "msgtype": "m.file", "url": "mxc://example.com/SomeStrangeUriKey" }, "origin_server_ts": 1595100000000, "unsigned": { "age": 1000, "transaction_id": "SomeTxId01234567" }, "event_id": "$SomeEventId01234567789Abcdef012345678", "room_id": "!SomeRoomId:example.com" } """ if not rooms: LOGGER.info( "No rooms are given. This should not happen. " "This file is being droppend and NOT sent." ) return if not os.path.isfile(file): LOGGER.debug( f"File {file} is not a file. Doesn't exist or " "is a directory." "This file is being droppend and NOT sent." ) return # # restrict to "txt", "pdf", "mp3", "ogg", "wav", ... # if not re.match("^.pdf$|^.txt$|^.doc$|^.xls$|^.mobi$|^.mp3$", # os.path.splitext(file)[1].lower()): # LOGGER.debug(f"File {file} is not a permitted file type. Should be " # ".pdf, .txt, .doc, .xls, .mobi or .mp3 ... " # f"[{os.path.splitext(file)[1].lower()}]" # "This file is being droppend and NOT sent.") # return # 'application/pdf' "plain/text" "audio/ogg" mime_type = magic.from_file(file, mime=True) # if ((not mime_type.startswith("application/")) and # (not mime_type.startswith("plain/")) and # (not mime_type.startswith("audio/"))): # LOGGER.debug(f"File {file} does not have an accepted mime type. " # "Should be something like application/pdf. " # f"Found mime type {mime_type}. " # "This file is being droppend and NOT sent.") # return # first do an upload of file, see upload() in documentation # http://matrix-nio.readthedocs.io/en/latest/nio.html#nio.AsyncClient.upload # then send URI of upload to room file_stat = await aiofiles.os.stat(file) async with aiofiles.open(file, "r+b") as f: resp, maybe_keys = await client.upload( f, content_type=mime_type, # application/pdf filename=os.path.basename(file), filesize=file_stat.st_size, ) if isinstance(resp, UploadResponse): LOGGER.debug(f"File was uploaded successfully to server. Response is: {resp}") else: LOGGER.info( "Bot failed to upload. " "Please retry. This could be temporary issue on your server. " "Sorry." ) LOGGER.info( f'file="{file}"; mime_type="{mime_type}"; ' f'filessize="{file_stat.st_size}"' f"Failed to upload: {resp}" ) # determine msg_type: if mime_type.startswith("audio/"): msg_type = "m.audio" elif mime_type.startswith("video/"): msg_type = "m.video" else: msg_type = "m.file" content = { "body": os.path.basename(file), # descriptive title "info": { "size": file_stat.st_size, "mimetype": mime_type, }, # noqa "msgtype": msg_type, "url": resp.content_uri, } try: for room_id in rooms: await client.room_send( room_id, message_type="m.room.message", content=content ) LOGGER.debug(f'This file was sent: "{file}" to room "{room_id}".') except Exception: LOGGER.debug(f"File send of file {file} failed. Sorry. Here is the traceback.") LOGGER.debug(traceback.format_exc())
async def send_image_to_room(client, room_id, image)
-
Send image to single room.
Arguments:
client (nio.AsyncClient): The client to communicate with Matrix room_id (str): The ID of the room to send the message to image (str): file name/path of image
Expand source code
async def send_image_to_room(client, room_id, image): """Send image to single room. Arguments: --------- client (nio.AsyncClient): The client to communicate with Matrix room_id (str): The ID of the room to send the message to image (str): file name/path of image """ LOGGER.debug(f"send_image_to_room {room_id} {image}") await send_image_to_rooms(client, [room_id], image)
async def send_image_to_rooms(client, rooms, image)
-
Send image to multiple rooms.
Arguments:
client (nio.AsyncClient): The client to communicate with Matrix rooms (list): list of room_id-s image (str): file name/path of image
This is a working example for a JPG image. "content": { "body": "someimage.jpg", "info": { "size": 5420, "mimetype": "image/jpeg", "thumbnail_info": { "w": 100, "h": 100, "mimetype": "image/jpeg", "size": 2106 }, "w": 100, "h": 100, "thumbnail_url": "mxc://example.com/SomeStrangeThumbnailUriKey" }, "msgtype": "m.image", "url": "mxc://example.com/SomeStrangeUriKey" }
Expand source code
async def send_image_to_rooms(client, rooms, image): """Send image to multiple rooms. Arguments: --------- client (nio.AsyncClient): The client to communicate with Matrix rooms (list): list of room_id-s image (str): file name/path of image This is a working example for a JPG image. "content": { "body": "someimage.jpg", "info": { "size": 5420, "mimetype": "image/jpeg", "thumbnail_info": { "w": 100, "h": 100, "mimetype": "image/jpeg", "size": 2106 }, "w": 100, "h": 100, "thumbnail_url": "mxc://example.com/SomeStrangeThumbnailUriKey" }, "msgtype": "m.image", "url": "mxc://example.com/SomeStrangeUriKey" } """ if not rooms: LOGGER.info( "No rooms are given. This should not happen. " "This file is being droppend and NOT sent." ) return if not os.path.isfile(image): LOGGER.debug( f"File {image} is not a file. Doesn't exist or " "is a directory." "This file is being droppend and NOT sent." ) return mime_type = magic.from_file(image, mime=True) # e.g. "image/jpeg" if not mime_type.startswith("image/"): LOGGER.debug("Drop message because file does not have an image mime type.") return im = Image.open(image) (width, height) = im.size # im.size returns (width,height) tuple # first do an upload of image, then send URI of upload to room file_stat = await aiofiles.os.stat(image) async with aiofiles.open(image, "r+b") as f: resp, maybe_keys = await client.upload( f, content_type=mime_type, # image/jpeg filename=os.path.basename(image), filesize=file_stat.st_size, ) if isinstance(resp, UploadResponse): LOGGER.debug("Image was uploaded successfully to server. ") else: LOGGER.debug(f"Failed to upload image. Failure response: {resp}") content = { "body": os.path.basename(image), # descriptive title "info": { "size": file_stat.st_size, "mimetype": mime_type, "thumbnail_info": None, # TODO "w": width, # width in pixel "h": height, # height in pixel "thumbnail_url": None, # TODO }, "msgtype": "m.image", "url": resp.content_uri, } try: for room_id in rooms: await client.room_send( room_id, message_type="m.room.message", content=content ) LOGGER.debug(f'This image was sent: "{image}" to room "{room_id}".') except Exception: LOGGER.debug( f"Image send of file {image} failed. " "Sorry. Here is the traceback." ) LOGGER.debug(traceback.format_exc())
async def send_text_to_room(client: nio.client.async_client.AsyncClient, room_id: str, message: str, notice: bool = True, format: Optional[MessageFormat] = MessageFormat.NATURAL, split: Optional[str] = None, replyto: Optional[nio.events.room_events.RoomMessageText] = None, replyto_room: Optional[nio.rooms.MatrixRoom] = None)
-
Send text to a matrix room.
Arguments:
client: The client to communicate with Matrix room_id: The ID of the room to send the message to message: The message content notice: Whether the message should be sent with an "m.notice" message type (will not ping users) format: The format for the message split: if set, split the message into multiple messages wherever the string specified in split occurs Defaults to None
Expand source code
async def send_text_to_room( client: AsyncClient, room_id: str, message: str, notice: bool = True, format: typing.Optional[MessageFormat] = MessageFormat.NATURAL, split: typing.Optional[str] = None, replyto: typing.Optional[RoomMessageText] = None, replyto_room: typing.Optional[MatrixRoom] = None, ): """Send text to a matrix room. Arguments: --------- client: The client to communicate with Matrix room_id: The ID of the room to send the message to message: The message content notice: Whether the message should be sent with an "m.notice" message type (will not ping users) format: The format for the message split: if set, split the message into multiple messages wherever the string specified in split occurs Defaults to None """ LOGGER.debug(f"send_text_to_room {room_id} {message}") messages = [] if split: for paragraph in message.split(split): # strip again to get get rid of leading/trailing newlines and # whitespaces left over from previous split if paragraph.strip() != "": messages.append(paragraph) else: messages.append(message) for message in messages: # Determine whether to ping room members or not msgtype = "m.notice" if notice else "m.text" content: typing.Dict[str, typing.Any] = { "msgtype": msgtype, "body": message, } if format == MessageFormat.FORMATTED: content["format"] = "org.matrix.custom.html" content["formatted_body"] = message elif format == MessageFormat.MARKDOWN: content["format"] = "org.matrix.custom.html" content["formatted_body"] = markdown(message) elif format == MessageFormat.CODE: content["format"] = "org.matrix.custom.html" content["formatted_body"] = "<pre><code>" + message + "\n</code></pre>\n" # next line: work-around for Element on Android content["body"] = "```\n" + message + "\n```" # to format it as code else: pass if (replyto and not replyto_room) or (not replyto and replyto_room): LOGGER.error( f"send_text_to_room was passed only one of replyto and replyto_room, NOT sending message as reply" ) elif replyto and replyto_room: LOGGER.debug(f"send_text_to_room replying to message {replyto.event_id}") # If there was no HTML-formatted body in the original message, # build one from the unformatted body. if ( not content.get("formatted_body") or content.get("format") != "org.matrix.custom.html" ): content["format"] = "org.matrix.custom.html" content["formatted_body"] = html.escape(content["body"]) content["body"] = ( reply_fallback_text_from_message(replyto.sender, replyto.body) + content["body"] ) content["formatted_body"] = ( reply_fallback_html_from_message( replyto_room.canonical_alias or replyto_room.room_id, replyto.event_id, replyto.sender, replyto_room.user_name(replyto.sender) or replyto.sender, replyto.body, ) + content["formatted_body"] ) content["m.relates_to"] = { "m.in_reply_to": { "event_id": replyto.event_id, } } try: await client.room_send( room_id, "m.room.message", content, ignore_unverified_devices=True, ) except SendRetryError: LOGGER.exception(f"Unable to send message response to {room_id}")