Skip to content

Commit fdc3e2d

Browse files
committed
Migrate built-in commands from hand-crafted per-backend strings to FormattedMessage
executor.py — New _extract_attachments() helper converts FormattedImage/FormattedAttachment from a FormattedMessage into base Attachment objects; _coerce_response() now populates Message.attachments when returning a FormattedMessage echo.py — Replaced manual mention_users() calls with FormattedMessage + UserMention nodes help.py — Replaced per-backend if/elif rendering (Symphony <expandable-card>, Slack mrkdwn, Discord markdown table) with FormattedMessage + Heading + Table.from_dict_list() that auto-renders for each backend status.py — Same migration: builds a Table.from_dict_list() of metrics instead of manual **bold** markdown lines schedule.py — Same migration for the schedule listing test_command_framework.py — 3 new tests for _coerce_response() covering images in content, file attachments, and mixed content+attachments Signed-off-by: Tim Paine <3105306+timkpaine@users.noreply.github.com>
1 parent f320900 commit fdc3e2d

6 files changed

Lines changed: 189 additions & 94 deletions

File tree

csp_bot/commands/echo.py

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
"""Echo command for csp-bot.
22
3-
A simple command that echoes back messages.
3+
A simple command that echoes back messages using FormattedMessage.
44
"""
55

66
from logging import getLogger
77
from typing import Optional, Type
88

99
from chatom import Message
10+
from chatom.format import FormattedMessage, Text, UserMention
1011

1112
from csp_bot.structs import BotCommand
12-
from csp_bot.utils import mention_users
1313

1414
from .base import BaseCommand, BaseCommandModel, ReplyToOtherCommand
1515

@@ -31,21 +31,27 @@ def help(self) -> str:
3131
def execute(self, command: BotCommand) -> Optional[Message]:
3232
log.info(f"Echo command: {command.command}")
3333

34-
# Build content from args
35-
content = " ".join(command.args) if command.args else ""
34+
text = " ".join(command.args) if command.args else ""
35+
has_targets = bool(command.targets)
3636

37-
# Add mentions for any tagged users
38-
if command.targets:
39-
mentions = mention_users(list(command.targets), command.backend)
40-
if mentions:
41-
content = f"{content} {mentions}".strip()
42-
43-
# If nothing to echo (no args and no targets), return None
44-
if not content:
37+
if not text and not has_targets:
4538
return None
4639

40+
msg = FormattedMessage(metadata={"backend": command.backend})
41+
if text:
42+
msg.content.append(Text(content=text))
43+
for target in command.targets:
44+
if msg.content:
45+
msg.content.append(Text(content=" "))
46+
msg.content.append(
47+
UserMention(
48+
user_id=target.id,
49+
display_name=getattr(target, "display_name", "") or getattr(target, "name", "") or "",
50+
)
51+
)
52+
4753
return Message(
48-
content=content,
54+
content=msg.render_for(command.backend),
4955
channel=command.channel,
5056
metadata={"backend": command.backend},
5157
)

csp_bot/commands/executor.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515
from typing import Any, List, Optional, Union
1616

1717
from chatom import Message
18+
from chatom.base.attachment import Attachment, AttachmentType, Image as BaseImage
1819
from chatom.format import FormattedMessage
20+
from chatom.format.attachment import FormattedAttachment, FormattedImage
1921

2022
from csp_bot.structs import BotCommand
2123

@@ -43,6 +45,49 @@ def _get_event_loop() -> asyncio.AbstractEventLoop:
4345
return _loop
4446

4547

