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
15 changes: 5 additions & 10 deletions matrix/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,15 +337,9 @@ def wrapper(func: ErrorCallback) -> Callable:
return wrapper

def get_room(self, room_id: str) -> Room:
"""
Retrieve a Room instance based on the room_id.

:param room_id: The ID of the room to retrieve.
:type room_id: str
:return: An instance of the Room class.
:rtype: Room
"""
return Room(room_id=room_id, bot=self)
"""Retrieve a Room instance based on the room_id."""
matrix_room = self.client.rooms[room_id]
return Room(matrix_room=matrix_room, client=self.client)

def _auto_register_events(self) -> None:
for attr in dir(self):
Expand Down Expand Up @@ -390,8 +384,9 @@ async def _process_commands(self, room: MatrixRoom, event: Event) -> None:

await ctx.command(ctx)

async def _build_context(self, room: MatrixRoom, event: Event) -> Context:
async def _build_context(self, matrix_room: MatrixRoom, event: Event) -> Context:
"""Builds the base context and extracts the command from the event"""
room = self.get_room(matrix_room.room_id)
ctx = Context(bot=self, room=room, event=event)

if not self.prefix or not ctx.body.startswith(self.prefix):
Expand Down
183 changes: 183 additions & 0 deletions matrix/content.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from markdown import markdown
from typing import Any


class BaseMessageContent(ABC):
"""Base class for outgoing message payloads."""

msgtype: str

@abstractmethod
def build(self) -> dict[str, Any]:
pass


@dataclass
class TextContent(BaseMessageContent):
msgtype = "m.text"
body: str

def build(self) -> dict:
return {"msgtype": self.msgtype, "body": self.body}


@dataclass
class MarkdownMessage(TextContent):
def build(self) -> dict:
return {
"msgtype": self.msgtype,
"body": self.body,
"format": "org.matrix.custom.html",
"formatted_body": markdown(self.body, extensions=["nl2br"]),
}


@dataclass
class NoticeContent(TextContent):
msgtype = "m.notice"


@dataclass
class ReplyContent(TextContent):
reply_to_event_id: str

def build(self) -> dict:
return {
"msgtype": self.msgtype,
"body": self.body,
"m.relates_to": {"m.in_reply_to": {"event_id": self.reply_to_event_id}},
}


@dataclass
class EditContent(TextContent):
original_event_id: str

def build(self) -> dict:
return {
"msgtype": self.msgtype,
"body": f"* {self.body}",
"m.new_content": {
"msgtype": "m.text",
"body": self.body,
},
"m.relates_to": {
"rel_type": "m.replace",
"event_id": self.original_event_id,
},
}


@dataclass
class FileContent(BaseMessageContent):
msgtype = "m.file"
filename: str
url: str
mimetype: str

def build(self) -> dict:
return {
"msgtype": self.msgtype,
"body": self.filename,
"url": self.url,
"info": {"mimetype": self.mimetype},
}


@dataclass
class ImageContent(BaseMessageContent):
msgtype = "m.image"
filename: str
url: str
mimetype: str
height: int = 0
width: int = 0

def build(self) -> dict:
return {
"msgtype": self.msgtype,
"body": self.filename,
"url": self.url,
"info": {
"mimetype": self.mimetype,
"h": self.height,
"w": self.width,
},
}


@dataclass
class AudioContent(BaseMessageContent):
msgtype = "m.audio"
filename: str
url: str
mimetype: str
duration: int = 0

def build(self) -> dict:
return {
"msgtype": self.msgtype,
"body": self.filename,
"url": self.url,
"info": {
"mimetype": self.mimetype,
"duration": self.duration,
},
}


@dataclass
class VideoContent(BaseMessageContent):
msgtype = "m.video"
filename: str
url: str
mimetype: str
height: int = 0
width: int = 0
duration: int = 0

def build(self) -> dict:
return {
"msgtype": self.msgtype,
"body": self.filename,
"url": self.url,
"info": {
"mimetype": self.mimetype,
"h": self.height,
"w": self.width,
"duration": self.duration,
},
}


@dataclass
class LocationContent(BaseMessageContent):
msgtype = "m.location"
geo_uri: str
description: str = ""

def build(self) -> dict:
return {
"msgtype": self.msgtype,
"body": self.description or self.geo_uri,
"geo_uri": self.geo_uri,
}


