Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
66f8722
Add support for message reactions and improve content creation in Mes…
chrisdedman Jun 20, 2025
28d5ce7
Refactor send method in Room class to support message reactions and i…
chrisdedman Jun 20, 2025
c40bca9
Fix ping command to use reply method instead of send
chrisdedman Jun 20, 2025
ca82eb5
Add reaction event example to respond to specific messages
chrisdedman Jun 20, 2025
7cd47d2
Remove debug print statement from send_reaction method
chrisdedman Jun 20, 2025
e1f328b
Add unit tests for send_reaction method in Message class
chrisdedman Jun 20, 2025
3120737
Update Message class to allow optional message body in send method an…
chrisdedman Jun 20, 2025
7cdd0bd
Refactor Message and Room classes to enforce non-optional message bod…
chrisdedman Jun 20, 2025
02ec9d3
Merge branch 'main' into feature/reaction
chrisdedman Jun 21, 2025
b57b463
Add 'on_react' event to EVENT_MAP in Bot class
chrisdedman Jun 22, 2025
e2b8f09
Update event specification for react_event to use 'on_react'
chrisdedman Jun 22, 2025
384ea9f
Mapped 'on_message_react' and 'on_member_react' events
chrisdedman Jun 22, 2025
cb1fe10
Refactor reaction event handlers to improve clarity
chrisdedman Jun 22, 2025
7575446
Added detailed docstrings and additional emoji reactions
chrisdedman Jun 22, 2025
e8c5449
Update type hints in Message and Room classes docstrings.
chrisdedman Jun 22, 2025
1455278
Rename event handlers for message and member reactions for consistency
chrisdedman Jun 22, 2025
a00ad7f
Consolidate reaction event handlers to improve consistency
chrisdedman Jun 22, 2025
febbc44
Fix variable name mismatch of its purpose
chrisdedman Jun 22, 2025
c610201
Refactor message variable to msg to avoid variable naming clash
chrisdedman Jun 22, 2025
9e164a2
refactor reaction example
chrisdedman Jul 11, 2025
73ba151
rerfactor how to get construct message reaction and send
chrisdedman Jul 11, 2025
e520f40
added get message from room
chrisdedman Jul 11, 2025
3d4e6b8
remove vs code from commit
chrisdedman Jul 11, 2025
dbc46f9
Merge branch 'main' into abstraction/reaction
chrisdedman Jul 11, 2025
1e650c5
fix import declaration
chrisdedman Jul 11, 2025
5b7df41
fix import in example
chrisdedman Jul 11, 2025
3b0c266
fix merge conflict
chrisdedman Jul 11, 2025
36929c3
fix merge conflict duplcate
chrisdedman Jul 11, 2025
5d3bbe1
Added additional example for reacting to emoji
chrisdedman Jul 11, 2025
3573f8b
fix unit test for send message and send reaction in Message class
chrisdedman Jul 11, 2025
53e4923
fix formatting to make mypy happy
chrisdedman Jul 11, 2025
51f69b1
Merge branch 'main' into abstraction/reaction
chrisdedman Dec 18, 2025
b849b0b
add type hints and docstrings for get_message and react methods
chrisdedman Dec 18, 2025
909cbd2
refactor Message class constructor and update docstrings for clarity
chrisdedman Dec 18, 2025
ab2498a
remove type hints from docstrings in Room class for cleaner documenta…
chrisdedman Dec 18, 2025
d069c99
refactor Message class to handle reactions using Event type
chrisdedman Dec 18, 2025
9b3ea48
update from_event method to return Message type hint for better clarity
chrisdedman Dec 18, 2025
e5b0102
enforce required event in from_event + enforce self.id non null
chrisdedman Dec 20, 2025
dafcbd6
enforce required Event in Room.send
chrisdedman Dec 20, 2025
07b4598
added comment todo for upcoming refactor
chrisdedman Dec 20, 2025
68ae7f9
added event to room.send as now it is required
chrisdedman Dec 20, 2025
26c95c3
mypy ffix
chrisdedman Dec 21, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions examples/reaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ async def on_message(room: Room, event: Event) -> None:
await room.send(event=event, key="hi")

if event.body.lower().startswith("❤️"):
await room.send(event=event, message="❤️")
# Or directly reply as a message instead of a reaction
await room.send(message="❤️", event=event)


@bot.event
Expand All @@ -29,15 +30,13 @@ async def on_react(room: Room, event: Event) -> None:
and reacts based on the reaction emoji.
"""
room = bot.get_room(room.room_id)

emoji = event.key
event_id = event.source["content"]["m.relates_to"]["event_id"]

if emoji == "🙏":
await room.send(event=event_id, key="❤️")
await room.react(event, "hi")

if emoji == "❤️":
await room.send(message="❤️")
await room.react(event, "❤️")


bot.start()
8 changes: 7 additions & 1 deletion matrix/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,14 @@ async def reply(self, message: str) -> None:
"""

