Add example commands for reactions and event reply

There is now a 'react' command that the bot will react to when used. When a reaction is
made on a message that the bot sent, then it will acknowledge that reaction using a
reply.
This commit is contained in:
Andrew Morgan 2021-01-03 23:47:27 -05:00
parent f2d1967aab
commit 647233cfac
4 changed files with 170 additions and 13 deletions

View file

@ -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:

View file

@ -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}."
)

View file

@ -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'<a href="https://matrix.to/#/{user_id}">{displayname}</a>'
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,
)

View file

@ -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