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
20 changes: 20 additions & 0 deletions examples/table.py
Original file line number Diff line number Diff line change
@@ -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")
2 changes: 2 additions & 0 deletions matrix/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from .space import Space
from .message import Message
from .extension import Extension
from .component import Table

__all__ = [
"Bot",
Expand All @@ -34,4 +35,5 @@
"Space",
"Message",
"Extension",
"Table",
]
109 changes: 109 additions & 0 deletions matrix/component.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
from html import escape
from abc import ABC, abstractmethod

CELL_TEMPLATE = "<td><strong>{name}</strong><br>{value}</td>"

ROW_TEMPLATE = "<tr>{cells}</tr>"

TABLE_TEMPLATE = "<h2>{title}</h2><table><tbody>{rows}</tbody></table>"


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("<td></td>")

rows.append(ROW_TEMPLATE.format(cells="".join(row_cells)))

return TABLE_TEMPLATE.format(
title=escape(self.title),
rows="".join(rows),
)
15 changes: 15 additions & 0 deletions matrix/content.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from dataclasses import dataclass
from markdown import markdown
from typing import Any
from .component import Component


class BaseMessageContent(ABC):
Expand Down Expand Up @@ -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(),
}
5 changes: 4 additions & 1 deletion matrix/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -106,6 +108,7 @@ async def cat(ctx: Context):
try:
return await self.room.send(
content,
component=component,
raw=raw,
notice=notice,
file=file,
Expand Down
28 changes: 28 additions & 0 deletions matrix/room.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -20,6 +21,7 @@
ImageContent,
AudioContent,
VideoContent,
ComponentContent,
)
from matrix.types import File, Image, Audio, Video

Expand Down Expand Up @@ -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.

Expand All @@ -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**!")

Expand All @@ -143,13 +150,34 @@ 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)

if file:
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,
Expand Down
102 changes: 102 additions & 0 deletions tests/test_component.py
Original file line number Diff line number Diff line change
@@ -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 "<h2>User Info</h2>" in result
assert "<table>" in result
assert "<strong>Name</strong>" in result
assert "Astra" in result
assert "<strong>Role</strong>" 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("<tr>") == 2
assert result.count("<td>") == 4
assert result.count("<td></td>") == 1
assert result.count("<strong>") == 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("<tr>") == 2
assert result.count("<td>") == 6
assert result.count("<td></td>") == 2
assert result.count("<strong>") == 4


def test_render__with_html_content__expect_escaped_html():
table = Table(title="<User Info>")
table.add_field("<Name>", "<Astra & Co>")

result = table.render()

assert "&lt;User Info&gt;" in result
assert "&lt;Name&gt;" in result
assert "&lt;Astra &amp; Co&gt;" in result

assert "<User Info>" not in result
assert "<Name>" not in result
assert "<Astra & Co>" not in result


def test_str__expect_rendered_html(table):
table.add_field("Name", "Astra")

assert str(table) == table.render()
Loading