Skip to content

Commit d10439d

Browse files
committed
fix: render AI replies as Telegram HTML so bold/italic show instead of literal asterisks (v0.14.2)
1 parent 41df9cf commit d10439d

11 files changed

Lines changed: 284 additions & 21 deletions

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
All notable changes to UnraidMonitor will be documented in this file.
44

5+
## [0.14.2] - 2026-06-05
6+
7+
### Fixed
8+
- **AI replies no longer show literal `*` characters** — the LLM returns standard CommonMark (`**bold**`, `### headings`, `*italic*`), but the natural-language chat handler sent it with no parse mode (so the asterisks rendered literally) and `/diagnose` sent it as Telegram legacy Markdown (which can't parse `**`, silently stripping the formatting). Model output is now converted to Telegram **HTML** before sending, so bold/italic/headings/code render correctly. HTML is the most forgiving parse target — text is escaped and only balanced tags are emitted, so an unmatched delimiter degrades to harmless text instead of breaking the whole message.
9+
10+
### Added
11+
- `src/utils/telegram_format.py``markdown_to_telegram_html()` converter (snake_case- and math-safe emphasis rules, fenced/inline code, links, list markers) plus `strip_html_tags()` for the plain-text fallback. Applied to NL chat, `/diagnose` (+ details), and the Diagnose alert button.
12+
513
## [0.14.1] - 2026-06-05
614

715
### Fixed

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ src/utils/version_store.py - read/write data/announced_version.json for startup
9292

9393
## Project Overview
9494

95-
Unraid Server Monitor Bot (v0.14.1) - A Docker-based Telegram bot for monitoring Unraid servers. Monitors Docker containers (events, logs, resources) and Unraid server health (CPU, memory, disks, array, UPS). Uses multi-provider LLM support (Anthropic, OpenAI, Ollama) for AI-powered diagnostics and natural language interaction. Sends alerts via Telegram with quick-action buttons.
95+
Unraid Server Monitor Bot (v0.14.2) - A Docker-based Telegram bot for monitoring Unraid servers. Monitors Docker containers (events, logs, resources) and Unraid server health (CPU, memory, disks, array, UPS). Uses multi-provider LLM support (Anthropic, OpenAI, Ollama) for AI-powered diagnostics and natural language interaction. Sends alerts via Telegram with quick-action buttons.
9696

9797
**Version string lives in TWO places**`pyproject.toml` (`version`) and `src/__init__.py` (`__version__`). Both must be bumped together on release; `tests/test_version.py` guards against drift. `BOT_VERSION` (in `src/bot/health_command.py`) resolves from installed package metadata when available, else falls back to `src.__version__` — the package is not installed in the Docker image, so the `__version__` fallback is what runs in production.
9898

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "unraid-monitor-bot"
3-
version = "0.14.1"
3+
version = "0.14.2"
44
requires-python = ">=3.11"
55
dependencies = [
66
"docker>=7.0.0,<8.0.0",

src/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.14.1"
1+
__version__ = "0.14.2"

src/bot/alert_callbacks.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Callback handlers for alert action buttons."""
22

33
import asyncio
4+
import html
45
import logging
56
import re
67
from datetime import timedelta
@@ -27,6 +28,7 @@
2728
from src.services.diagnostic import DiagnosticService
2829
from src.utils.formatting import validate_container_name, escape_markdown, truncate_callback_data, format_duration_minutes
2930
from src.utils.sanitize import sanitize_logs_for_display
31+
from src.utils.telegram_format import markdown_to_telegram_html
3032

3133
if TYPE_CHECKING:
3234
from src.alerts.array_mute_manager import ArrayMuteManager
@@ -265,15 +267,16 @@ async def handler(callback: CallbackQuery) -> None:
265267
context.brief_summary = analysis
266268
diagnostic_service.store_context(user_id, context)
267269

268-
response = f"""*Diagnosis: {escape_markdown(actual_name)}*
269-
270-
{analysis}
271-
272-
_Want more details?_"""
270+
# analysis is LLM Markdown; render the whole reply as Telegram HTML.
271+
response = (
272+
f"<b>Diagnosis: {html.escape(actual_name)}</b>\n\n"
273+
f"{markdown_to_telegram_html(analysis)}\n\n"
274+
f"<i>Want more details?</i>"
275+
)
273276

274277
if callback.message:
275278
try:
276-
await callback.message.answer(response, parse_mode="Markdown")
279+
await callback.message.answer(response, parse_mode="HTML")
277280
except TelegramBadRequest:
278281
plain_response = f"Diagnosis: {actual_name}\n\n{analysis}\n\nWant more details?"
279282
await callback.message.answer(plain_response)

src/bot/diagnose_command.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Diagnose command handler for AI-powered container analysis."""
22

3+
import html
34
import logging
45
import re
56
from typing import Callable, Awaitable
@@ -10,6 +11,7 @@
1011
from src.state import ContainerStateManager
1112
from src.services.diagnostic import DiagnosticService
1213
from src.utils.formatting import safe_reply
14+
from src.utils.telegram_format import markdown_to_telegram_html
1315

1416
logger = logging.getLogger(__name__)
1517

@@ -120,11 +122,13 @@ async def handler(message: Message) -> None:
120122
],
121123
])
122124