48+
def _extract_attachments(fm: FormattedMessage) -> list:
49+
"""Extract base Attachment objects from a FormattedMessage.
50+
51+
Converts FormattedAttachment/FormattedImage from both the content list
52+
and the attachments list into chatom base Attachment/Image objects
53+
suitable for Message.attachments.
54+
"""
55+
result = []
56+
for node in fm.content:
57+
if isinstance(node, FormattedImage):
58+
result.append(
59+
BaseImage(
60+
url=node.url,
61+
alt_text=node.alt_text,
62+
content_type="image/png",
63+
attachment_type=AttachmentType.IMAGE,
64+
)
65+
)
66+
elif isinstance(node, FormattedAttachment):
67+
att_type = Attachment.from_content_type(node.content_type) if node.content_type else AttachmentType.FILE
68+
result.append(
69+
Attachment(
70+
filename=node.filename,
71+
url=node.url,
72+
size=node.size,
73+
content_type=node.content_type,
74+
attachment_type=att_type,
75+
)
76+
)
77+
for fa in fm.attachments:
78+
att_type = Attachment.from_content_type(fa.content_type) if fa.content_type else AttachmentType.FILE
79+
result.append(
80+
Attachment(
81+
filename=fa.filename,
82+
url=fa.url,
83+
size=fa.size,
84+
content_type=fa.content_type,
85+
attachment_type=att_type,
86+
)
87+
)
88+
return result
89+
90+
4691
def _coerce_response(item: Any, backend: str) -> Optional[Union[Message, BotCommand]]:
4792
"""Coerce a command return value into a chatom Message.
4893
@@ -71,8 +116,10 @@ def _coerce_response(item: Any, backend: str) -> Optional[Union[Message, BotComm
71116

72117
if isinstance(item, FormattedMessage):
73118
rendered = item.render_for(backend)
119+
attachments = _extract_attachments(item)
74120
msg = Message(
75121
content=rendered,
122+
attachments=attachments,
76123
metadata={"backend": backend, "formatted": item},
77124
)
78125
return msg

csp_bot/commands/help.py

Lines changed: 38 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
"""Help command for csp-bot.
22
3-
Uses chatom's formatting utilities for cross-platform output.
3+
Uses chatom's FormattedMessage for automatic cross-platform rendering.
44
"""
55

6-
from html import escape
76
from logging import getLogger
8-
from typing import Mapping, Optional, Type
7+
from typing import Any, Mapping, Optional, Type
98

109
from chatom import Message
10+
from chatom.format import FormattedMessage, Heading, Table, Text
1111

1212
from csp_bot.structs import BotCommand
1313

@@ -16,6 +16,25 @@
1616
log = getLogger(__name__)
1717

1818

19+
def _command_backends(runner: Any) -> list:
20+
"""Get backends list from either legacy or new command types."""
21+
if isinstance(runner, BaseCommand):
22+
return runner.backends()
23+
return getattr(runner, "backends", []) or []
24+
25+
26+
def _command_name(runner: Any) -> str:
27+
if isinstance(runner, BaseCommand):
28+
return runner.name()
29+
return getattr(runner, "name", "") or ""
30+
31+
32+
def _command_help(runner: Any) -> str:
33+
if isinstance(runner, BaseCommand):
34+
return runner.help()
35+
return getattr(runner, "help", "") or ""
36+
37+
1938
class HelpCommand(ReplyCommand):
2039
"""Display help for available commands."""
2140

@@ -31,69 +50,39 @@ def help(self) -> str:
3150
def execute(
3251
self,
3352
command: BotCommand,
34-
commands: Mapping[str, "BaseCommand"] = None,
53+
commands: Mapping[str, Any] = None,
3554
) -> Optional[Message]:
3655
log.info(f"Help command: {command.command}")
3756

3857
# Collect help for each command
39-
helps = []
58+
rows = []
4059
for cmd_key, cmd_inst in commands.items():
41-
# Skip hidden commands
4260
if cmd_key.startswith("_"):
4361
continue
4462

45-
# Skip commands not supported on this backend
46-
if cmd_inst.backends() and command.backend not in cmd_inst.backends():
63+
backends = _command_backends(cmd_inst)
64+
if backends and command.backend not in backends:
4765
continue
4866

49-
# Filter by requested commands if args provided
5067
if command.args and cmd_key not in command.args:
5168
continue
5269

53-
helps.append(
54-
(
55-
escape(cmd_key, quote=True),
56-
escape(cmd_inst.name(), quote=True),
57-
escape(cmd_inst.help(), quote=True),
58-
)
70+
rows.append(
71+
{
72+
"Command": f"/{cmd_key}",
73+
"Name": _command_name(cmd_inst),
74+
"Info": _command_help(cmd_inst),
75+
}
5976
)
6077

61-
helps = sorted(helps, key=lambda x: x[0])
62-
63-
# Build response using chatom's FormattedMessage
64-
if command.backend == "symphony":
65-
# Symphony supports expandable cards
66-
table = "<table><thead><tr><td>Command</td><td>Name</td><td>Info</td></tr></thead><tbody>"
67-
for cmd_key, name, help_text in helps:
68-
table += f"<tr><td>/{cmd_key}</td><td>{name}</td><td>{help_text}</td></tr>"
69-
table += "</tbody></table>"
70-
content = f'<expandable-card state="collapsed"><header>Bot Commands Help</header><body variant="default">{table}</body></expandable-card>'
71-
elif command.backend == "slack":
72-
# Slack uses mrkdwn code blocks for tables
73-
lines = ["*Bot Commands Help*", "```"]
74-
lines.append(f"{'Command':<15} {'Name':<15} {'Info'}")
75-
lines.append("-" * 60)
76-
for cmd_key, name, help_text in helps:
77-
lines.append(f"/{cmd_key:<14} {name:<15} {help_text}")
78-
lines.append("```")
79-
content = "\n".join(lines)
80-
elif command.backend == "discord":
81-
# Discord uses markdown tables
82-
lines = ["# Bot Commands Help", ""]
83-
lines.append("| Command | Name | Info |")
84-
lines.append("|---------|------|------|")
85-
for cmd_key, name, help_text in helps:
86-
lines.append(f"| /{cmd_key} | {name} | {help_text} |")
87-
content = "\n".join(lines)
88-
else:
89-
# Plain text fallback
90-
lines = ["Bot Commands Help", ""]
91-
for cmd_key, name, help_text in helps:
92-
lines.append(f"/{cmd_key}: {name} - {help_text}")
93-
content = "\n".join(lines)
78+
rows.sort(key=lambda r: r["Command"])
79+
80+
msg = FormattedMessage(metadata={"backend": command.backend})
81+
msg.content.append(Heading(child=Text(content="Bot Commands Help"), level=3))
82+
msg.content.append(Table.from_dict_list(rows, columns=["Command", "Name", "Info"]))
9483

9584
return Message(
96-
content=content,
85+
content=msg.render_for(command.backend),
9786
channel=command.channel,
9887
metadata={"backend": command.backend},
9988
)

csp_bot/commands/schedule.py

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@
33
Allows scheduling commands for delayed or recurring execution.
44
"""
55

