Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion examples/config.yaml

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It seems like editor changed some whitespace or something, but it doesn't seem to change anything. Just be careful with that in the future.

Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
HOMESERVER: "your_matrix_server(default to matrix.org)"
USERNAME: "your_bot_username"
PASSWORD: "your_Password"
PREFIX: "your_custom_prefix(default to !)"
PREFIX: "your_custom_prefix(default to !)"
60 changes: 53 additions & 7 deletions matrix/message.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from collections.abc import AsyncIterator
from typing import TYPE_CHECKING, Self

from nio import (
Expand All @@ -7,7 +8,7 @@
RoomGetStateEventResponse,
)

from matrix.types import Reaction
from matrix.types import Reaction, ReactionEvent
from matrix.content import ReactionContent, EditContent
from matrix.errors import MatrixError
from matrix.api import matrix_call
Expand Down Expand Up @@ -76,20 +77,30 @@ async def reactions(ctx: Context):
"""
raw: dict[str, list[str]] = {}

async for reaction_event in self._iter_reaction_events():
raw.setdefault(reaction_event.emoji, []).append(reaction_event.sender)

return [Reaction(key=emoji, senders=senders) for emoji, senders in raw.items()]

async def _iter_reaction_events(self) -> AsyncIterator[ReactionEvent]:
"""Yield complete reaction relation events for this message."""
try:
async for event in self.client.room_get_event_relations(
room_id=self.room.room_id,
event_id=self.event_id,
):
emoji = getattr(event, "key", None)
sender = getattr(event, "sender", None)

if emoji and sender:
raw.setdefault(emoji, []).append(sender)
event_id = getattr(event, "event_id", None)

if emoji and sender and event_id:
yield ReactionEvent(
emoji=emoji,
sender=sender,
event_id=event_id,
)
except Exception as e:
raise MatrixError(f"Failed to fetch reactions: {e}")

return [Reaction(key=emoji, senders=senders) for emoji, senders in raw.items()]
raise MatrixError(f"Failed to fetch reactions: {e}") from e

async def reply(self, body: str) -> "Message":
"""Reply to this message.
Expand Down Expand Up @@ -131,6 +142,41 @@ async def thumbsup(ctx: Context):
error_message="Failed to add reaction",
)

async def unreact(self, emoji: str) -> None:
"""Remove this client's reaction emoji from the message.

If the client has not reacted with the requested emoji, this method
does nothing.

## Example
```python
@bot.command()
async def toggle(ctx: Context):
msg = await ctx.reply("React to this!")
await msg.react("👍")
await msg.unreact("👍")
```
"""
reaction_event_id = None
async for reaction_event in self._iter_reaction_events():
if (
reaction_event.emoji == emoji
and reaction_event.sender == self.client.user_id
):
reaction_event_id = reaction_event.event_id
break

if reaction_event_id is None:
return

await matrix_call(
self.client.room_redact(
room_id=self.room.room_id,
event_id=reaction_event_id,
),
error_message="Failed to remove reaction",
)

async def edit(self, new_body: str) -> None:
"""Updates the message content to the new text.

Expand Down
9 changes: 9 additions & 0 deletions matrix/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,12 @@ class Video(File):
class Reaction:
key: str
senders: list[str]


@dataclass
class ReactionEvent:
"""Details for a single reaction relation event."""

emoji: str
sender: str
event_id: str
70 changes: 69 additions & 1 deletion tests/test_message.py

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good test coverage overall, but there's a small gap: unreact wraps the relation loop, but none of the three tests actually test the possible exception raising from there.

Something like:

@pytest.mark.asyncio
async def test_unreact_with_relations_error__expect_matrix_error(message, client):
    async def mock_relations(*args, **kwargs):
        raise Exception("Network error")
        yield

    client.room_get_event_relations = mock_relations

    with pytest.raises(MatrixError, match="Failed to fetch reactions: ..."):
        await message.unreact("👍")

This is similar to test_fetch_reactions_with_error__expect_matrix_error

Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ def matrix_room():

@pytest.fixture
def client():
return AsyncMock(spec=AsyncClient)
client = AsyncMock(spec=AsyncClient)
client.user_id = "@bot:matrix.org"
return client


@pytest.fixture
Expand Down Expand Up @@ -109,6 +111,72 @@ async def test_react_with_api_error__expect_matrix_error(message, client):
await message.react("😀")


@pytest.mark.asyncio
async def test_unreact__expect_own_matching_reaction_redacted(message, client):
own_reaction = MagicMock(event_id="$reaction1", key="👍", sender="@bot:matrix.org")
other_reaction = MagicMock(
event_id="$reaction2", key="👍", sender="@alice:matrix.org"
)

async def mock_relations(*args, **kwargs):
for reaction in [other_reaction, own_reaction]:
yield reaction

client.room_get_event_relations = mock_relations
client.room_redact = AsyncMock()

await message.unreact("👍")

client.room_redact.assert_awaited_once_with(
room_id="!room:example.com", event_id="$reaction1"
)


@pytest.mark.asyncio
async def test_unreact_without_matching_reaction__expect_no_redaction(message, client):
other_reaction = MagicMock(
event_id="$reaction2", key="👍", sender="@alice:matrix.org"
)

async def mock_relations(*args, **kwargs):
yield other_reaction

client.room_get_event_relations = mock_relations
client.room_redact = AsyncMock()

await message.unreact("👍")

client.room_redact.assert_not_awaited()


@pytest.mark.asyncio
async def test_unreact_with_relations_error__expect_matrix_error(message, client):
async def mock_relations(*args, **kwargs):
raise Exception("Network error")
yield

client.room_get_event_relations = mock_relations

with pytest.raises(MatrixError, match="Failed to fetch reactions: Network error"):
await message.unreact("👍")


@pytest.mark.asyncio
async def test_unreact_with_api_error__expect_matrix_error(message, client):
own_reaction = MagicMock(event_id="$reaction1", key="👍", sender="@bot:matrix.org")

async def mock_relations(*args, **kwargs):
yield own_reaction

client.room_get_event_relations = mock_relations
client.room_redact = AsyncMock(
return_value=RoomRedactError("not allowed", "M_FORBIDDEN")
)

with pytest.raises(MatrixError, match="Failed to remove reaction"):
await message.unreact("👍")


@pytest.mark.asyncio
async def test_edit__expect_message_updated(message, client):
client.room_send = AsyncMock()
Expand Down
Loading