From 1651edd3a7717dedebbf2ab9eb447ee4efb4c0d3 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 8 Jul 2026 16:54:55 -0700 Subject: [PATCH 1/9] Add support for sending component-formatted messages in Room and Context --- matrix/content.py | 15 +++++++++++++++ matrix/context.py | 5 ++++- matrix/room.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) 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..ca3b457 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: @@ -58,6 +59,7 @@ async def reply( self, content: str | None = None, *, + component: Component | None = None, raw: bool = False, notice: bool = False, file: File | None = None, @@ -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..73b0820 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 @@ -115,6 +117,7 @@ async def send( self, content: str | None = None, *, + component: Component | None = None, raw: bool = False, notice: bool = False, file: File | None = None, @@ -131,6 +134,10 @@ async def send( ## Example ```python + # Send component-formatted message + table = MatrixTable(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 = MatrixTable(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, From 410f1b0ed73ca8ea8a2f698d796227c2007d6447 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 8 Jul 2026 16:56:11 -0700 Subject: [PATCH 2/9] Add MatrixTable component for structured message rendering --- matrix/__init__.py | 2 ++ matrix/component.py | 59 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 matrix/component.py diff --git a/matrix/__init__.py b/matrix/__init__.py index 0ebc2fe..dc854fb 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 MatrixTable __all__ = [ "Bot", @@ -34,4 +35,5 @@ "Space", "Message", "Extension", + "MatrixTable", ] diff --git a/matrix/component.py b/matrix/component.py new file mode 100644 index 0000000..130f4b2 --- /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 MatrixTable(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() From e78ee0c0ffe2a69902e5dae18834b3e276fc39c9 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 8 Jul 2026 16:59:32 -0700 Subject: [PATCH 3/9] renamed MatrixTable to Table class --- matrix/__init__.py | 4 ++-- matrix/component.py | 2 +- matrix/room.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/matrix/__init__.py b/matrix/__init__.py index dc854fb..5232aae 100644 --- a/matrix/__init__.py +++ b/matrix/__init__.py @@ -18,7 +18,7 @@ from .space import Space from .message import Message from .extension import Extension -from .component import MatrixTable +from .component import Table __all__ = [ "Bot", @@ -35,5 +35,5 @@ "Space", "Message", "Extension", - "MatrixTable", + "Table", ] diff --git a/matrix/component.py b/matrix/component.py index 130f4b2..34d92f8 100644 --- a/matrix/component.py +++ b/matrix/component.py @@ -14,7 +14,7 @@ def render(self) -> str: pass -class MatrixTable(Component): +class Table(Component): def __init__(self, *, title: str, columns: int = 2) -> None: self.title: str = title self.columns: int = columns diff --git a/matrix/room.py b/matrix/room.py index 73b0820..8bddd4d 100644 --- a/matrix/room.py +++ b/matrix/room.py @@ -135,7 +135,7 @@ async def send( ```python # Send component-formatted message - table = MatrixTable(title="Los Angeles") + table = Table(title="Los Angeles") await room.send(component=table) # Send a markdown-formatted text message @@ -170,7 +170,7 @@ async def send_component( ```python # Send component-formatted message - table = MatrixTable(title="Los Angeles") + table = Table(title="Los Angeles") await room.send_component(table) ``` """ From 7094255558a144521cfdf575272fbf28ffed03eb Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 8 Jul 2026 17:03:53 -0700 Subject: [PATCH 4/9] added feature example for Table component --- examples/table.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 examples/table.py 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") From 1fd5832bda23b38fae65dea45f7f40dd883ccec1 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 10 Jul 2026 15:33:26 -0700 Subject: [PATCH 5/9] move component param placement --- matrix/context.py | 2 +- matrix/room.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/matrix/context.py b/matrix/context.py index ca3b457..20a0a1b 100644 --- a/matrix/context.py +++ b/matrix/context.py @@ -59,10 +59,10 @@ async def reply( self, content: str | None = None, *, - component: Component | None = None, raw: bool = False, notice: bool = False, file: File | None = None, + component: Component | None = None, ) -> Message: """Reply to the command with a message. diff --git a/matrix/room.py b/matrix/room.py index 8bddd4d..5a9e99d 100644 --- a/matrix/room.py +++ b/matrix/room.py @@ -117,10 +117,10 @@ async def send( self, content: str | None = None, *, - component: Component | None = None, raw: bool = False, notice: bool = False, file: File | None = None, + component: Component | None = None, ) -> Message: """Send a message to the room. From 57802f3d06f542c49da1e1833e1b6a0efd615280 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 10 Jul 2026 15:33:42 -0700 Subject: [PATCH 6/9] remove blackquote --- matrix/component.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/matrix/component.py b/matrix/component.py index 34d92f8..f92181f 100644 --- a/matrix/component.py +++ b/matrix/component.py @@ -48,12 +48,10 @@ def render(self) -> str: rows += f"{''.join(row_cells)}" - return f"""
-

{escape(self.title)}

+ return f"""

{escape(self.title)}

{rows}
-
""".strip() From a35052de77e0dd3b9951444896133996440c8893 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 30 Jul 2026 15:40:15 -0700 Subject: [PATCH 7/9] fix html formatting --- matrix/component.py | 37 +++++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/matrix/component.py b/matrix/component.py index f92181f..4ef7873 100644 --- a/matrix/component.py +++ b/matrix/component.py @@ -1,6 +1,12 @@ from html import escape from abc import ABC, abstractmethod +CELL_TEMPLATE = "{name}
{value}" + +ROW_TEMPLATE = "{cells}" + +TABLE_TEMPLATE = "

{title}

{rows}
" + class Component(ABC): """Base class for message components.""" @@ -32,26 +38,25 @@ def to_plain_text(self) -> str: ) def render(self) -> str: - cells = [f""" - - {escape(name)}
- {escape(value)} - - """ for name, value in self.fields] - - rows = "" + 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.columns): row_cells = cells[i : i + self.columns] while len(row_cells) < self.columns: row_cells.append("") - rows += f"{''.join(row_cells)}" + rows.append(ROW_TEMPLATE.format(cells="".join(row_cells))) - return f"""

{escape(self.title)}

- - - {rows} - -
-""".strip() + return TABLE_TEMPLATE.format( + title=escape(self.title), + rows="".join(rows), + ) From 2b76240ebdf466dca40d5c272e817e314c57fa29 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 30 Jul 2026 15:56:00 -0700 Subject: [PATCH 8/9] unit test coverage for component feature --- tests/test_component.py | 85 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 tests/test_component.py diff --git a/tests/test_component.py b/tests/test_component.py new file mode 100644 index 0000000..2bcd2e9 --- /dev/null +++ b/tests/test_component.py @@ -0,0 +1,85 @@ +import pytest +from matrix.component import Table + + +@pytest.fixture +def table(): + return Table(title="User Info") + + +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("") == 1 + assert result.count("") == 3 + + +def test_render__with_custom_columns__expect_rows_grouped_by_column_count(): + table = Table(title="User Info", columns=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("") == 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() From 89fe3b90210b2e60d2e35cfce26e041c8fe19294 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 30 Jul 2026 16:10:59 -0700 Subject: [PATCH 9/9] added docs string for Table class and methods --- matrix/component.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/matrix/component.py b/matrix/component.py index 4ef7873..d204fd0 100644 --- a/matrix/component.py +++ b/matrix/component.py @@ -21,6 +21,13 @@ def render(self) -> str: 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, columns: int = 2) -> None: self.title: str = title self.columns: int = columns @@ -30,14 +37,51 @@ 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(
") == 4 + assert result.count("
") == 6 + assert result.count("