6-
from html import escape
76
from logging import getLogger
87
from typing import TYPE_CHECKING, List, Mapping, Optional, Type
98

109
from chatom import Message
10+
from chatom.format import Bold, FormattedMessage, Table, Text
1111
from croniter import CroniterBadCronError, croniter
1212
from dateparser import parse
1313

@@ -111,21 +111,14 @@ def preexecute(
111111
def execute(self, command: BotCommand, schedule: Mapping[str, BotCommand]) -> Message:
112112
log.info("Schedule list command")
113113

114-
# Build schedule listing
115-
if command.backend == "symphony":
116-
table = "<table><thead><tr><td>ID</td><td>Command</td></tr></thead><tbody>"
117-
for scheduled_cmd in schedule.values():
118-
table += f"<tr><td>{id(scheduled_cmd)}</td><td>{escape(scheduled_cmd.command)}</td></tr>"
119-
table += "</tbody></table>"
120-
content = f'<expandable-card state="collapsed"><header>Bot Schedule</header><body variant="default">{table}</body></expandable-card>'
121-
else:
122-
lines = ["*Scheduled Commands*", ""]
123-
for scheduled_cmd in schedule.values():
124-
lines.append(f"- {id(scheduled_cmd)}: /{scheduled_cmd.command}")
125-
content = "\n".join(lines)
114+
rows = [{"ID": str(id(scheduled_cmd)), "Command": f"/{scheduled_cmd.command}"} for scheduled_cmd in schedule.values()]
115+
116+
msg = FormattedMessage(metadata={"backend": command.backend})
117+
msg.content.append(Bold(child=Text(content="Scheduled Commands")))
118+
msg.content.append(Table.from_dict_list(rows, columns=["ID", "Command"]))
126119

127120
return Message(
128-
content=content,
121+
content=msg.render_for(command.backend),
129122
channel=command.channel,
130123
metadata={"backend": command.backend},
131124
)

csp_bot/commands/status.py

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Status command for csp-bot.
22
3-
Displays system and bot status information.
3+
Displays system and bot status information using FormattedMessage.
44
"""
55

66
from datetime import datetime
@@ -12,6 +12,7 @@
1212

1313
import psutil
1414
from chatom import Message
15+
from chatom.format import Bold, FormattedMessage, Table, Text
1516

1617
from csp_bot.structs import BotCommand
1718

@@ -47,25 +48,27 @@ def preexecute(self, command: BotCommand, bot_instance: "Bot") -> BotCommand:
4748
def execute(self, command: BotCommand) -> Optional[Message]:
4849
log.info("Status command")
4950

50-
# Build status information
51-
lines = []
52-
53-
lines.append(f"**Now**: {datetime.utcnow()}")
54-
lines.append(f"**Backends**: {', '.join(self._adapters)}")
55-
lines.append(f"**CPU**: {psutil.cpu_percent()}%")
56-
lines.append(f"**Memory**: {psutil.virtual_memory().percent}%")
57-
lines.append(f"**Memory Available**: {round(psutil.virtual_memory().available * 100 / psutil.virtual_memory().total, 2)}%")
58-
lines.append(f"**Host**: {_HOSTNAME}")
59-
lines.append(f"**User**: {_USER}")
60-
61-
current_process = psutil.Process()
62-
lines.append(f"**PID**: {current_process.pid}")
63-
lines.append(f"**Active Threads**: {active_count()}")
64-
65-
content = "\n".join(lines)
51+
mem = psutil.virtual_memory()
52+
proc = psutil.Process()
53+
54+
rows = [
55+
{"Metric": "Now", "Value": str(datetime.utcnow())},
56+
{"Metric": "Backends", "Value": ", ".join(self._adapters)},
57+
{"Metric": "CPU", "Value": f"{psutil.cpu_percent()}%"},
58+
{"Metric": "Memory", "Value": f"{mem.percent}%"},
59+
{"Metric": "Memory Available", "Value": f"{round(mem.available * 100 / mem.total, 2)}%"},
60+
{"Metric": "Host", "Value": _HOSTNAME},
61+
{"Metric": "User", "Value": _USER},
62+
{"Metric": "PID", "Value": str(proc.pid)},
63+
{"Metric": "Active Threads", "Value": str(active_count())},
64+
]
65+
66+
msg = FormattedMessage(metadata={"backend": command.backend})
67+
msg.content.append(Bold(child=Text(content="Bot Status")))
68+
msg.content.append(Table.from_dict_list(rows, columns=["Metric", "Value"]))
6669

6770
return Message(
68-
content=content,
71+
content=msg.render_for(command.backend),
6972
channel=command.channel,
7073
metadata={"backend": command.backend},
7174
)

0 commit comments

Comments
 (0)