diff --git a/my_project_name/bot_commands.py b/my_project_name/bot_commands.py index 1dfa608..d9a40db 100644 --- a/my_project_name/bot_commands.py +++ b/my_project_name/bot_commands.py @@ -1,4 +1,4 @@ -from my_project_name.chat_functions import send_text_to_room +from my_project_name.chat_functions import react_to_event, send_text_to_room class Command(object): @@ -30,6 +30,8 @@ class Command(object): """Process the command""" if self.command.startswith("echo"): await self._echo() + elif self.command.startswith("react"): + await self._react() elif self.command.startswith("help"): await self._show_help() else: @@ -40,6 +42,20 @@ class Command(object): response = " ".join(self.args) await send_text_to_room(self.client, self.room.room_id, response) + async def _react(self): + """Make the bot react to the command message""" + # React with a start emoji + reaction = "⭐" + await react_to_event( + self.client, self.room.room_id, self.event.event_id, reaction + ) + + # React with some generic text + reaction = "Some text" + await react_to_event( + self.client, self.room.room_id, self.event.event_id, reaction + ) + async def _show_help(self): """Show the help text""" if not self.args: diff --git a/my_project_name/callbacks.py b/my_project_name/callbacks.py index 7152e64..a7443ec 100644 --- a/my_project_name/callbacks.py +++ b/my_project_name/callbacks.py @@ -1,8 +1,9 @@ import logging -from nio import JoinError +from nio import JoinError, MatrixRoom, RoomGetEventError, UnknownEvent from my_project_name.bot_commands import Command +from my_project_name.chat_functions import make_pill, send_text_to_room from my_project_name.message_responses import Message logger = logging.getLogger(__name__) @@ -86,3 +87,66 @@ class Callbacks(object): # Successfully joined room logger.info(f"Joined {room.room_id}") + + async def _reaction( + self, room: MatrixRoom, event: UnknownEvent, reacted_to_id: str + ): + """A reaction was sent to one of our messages. Let's send a reply acknowledging it. + + Args: + room: The room the reaction was sent in. + + event: The reaction event. + + reacted_to_id: The event ID that the reaction points to. + """ + logger.debug(f"Got reaction to {room.room_id} from {event.sender}.") + + # Get the original event that was reacted to + event_response = await self.client.room_get_event(room.room_id, reacted_to_id) + if isinstance(event_response, RoomGetEventError): + logger.warning( + "Error getting event that was reacted to (%s)", reacted_to_id + ) + return + reacted_to_event = event_response.event + + # Only acknowledge reactions to events that we sent + if reacted_to_event.sender != self.config.user_id: + return + + # Send a message acknowledging the reaction + reaction_sender_pill = make_pill(event.sender) + reaction_content = ( + event.source.get("content", {}).get("m.relates_to", {}).get("key") + ) + message = ( + f"{reaction_sender_pill} reacted to this event with `{reaction_content}`!" + ) + await send_text_to_room( + self.client, + room.room_id, + message, + reply_to_event_id=reacted_to_id, + ) + + async def unknown(self, room: MatrixRoom, event: UnknownEvent): + """Callback for when an event with a type that is unknown to matrix-nio is received. + Currently this is used for reaction events, which are not specced. + + Args: + room: The room the reaction was sent in. + + event: The reaction event. + """ + if event.type == "m.reaction": + # Get the ID of the event this was a reaction to + relation_dict = event.source.get("content", {}).get("m.relates_to", {}) + + reacted_to = relation_dict.get("event_id") + if reacted_to and relation_dict.get("rel_type") == "m.annotation": + await self._reaction(room, event, reacted_to) + + logger.debug( + f"Got unknown event with type to {event.type} from {event.sender} in {room.room_id}." + ) diff --git a/my_project_name/chat_functions.py b/my_project_name/chat_functions.py index 7e480a8..87038fb 100644 --- a/my_project_name/chat_functions.py +++ b/my_project_name/chat_functions.py @@ -1,28 +1,37 @@ import logging +from typing import Optional, Union from markdown import markdown -from nio import SendRetryError +from nio import AsyncClient, ErrorResponse, Response, SendRetryError logger = logging.getLogger(__name__) async def send_text_to_room( - client, room_id, message, notice=True, markdown_convert=True + client: AsyncClient, + room_id: str, + message: str, + notice: bool = True, + markdown_convert: bool = True, + reply_to_event_id: Optional[str] = None, ): - """Send text to a matrix room + """Send text to a matrix room. Args: - client (nio.AsyncClient): The client to communicate to matrix with + client: The client to communicate to matrix with. - room_id (str): The ID of the room to send the message to + room_id: The ID of the room to send the message to. - message (str): The message content + message: The message content. - notice (bool): Whether the message should be sent with an "m.notice" message type - (will not ping users) + notice: Whether the message should be sent with an "m.notice" message type + (will not ping users). - markdown_convert (bool): Whether to convert the message content to markdown. + markdown_convert: Whether to convert the message content to markdown. Defaults to true. + + reply_to_event_id: Whether this message is a reply to another event. The event + ID this is message is a reply to. """ # Determine whether to ping room members or not msgtype = "m.notice" if notice else "m.text" @@ -36,9 +45,74 @@ async def send_text_to_room( if markdown_convert: content["formatted_body"] = markdown(message) + if reply_to_event_id: + content["m.relates_to"] = {"m.in_reply_to": {"event_id": reply_to_event_id}} + try: await client.room_send( - room_id, "m.room.message", content, ignore_unverified_devices=True, + room_id, + "m.room.message", + content, + ignore_unverified_devices=True, ) except SendRetryError: logger.exception(f"Unable to send message response to {room_id}") + + +def make_pill(user_id: str, displayname: str = None) -> str: + """Convert a user ID (and optionally a display name) to a formatted user 'pill' + + Args: + user_id: The MXID of the user. + + displayname: An optional displayname. Clients like Element will figure out the + correct display name no matter what, but other clients may not. If not + provided, the MXID will be used instead. + + Returns: + The formatted user pill. + """ + if not displayname: + # Use the user ID as the displayname if not provided + displayname = user_id + + return f'{displayname}' + + +async def react_to_event( + client: AsyncClient, + room_id: str, + event_id: str, + reaction_text: str, +) -> Union[Response, ErrorResponse]: + """Reacts to a given event in a room with the given reaction text + + Args: + client: The client to communicate to matrix with. + + room_id: The ID of the room to send the message to. + + event_id: The ID of the event to react to. + + reaction_text: The string to react with. Can also be (one or more) emoji characters. + + Returns: + A nio.Response or nio.ErrorResponse if an error occurred. + + Raises: + SendRetryError: If the reaction was unable to be sent. + """ + content = { + "m.relates_to": { + "rel_type": "m.annotation", + "event_id": event_id, + "key": reaction_text, + } + } + + return await client.room_send( + room_id, + "m.reaction", + content, + ignore_unverified_devices=True, + ) diff --git a/my_project_name/main.py b/my_project_name/main.py index f3173ef..732340b 100644 --- a/my_project_name/main.py +++ b/my_project_name/main.py @@ -12,6 +12,7 @@ from nio import ( LocalProtocolError, LoginError, RoomMessageText, + UnknownEvent, ) from my_project_name.callbacks import Callbacks @@ -59,6 +60,7 @@ async def main(): callbacks = Callbacks(client, store, config) client.add_event_callback(callbacks.message, (RoomMessageText,)) client.add_event_callback(callbacks.invite, (InviteMemberEvent,)) + client.add_event_callback(callbacks.unknown, (UnknownEvent,)) # Keep trying to reconnect on failure (with some time in-between) while True: @@ -74,7 +76,8 @@ async def main(): # Try to login with the configured username/password try: login_response = await client.login( - password=config.user_password, device_name=config.device_name, + password=config.user_password, + device_name=config.device_name, ) # Check if login failed