@dataclass
class ReactionContent(BaseMessageContent):
"""For sending reactions to an event."""

event_id: str
emoji: str

def build(self) -> dict:
return {
"m.relates_to": {
"rel_type": "m.annotation",
"event_id": self.event_id,
"key": self.emoji,
}
}
117 changes: 87 additions & 30 deletions matrix/context.py
Original file line number Diff line number Diff line change
@@ -1,45 +1,33 @@
import shlex

from nio import Event, MatrixRoom
from nio import Event
from typing import TYPE_CHECKING, Optional, Any, List

from .errors import MatrixError
from .message import Message
from .room import Room
from .types import File, Image

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


class Context:
"""
Represents the context in which a command is executed. Provides
"""Represents the context in which a command is executed. Provides
access to the bot instance, room and event metadata, parsed arguments,
and other utilities.

:param bot: The bot instance executing the command.
:type bot: Bot
:param room: The Matrix room where the event occurred.
:type room: MatrixRoom
:param event: The event that triggered the command or message.
:type event: Event

:raises MatrixError: If a Matrix operation fails.
"""

def __init__(self, bot: "Bot", room: MatrixRoom, event: Event):
def __init__(self, bot: "Bot", room: Room, event: Event):
self.bot = bot
self.room = room
self.event = event

self.body: str = getattr(event, "body", "")
self.sender: str = event.sender

# Room metadata.
self.room_id: str = room.room_id
self.room_name: str = room.name

# Command metdata
# Command metadata
self.prefix: str = bot.prefix
self.command: Optional[Command] = None
self.subcommand: Optional[Command] = None
Expand All @@ -66,25 +54,94 @@ def args(self) -> List[str]:
@property
def logger(self) -> Any:
"""Logger for instance specific to the current room or event."""
return self.bot.log.getChild(self.room_id)

async def reply(self, message: str) -> None:
"""
Send a message to the Matrix room.

:param message: The message to send.
:type message: str

:return: None
return self.bot.log.getChild(self.room.room_id)

async def reply(
self,
content: str | None = None,
*,
raw: bool = False,
notice: bool = False,
file: File | None = None,
image: Image | None = None,
) -> Message:
"""Reply to the command with a message.

## Example

```python
@bot.command()
async def hello(ctx: Context):
# Send a markdown-formatted reply
await ctx.reply("Hello **world**!")

@bot.command()
async def status(ctx: Context):
# Send a notice message
await ctx.reply("Bot is online!", notice=True)

@bot.command()
async def document(ctx: Context):
# Upload and send a file
with open("report.pdf", "rb") as f:
resp, _ = await ctx.room.client.upload(f, content_type="application/pdf")

file = File(
filename="report.pdf",
path=resp.content_uri,
mimetype="application/pdf"
)
await ctx.reply(file=file)

@bot.command()
async def cat(ctx: Context):
# Upload and send an image
from PIL import Image as PILImage

with PILImage.open("cat.jpg") as img:
width, height = img.size

with open("cat.jpg", "rb") as f:
resp, _ = await ctx.room.client.upload(f, content_type="image/jpeg")

image = Image(
filename="cat.jpg",
path=resp.content_uri,
mimetype="image/jpeg",
width=width,
height=height
)
await ctx.reply(image=image)
```
"""

try:
c = Message(self.bot)
await c.send(room_id=self.room_id, message=message)
return await self.room.send(
content,
raw=raw,
notice=notice,
file=file,
image=image,
)
except Exception as e:
raise MatrixError(f"Failed to send message: {e}")

async def send_help(self) -> None:
"""Send help from the current command context.

Displays help text for the current subcommand, command, or the bot's
general help menu depending on what's available in the context. The help
hierarchy is: subcommand help > command help > bot help.

## Example

```python
@bot.group()
async def config(ctx: Context):
# If user runs just "!config" with no subcommand
await ctx.send_help()
```
"""
if self.subcommand:
await self.reply(self.subcommand.help)
return
Expand Down
2 changes: 1 addition & 1 deletion matrix/group.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ def register_command(self, cmd: Command) -> Command:
return cmd

async def invoke(self, ctx: "Context") -> None:
if subcommand := ctx.args.pop(0):
if ctx.args and (subcommand := ctx.args.pop(0)):
ctx.subcommand = self.get_command(subcommand)
await ctx.subcommand(ctx)
else:
Expand Down
Loading