try:
# @todo When Message instance is refactored, refactor this to pass
# the room_id to the message and remove it from `send_message` method.
"""example:
c = Message(self.bot, self.room_id)
await c.send_message(message=message)
"""
c = Message(self.bot)
await c.send(room_id=self.room_id, message=message)
await c.send_message(room_id=self.room_id, message=message)
except Exception as e:
raise MatrixError(f"Failed to send message: {e}")

Expand Down
82 changes: 53 additions & 29 deletions matrix/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from nio import Event

if TYPE_CHECKING:
from .bot import Bot # pragma: no cover
from matrix.bot import Bot # pragma: no cover


class Message:
Expand All @@ -15,15 +15,30 @@ class Message:
formatting the message content as either plain text or HTML.

:param bot: The bot instance to use for messages.
:type bot: Bot
:param id: The unique identifier of the message event.
:param content: The content of the message.
:param sender: The sender of the message.
"""

MESSAGE_TYPE = "m.room.message"
MATRIX_CUSTOM_HTML = "org.matrix.custom.html"
TEXT_MESSAGE_TYPE = "m.text"

def __init__(self, bot: "Bot") -> None:
def __init__(
self,
bot: "Bot",
*,
id: Optional[str] = None,
event: Optional[Event] = None,
content: Optional[str] = None,
sender: Optional[str] = None,
) -> None:
self.bot = bot
self.id = id
if not self.id and event:
self.id = event.event_id
self.content = content
self.sender = sender

async def _send_to_room(
self, room_id: str, content: Dict, message_type: str = MESSAGE_TYPE
Expand All @@ -32,11 +47,8 @@ async def _send_to_room(
Send a message to the Matrix room.

:param room_id: The ID of the room to send the message to.
:type room_id: str
:param content: The matrix JSON payload.
:type content: Dict
:param message_type: The type of the message.
:type message_type: str

:raise MatrixError: If sending the message fails.
"""
Expand All @@ -54,25 +66,20 @@ def _make_content(
body: str = "",
html: Optional[bool] = None,
reaction: Optional[bool] = None,
event_id: Optional[str] = None,
key: Optional[str] = None,
) -> Dict:
"""
Create the content dictionary for a message.

:param body: The body of the message.
:type body: str
:param html: Wheter to format the message as HTML.
:type html: Optional[bool]
:param reaction: Wheter to format the context with a reaction event.
:type reaction: Optional[bool]
:param event_id: The ID of the event to react to.
:type event_id: Optional[str]
:param key: The reaction to the message.
:type key: Optional[str]

:return: The content of the dictionary.
"""
if self.id is None:
raise ValueError("id cannot be None")

