diff --git a/examples/table.py b/examples/table.py
new file mode 100644
index 0000000..6a7dde7
--- /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")
+
+ 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 0ebc2fe..5232aae 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",
@@ -34,4 +35,5 @@
"Space",
"Message",
"Extension",
+ "Table",
]
diff --git a/matrix/component.py b/matrix/component.py
new file mode 100644
index 0000000..2f4e0da
--- /dev/null
+++ b/matrix/component.py
@@ -0,0 +1,109 @@
+from html import escape
+from abc import ABC, abstractmethod
+
+CELL_TEMPLATE = "
{name} {value} | "
+
+ROW_TEMPLATE = "{cells}
"
+
+TABLE_TEMPLATE = "{title}
"
+
+
+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):
+ """A component that renders labeled fields as a table.
+
+ Fields are displayed in rows using the configured number of columns.
+ Incomplete rows are padded with empty cells. Field names, values, and the
+ table title are HTML-escaped when rendered.
+ """
+
+ def __init__(self, *, title: str, column_count: int = 2) -> None:
+ if column_count < 1:
+ raise ValueError("column_count must be greater than 0")
+
+ self.title: str = title
+ self.column_count: int = column_count
+ self.fields: list[tuple[str, str]] = []
+
+ def __str__(self) -> str:
+ return self.render()
+
+ def add_field(self, name: str, value: str) -> None:
+ """Add a labeled field to the table.
+
+ ## Example
+
+ ```python
+ table = Table(title="User Info")
+ table.add_field("Name", "Astra")
+ ```
+ """
+ self.fields.append((name, value))
+
+ def to_plain_text(self) -> str:
+ """Render the table as plain text.
+
+ ## Example
+
+ ```python
+ table = Table(title="User Info")
+ table.add_field("Name", "Astra")
+
+ result = table.to_plain_text()
+ # User Info
+ # Name: Astra
+ ```
+ """
+ return "\n".join(
+ [self.title, *[f"{name}: {value}" for name, value in self.fields]]
+ )
+
+ def render(self) -> str:
+ """Render the table as HTML with escaped field content.
+
+ Incomplete rows are padded with empty cells based on the configured
+ column count.
+
+ ## Example
+
+ ```python
+ table = Table(title="User Info")
+ table.add_field("Name", "Astra")
+ table.add_field("Role", "Engineer")
+
+ html = table.render()
+ ```
+ """
+ cells = []
+ for name, value in self.fields:
+ cells.append(
+ CELL_TEMPLATE.format(
+ name=escape(name),
+ value=escape(value),
+ )
+ )
+
+ rows = []
+ for i in range(0, len(cells), self.column_count):
+ row_cells = cells[i : i + self.column_count]
+
+ while len(row_cells) < self.column_count:
+ row_cells.append(" | ")
+
+ rows.append(ROW_TEMPLATE.format(cells="".join(row_cells)))
+
+ return TABLE_TEMPLATE.format(
+ title=escape(self.title),
+ rows="".join(rows),
+ )
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 ab4d3cb..20a0a1b 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
from .member import Member
if TYPE_CHECKING:
@@ -61,6 +62,7 @@ async def reply(
raw: bool = False,
notice: bool = False,
file: File | None = None,
+ component: Component | None = None,
) -> Message:
"""Reply to the command with a message.
@@ -106,6 +108,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 d7c97f5..5a9e99d 100644
--- a/matrix/room.py
+++ b/matrix/room.py
@@ -8,6 +8,7 @@
RoomGetStateEventError,
)
+from matrix.component import Component
from matrix.api import matrix_call
from matrix.message import Message
from matrix.content import (
@@ -20,6 +21,7 @@
ImageContent,
AudioContent,
VideoContent,
+ ComponentContent,
)
from matrix.types import File, Image, Audio, Video
@@ -118,6 +120,7 @@ async def send(
raw: bool = False,
notice: bool = False,
file: File | None = None,
+ component: Component | None = None,
) -> Message:
"""Send a message to the room.
@@ -131,6 +134,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**!")
@@ -143,6 +150,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)
@@ -150,6 +160,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,
diff --git a/tests/test_component.py b/tests/test_component.py
new file mode 100644
index 0000000..7cdf77c
--- /dev/null
+++ b/tests/test_component.py
@@ -0,0 +1,102 @@
+import pytest
+from matrix.component import Table
+
+
+@pytest.fixture
+def table():
+ return Table(title="User Info")
+
+
+@pytest.mark.parametrize("column_count", [0, -1, -10])
+def test_init__when_column_count_is_less_than_one__expect_value_error(
+ column_count: int,
+) -> None:
+ with pytest.raises(
+ ValueError,
+ match="column_count must be greater than 0",
+ ):
+ Table(title="User Info", column_count=column_count)
+
+
+def test_init__when_column_count_is_one__expect_column_count_set() -> None:
+ table = Table(title="User Info", column_count=1)
+
+ assert table.column_count == 1
+
+
+def test_to_plain_text__expect_title_and_fields(table):
+ table.add_field("Name", "Astra")
+ table.add_field("Role", "Engineer")
+
+ result = table.to_plain_text()
+
+ assert result == "User Info\nName: Astra\nRole: Engineer"
+
+
+def test_to_plain_text__with_no_fields__expect_title_only(table):
+ result = table.to_plain_text()
+
+ assert result == "User Info"
+
+
+def test_render__expect_html_table(table):
+ table.add_field("Name", "Astra")
+ table.add_field("Role", "Engineer")
+
+ result = table.render()
+
+ assert "User Info
" in result
+ assert "" in result
+ assert "Name" in result
+ assert "Astra" in result
+ assert "Role" in result
+ assert "Engineer" in result
+
+
+def test_render__with_odd_number_of_fields__expect_empty_padding_cell(table):
+ table.add_field("Name", "Astra")
+ table.add_field("Role", "Engineer")
+ table.add_field("Location", "CA")
+
+ result = table.render()
+
+ assert result.count("") == 2
+ assert result.count("| ") == 4
+ assert result.count(" | | ") == 1
+ assert result.count("") == 3
+
+
+def test_render__with_custom_columns__expect_rows_grouped_by_column_count():
+ table = Table(title="User Info", column_count=3)
+ table.add_field("Name", "Astra")
+ table.add_field("Role", "Engineer")
+ table.add_field("Location", "CA")
+ table.add_field("Status", "Active")
+
+ result = table.render()
+
+ assert result.count("") == 2
+ assert result.count("| ") == 6
+ assert result.count(" | | ") == 2
+ assert result.count("") == 4
+
+
+def test_render__with_html_content__expect_escaped_html():
+ table = Table(title="")
+ table.add_field("", "")
+
+ result = table.render()
+
+ assert "<User Info>" in result
+ assert "<Name>" in result
+ assert "<Astra & Co>" in result
+
+ assert "" not in result
+ assert "" not in result
+ assert "" not in result
+
+
+def test_str__expect_rendered_html(table):
+ table.add_field("Name", "Astra")
+
+ assert str(table) == table.render()