diff --git a/examples/table.py b/examples/table.py new file mode 100644 index 0000000..8dbd515 --- /dev/null +++ b/examples/table.py @@ -0,0 +1,20 @@ +from matrix import Bot, Table + +bot = Bot() + + +@bot.command() +async def weather(ctx): + weather = Table(title="Los Angeles", columns=2) + + weather.add_field("Description", "Clear Sky") + weather.add_field("Visibility", "10000m | 32808ft") + weather.add_field("Temperature", "71.33°F | 21.85°C") + weather.add_field("Feels Like", "71.33°F | 21.85°C") + weather.add_field("Atmospheric Pressure", "1012 hPa") + weather.add_field("Humidity", "66%") + + await ctx.reply(component=weather) + + +bot.start(config="config.yaml") diff --git a/matrix/__init__.py b/matrix/__init__.py index 1845a21..91399f0 100644 --- a/matrix/__init__.py +++ b/matrix/__init__.py @@ -18,6 +18,7 @@ from .space import Space from .message import Message from .extension import Extension +from .component import Table __all__ = [ "Bot", @@ -32,4 +33,5 @@ "Space", "Message", "Extension", + "Table", ] diff --git a/matrix/component.py b/matrix/component.py new file mode 100644 index 0000000..34d92f8 --- /dev/null +++ b/matrix/component.py @@ -0,0 +1,59 @@ +from html import escape +from abc import ABC, abstractmethod + + +class Component(ABC): + """Base class for message components.""" + + @abstractmethod + def to_plain_text(self) -> str: + pass + + @abstractmethod + def render(self) -> str: + pass + + +class Table(Component): + def __init__(self, *, title: str, columns: int = 2) -> None: + self.title: str = title + self.columns: int = columns + self.fields: list[tuple[str, str]] = [] + + def __str__(self) -> str: + return self.render() + + def add_field(self, name: str, value: str) -> None: + self.fields.append((name, value)) + + def to_plain_text(self) -> str: + return "\n".join( + [self.title, *[f"{name}: {value}" for name, value in self.fields]] + ) + + def render(self) -> str: + cells = [f""" + + {escape(name)}
+ {escape(value)} + + """ for name, value in self.fields] + + rows = "" + for i in range(0, len(cells), self.columns): + row_cells = cells[i : i + self.columns] + + while len(row_cells) < self.columns: + row_cells.append("") + + rows += f"{''.join(row_cells)}" + + return f"""
+

{escape(self.title)}

+ + + {rows} + +
+
+""".strip() diff --git a/matrix/content.py b/matrix/content.py index 7b3ca05..1a9dc04 100644 --- a/matrix/content.py +++ b/matrix/content.py @@ -2,6 +2,7 @@ from dataclasses import dataclass from markdown import markdown from typing import Any +from .component import Component class BaseMessageContent(ABC): @@ -172,3 +173,17 @@ def build(self) -> dict: "key": self.emoji, } } + + +@dataclass +class ComponentContent(BaseMessageContent): + msgtype = "m.text" + component: Component + + def build(self) -> dict: + return { + "msgtype": self.msgtype, + "body": self.component.to_plain_text(), + "format": "org.matrix.custom.html", + "formatted_body": self.component.render(), + } diff --git a/matrix/context.py b/matrix/context.py index 185b5f0..0d2d48f 100644 --- a/matrix/context.py +++ b/matrix/context.py @@ -6,7 +6,8 @@ from .errors import MatrixError from .message import Message from .room import Room -from .types import File, Image +from .types import File +from matrix.component import Component if TYPE_CHECKING: from .bot import Bot # pragma: no cover @@ -56,6 +57,7 @@ async def reply( self, content: str | None = None, *, + component: Component | None = None, raw: bool = False, notice: bool = False, file: File | None = None, @@ -104,6 +106,7 @@ async def cat(ctx: Context): try: return await self.room.send( content, + component=component, raw=raw, notice=notice, file=file, diff --git a/matrix/room.py b/matrix/room.py index 1d1fc0c..b850aef 100644 --- a/matrix/room.py +++ b/matrix/room.py @@ -2,6 +2,7 @@ from nio import AsyncClient, MatrixRoom, Event +from matrix.component import Component from matrix.api import matrix_call from matrix.message import Message from matrix.content import ( @@ -14,6 +15,7 @@ ImageContent, AudioContent, VideoContent, + ComponentContent, ) from matrix.types import File, Image, Audio, Video @@ -101,6 +103,7 @@ async def send( self, content: str | None = None, *, + component: Component | None = None, raw: bool = False, notice: bool = False, file: File | None = None, @@ -117,6 +120,10 @@ async def send( ## Example ```python + # Send component-formatted message + table = Table(title="Los Angeles") + await room.send(component=table) + # Send a markdown-formatted text message await room.send("Hello **world**!") @@ -129,6 +136,9 @@ async def send( await room.send(file=image) ``` """ + if component: + return await self.send_component(component) + if content: return await self.send_text(content, raw=raw, notice=notice) @@ -136,6 +146,24 @@ async def send( return await self.send_file(file) raise ValueError("You must provide content or file.") + async def send_component( + self, + component: Component, + ) -> Message: + """Send a component-formatted message to the room. + + ## Example + + ```python + # Send component-formatted message + table = Table(title="Los Angeles") + await room.send_component(table) + ``` + """ + payload: ComponentContent = ComponentContent(component=component) + + return await self._send_payload(payload) + async def send_text( self, content: str,