123-
response = f"""*Diagnosis: {actual_name}*
124-
125-
{analysis}"""
125+
# analysis is LLM Markdown; render the whole reply as Telegram HTML.
126+
response = (
127+
f"<b>Diagnosis: {html.escape(actual_name)}</b>\n\n"
128+
f"{markdown_to_telegram_html(analysis)}"
129+
)
126130

127-
await safe_reply(message, response, reply_markup=keyboard)
131+
await safe_reply(message, response, parse_mode="HTML", reply_markup=keyboard)
128132

129133
return handler
130134

@@ -153,9 +157,9 @@ async def handler(callback: CallbackQuery) -> None:
153157

154158
details = await diagnostic_service.get_details(user_id)
155159
if details:
156-
response = f"*Detailed Analysis*\n\n{details}"
160+
response = f"<b>Detailed Analysis</b>\n\n{markdown_to_telegram_html(details)}"
157161
if isinstance(callback.message, Message):
158-
await safe_reply(callback.message, response)
162+
await safe_reply(callback.message, response, parse_mode="HTML")
159163
else:
160164
if callback.message:
161165
await callback.message.answer("Could not generate detailed analysis.")

src/bot/nl_handler.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
from aiogram.filters import BaseFilter
77
from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton
88

9-
from src.utils.formatting import truncate_message
9+
from src.utils.formatting import safe_reply, truncate_message
10+
from src.utils.telegram_format import markdown_to_telegram_html
1011

1112
logger = logging.getLogger(__name__)
1213

@@ -65,7 +66,17 @@ async def handler(message: Message) -> None:
6566
]
6667
])
6768

68-
await message.answer(truncate_message(result.response), reply_markup=reply_markup)
69+
# The LLM returns CommonMark Markdown; render it as Telegram HTML so
70+
# bold/italic/headings display instead of leaking literal '*' characters.
71+
# Truncate after conversion so the final HTML stays within Telegram's
72+
# length limit (a cut tag degrades to plain text via safe_reply).
73+
html_response = truncate_message(
74+
markdown_to_telegram_html(result.response),
75+
suffix="\n\n<i>(truncated)</i>",
76+
)
77+
await safe_reply(
78+
message, html_response, parse_mode="HTML", reply_markup=reply_markup
79+
)
6980

7081
return handler
7182