base: Dict = {
"msgtype": self.TEXT_MESSAGE_TYPE,
Expand All @@ -85,50 +92,67 @@ def _make_content(

if reaction:
base["m.relates_to"] = {
"event_id": event_id,
"event_id": self.id,
"key": key,
"rel_type": "m.annotation",
}

return base

async def send(
async def send_message(
self, room_id: str, message: str, format_markdown: Optional[bool] = True
) -> None:
"""
Send a message to a Matrix room.

:param room_id: The ID of the room to send the message to.
:type room_id: str
:param message: The message to send.
:type message: str
:param format_markdown: Whether to format the message as Markdown
(default to True).
:type format_markdown: Optional[bool]
"""
await self._send_to_room(
room_id=room_id,
content=self._make_content(body=str(message), html=format_markdown),
)

async def send_reaction(self, room_id: str, event: Event, key: str) -> None:
async def send_reaction(self, room_id: str, key: str) -> None:
"""
Send a reaction to a message from a user in a Matrix room.

:param room_id: The ID of the room to send the message to.
:type room_id: str
:param event: The event object to react to.
:type event: Event
:param key: The reaction to the message.
:type key: str
"""
if isinstance(event, Event):
event_id = event.event_id
else:
event_id = event

await self._send_to_room(
room_id=room_id,
content=self._make_content(event_id=event_id, key=key, reaction=True),
content=self._make_content(key=key, reaction=True),
message_type="m.reaction",
)

@staticmethod
def from_event(bot: "Bot", event: Event) -> "Message":
"""
Method to construct a Message instance from event.
Support regular message events and reaction events.

:param bot: The bot instance to use for messages.
:param event: The event object to construct the message from.

:return: The constructed Message instance.
:raise MissingArgumentError: If event is None.
"""
if event is None:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not necessary since we pass an event.

raise ValueError("event cannot be None")

if isinstance(event, Event) and event.source["type"] == "m.reaction":
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't make sense either. It should always be an instance of event.cif they goal is to check the type just check the types you don't have to check the instance

event_id = event.source["content"]["m.relates_to"]["event_id"]
body = event.source["content"]
else:
event_id = event.event_id
body = event.body

return Message(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like we should pass the the event now

bot=bot,
id=event_id,
content=body,
sender=event.sender,
)
46 changes: 31 additions & 15 deletions matrix/room.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,7 @@ class Room:
Represents a Matrix room and provides methods to interact with it.

:param room_id: The unique identifier of the room.
:type room_id: str
:param bot: The bot instance used to send messages.
:type bot: Bot
"""

def __init__(self, room_id: str, bot: "Bot") -> None:
Expand All @@ -24,30 +22,53 @@ def __init__(self, room_id: str, bot: "Bot") -> None:
async def send(
self,
message: str = "",
*,
event: Event,
markdown: Optional[bool] = True,
event: Optional[Event] = None,
key: Optional[str] = None,
) -> None:
"""
Send a message to the room.

:param message: The message to send.
:type message: str
:param markdown: Whether to format the message as Markdown.
:type markdown: Optional[bool]
:param event: An event object to react to.
:type event: Optional[Event]
:param key: The reaction to the message.
:type key: Optional[str]

:raises MatrixError: If sending the message fails.
"""
try:
msg = Message(self.bot)
msg = self.get_message(self.bot, event)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can probably get ride of get_message for now and use from_event directly

if key:
await msg.send_reaction(self.room_id, event, key)
await msg.send_reaction(self.room_id, key)
else:
await msg.send(self.room_id, message, markdown)
await msg.send_message(self.room_id, message, markdown)
except Exception as e:
raise MatrixError(f"Failed to send message: {e}")

@staticmethod
def get_message(bot: "Bot", event: Event) -> Message:
"""
Get a Message instance from an event.
:param bot: The bot instance to use for messages.
:param event: The event object to construct the message from.

:return: The constructed Message instance.
"""
if not event and not bot:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This shouldn't happen since we have bot and event as parameter

raise MatrixError("Failed to get message.")

return Message.from_event(bot, event)

async def react(self, event: Event, key: str) -> None:
"""
Send a reaction to a message in the room.

:param event: The event to react to.
:param key: The reaction to the message.
"""
try:
await self.send(event=event, key=key)
except Exception as e:
raise MatrixError(f"Failed to send message: {e}")

Expand All @@ -70,9 +91,7 @@ async def ban_user(self, user_id: str, reason: Optional[str] = None) -> None:
Ban a user from a room.

:param user_id: The ID of the user to ban of the room.
:type user_id: str
:param reason: The reason to ban the user.
:type reason: Optional[str]

:raises MatrixError: If banning the user fails.
"""
Expand All @@ -89,7 +108,6 @@ async def unban_user(self, user_id: str) -> None:
Unban a user from a room.

:param user_id: The ID of the user to unban of the room.
:type user_id: str

:raises MatrixError: If unbanning the user fails.
"""
Expand All @@ -104,9 +122,7 @@ async def kick_user(self, user_id: str, reason: Optional[str] = None) -> None:
Kick a user from a room.

:param user_id: The ID of the user to kick of the room.
:type user_id: str
:param reason: The reason to kick the user.
:type reason: Optional[str]

:raises MatrixError: If kicking the user fails.
"""
Expand Down
8 changes: 4 additions & 4 deletions tests/test_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ async def test_send_message_success(message_default):
room_id = "!room:id"
message = "Hello, world!"

await message_default.send(room_id, message)
await message_default.send_message(room_id, message)
message_default.bot.client.room_send.assert_awaited_once()


Expand All @@ -49,7 +49,7 @@ async def test_send_message_failure(message_default):
"Failed to send message"
)
with pytest.raises(MatrixError, match="Failed to send message"):
await message_default.send(room_id, message)
await message_default.send_message(room_id, message)


def test_make_content_with_html(message_default):
Expand All @@ -76,7 +76,7 @@ def test_make_content_without_html(message_default):
async def test_send_reaction_success(message_default, event):
room_id = "!room:id"

await message_default.send_reaction(room_id, event, "hi")
await message_default.send_reaction(room_id, "hi")
message_default.bot.client.room_send.assert_awaited_once()


Expand All @@ -88,4 +88,4 @@ async def test_send_reaction_failure(message_default, event):
"Failed to send message"
)
with pytest.raises(MatrixError, match="Failed to send message"):
await message_default.send_reaction(room_id, event, "🙏")
await message_default.send_reaction(room_id, "🙏")
Loading