src/constants.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,9 @@
9797
# Shown once when BOT_VERSION first differs from data/announced_version.json.
9898
ANNOUNCED_VERSION_PATH = "data/announced_version.json"
9999
WHATS_NEW: dict[str, list[str]] = {
100+
"0.14.2": [
101+
"Cleaner AI replies - chat and /diagnose answers now render bold, italics and code properly instead of showing raw ** asterisks",
102+
],
100103
"0.14.0": [
101104
"Turn features on from Telegram - /manage → ⚙️ Features explains and enables image-update alerts and lets you pick auto-heal containers, no config file editing",
102105
],

src/utils/formatting.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from aiogram.exceptions import TelegramBadRequest
99

1010
from src.constants import MAX_CONTAINER_NAME_LENGTH
11+
from src.utils.telegram_format import strip_html_tags
1112

1213
# Valid Docker container name pattern (alphanumeric, dash, underscore, dot, colon)
1314
# Docker allows: [a-zA-Z0-9][a-zA-Z0-9_.-]* but we also allow colons for compose names
@@ -26,18 +27,25 @@ def _strip_markdown(text: str) -> str:
2627
return text.replace("*", "").replace("`", "").replace("_", "").replace("[", "").replace("]", "")
2728

2829

30+
def _plain_fallback(text: str, parse_mode: str | None) -> str:
31+
"""Reduce formatted text to plain text for the parse-failure fallback."""
32+
if parse_mode == "HTML":
33+
return strip_html_tags(text)
34+
return _strip_markdown(text)
35+
36+
2937
async def safe_reply(
3038
message: Message,
3139
text: str,
3240
parse_mode: str = "Markdown",
3341
**kwargs: Any,
3442
) -> Message:
35-
"""Send a message with Markdown, falling back to plain text on parse failure."""
43+
"""Send a formatted message, falling back to plain text on parse failure."""
3644
try:
3745
return await message.answer(text, parse_mode=parse_mode, **kwargs)
3846
except TelegramBadRequest as e:
3947
if "can't parse entities" in str(e):
40-
return await message.answer(_strip_markdown(text), **kwargs)
48+
return await message.answer(_plain_fallback(text, parse_mode), **kwargs)
4149
raise
4250

4351

@@ -47,12 +55,12 @@ async def safe_edit(
4755
parse_mode: str = "Markdown",
4856
**kwargs: Any,
4957
) -> Message | bool:
50-
"""Edit a message with Markdown, falling back to plain text on parse failure."""
58+
"""Edit a message with formatting, falling back to plain text on parse failure."""
5159
try:
5260
return await message.edit_text(text, parse_mode=parse_mode, **kwargs) # type: ignore[union-attr]
5361
except TelegramBadRequest as e:
5462
if "can't parse entities" in str(e):
55-
return await message.edit_text(_strip_markdown(text), **kwargs) # type: ignore[union-attr]
63+
return await message.edit_text(_plain_fallback(text, parse_mode), **kwargs) # type: ignore[union-attr]
5664
raise
5765

5866

src/utils/telegram_format.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""Convert LLM-generated Markdown into Telegram-safe HTML.
2+
3+
The LLM providers (Anthropic, OpenAI, Ollama) return standard CommonMark:
4+
``**bold**``, ``# headings``, ``*italic*``, ``` ``` ``` fenced code blocks. Telegram's
5+
legacy Markdown parse mode does not understand double-asterisk bold or
6+
headings, so that output renders with literal ``*`` characters all over it
7+
(or fails to parse and gets stripped to plain text entirely).
8+
9+
Telegram's HTML parse mode is the most forgiving target for arbitrary model
10+
output: the text is HTML-escaped first and we only ever emit a small set of
11+
*balanced* tags (``<b>``, ``<i>``, ``<code>``, ``<pre>``, ``<a>``, ``<s>``), so an
12+
unmatched Markdown delimiter degrades to a harmless literal character instead
13+
of breaking the whole message. See the Telegram Bot API "HTML style" docs for
14+
the supported tag set.
15+
"""
16+
17+
import html
18+
import re
19+
20+
__all__ = ["markdown_to_telegram_html", "strip_html_tags"]
21+
22+
# Fenced code blocks: ```lang\n ... ``` (contents preserved verbatim).
23+
_FENCE_RE = re.compile(r"```[^\n]*\n?(.*?)```", re.DOTALL)
24+
# Inline code: `code`.
25+
_INLINE_CODE_RE = re.compile(r"`([^`\n]+)`")
26+
# Markdown links: [text](url).
27+
_LINK_RE = re.compile(r"\[([^\]\n]+)\]\((https?://[^)\s]+)\)")
28+
# Headings: up to three leading spaces, 1-6 '#', text, optional trailing '#'.
29+
_HEADING_RE = re.compile(r"^[ \t]{0,3}#{1,6}[ \t]+(.+?)[ \t]*#*[ \t]*$", re.MULTILINE)
30+
# Bold: **text** / __text__ (no surrounding word char for underscores).
31+
_BOLD_STAR_RE = re.compile(r"\*\*(?=\S)(.+?)(?<=\S)\*\*")
32+
_BOLD_UNDER_RE = re.compile(r"(?<!\w)__(?=\S)(.+?)(?<=\S)__(?!\w)")
33+
# Italic: *text* / _text_ (single delimiter, no adjacent space, snake_case-safe).
34+
_ITALIC_STAR_RE = re.compile(r"(?<!\*)\*(?!\s)([^*\n]+?)(?<!\s)\*(?!\*)")
35+
_ITALIC_UNDER_RE = re.compile(r"(?<!\w)_(?=\S)([^_\n]+?)(?<=\S)_(?!\w)")
36+
# Strikethrough: ~~text~~.
37+
_STRIKE_RE = re.compile(r"~~(?=\S)(.+?)(?<=\S)~~")
38+
# List markers using '*' or '+' (a literal '*' bullet would otherwise show raw).
39+
_BULLET_RE = re.compile(r"^([ \t]*)[*+][ \t]+(?=\S)", re.MULTILINE)
40+
# Placeholder token used to protect code spans from further processing.
41+
_PLACEHOLDER_RE = re.compile(r"\x00(\d+)\x00")
42+
_TAG_RE = re.compile(r"<[^>]+>")
43+
44+
45+
def markdown_to_telegram_html(text: str) -> str:
46+
"""Convert Markdown produced by an LLM into Telegram-safe HTML.
47+
48+
Args:
49+
text: Raw model output in CommonMark-ish Markdown.
50+
51+
Returns:
52+
HTML suitable for ``parse_mode="HTML"``. Returns an empty string for
53+
empty input.
54+
"""
55+
if not text:
56+
return ""
57+
58+
protected: list[str] = []
59+
60+
def _stash(fragment: str) -> str:
61+
protected.append(fragment)
62+
return f"\x00{len(protected) - 1}\x00"
63+
64+
# 1. Pull out code (fenced first, then inline) and escape its contents now,
65+
# so later Markdown/HTML passes never touch it.
66+
def _repl_fence(m: re.Match[str]) -> str:
67+
return _stash(f"<pre>{html.escape(m.group(1).rstrip(chr(10)), quote=False)}</pre>")
68+
69+
def _repl_inline_code(m: re.Match[str]) -> str:
70+
return _stash(f"<code>{html.escape(m.group(1), quote=False)}</code>")
71+
72+
text = _FENCE_RE.sub(_repl_fence, text)
73+
text = _INLINE_CODE_RE.sub(_repl_inline_code, text)
74+
75+
# 2. Escape everything else. Telegram HTML only requires &, < and > to be
76+
# escaped in text; quotes are left intact so they read naturally. Markdown
77+
# delimiters (*, _, #, [, ]) survive escaping, so the regex passes below
78+
# still work; '\x00' placeholders are untouched.
79+
text = html.escape(text, quote=False)
80+
81+
# 3. Links -> <a>. The captured url already had '&' escaped to '&amp;'; also
82+
# make any quote attribute-safe inside the href.
83+
def _repl_link(m: re.Match[str]) -> str:
84+
label, url = m.group(1), m.group(2).replace('"', "&quot;")
85+
return _stash(f'<a href="{url}">{label}</a>')
86+
87+
text = _LINK_RE.sub(_repl_link, text)
88+
89+
# 4. Headings -> bold line.
90+
text = _HEADING_RE.sub(r"<b>\1</b>", text)
91+
92+
# 5. Bold, then italic, then strikethrough.
93+
text = _BOLD_STAR_RE.sub(r"<b>\1</b>", text)
94+
text = _BOLD_UNDER_RE.sub(r"<b>\1</b>", text)
95+
text = _ITALIC_STAR_RE.sub(r"<i>\1</i>", text)
96+
text = _ITALIC_UNDER_RE.sub(r"<i>\1</i>", text)
97+
text = _STRIKE_RE.sub(r"<s>\1</s>", text)
98+
99+
# 6. Normalise '*'/'+' bullets to a clean glyph ('-' bullets already read
100+
# fine and are left as-is).
101+
text = _BULLET_RE.sub(r"\1• ", text)
102+
103+
# 7. Collapse the doubled tags a heading-with-bold produces (e.g. '## **x**').
104+
text = text.replace("<b><b>", "<b>").replace("</b></b>", "</b>")
105+
106+
# 8. Restore protected code/link fragments.
107+
text = _PLACEHOLDER_RE.sub(lambda m: protected[int(m.group(1))], text)
108+
return text
109+
110+
111+
def strip_html_tags(text: str) -> str:
112+
"""Strip HTML tags and unescape entities for a plain-text fallback."""
113+
return html.unescape(_TAG_RE.sub("", text))

0 commit comments

Comments
 (0)