diff --git a/apps/api/src/services/telegram_gateway.py b/apps/api/src/services/telegram_gateway.py index b96f6797..af18a3a5 100644 --- a/apps/api/src/services/telegram_gateway.py +++ b/apps/api/src/services/telegram_gateway.py @@ -215,6 +215,13 @@ def _awooop_runs_button_row(incident_id: str) -> list[dict[str, str]]: }] +def _awooop_runs_reply_markup(incident_id: str) -> dict | None: + row = _awooop_runs_button_row(incident_id) + if not row: + return None + return {"inline_keyboard": [row]} + + def _latest_remediation_history_item(history: dict[str, object] | None) -> dict[str, object]: if not history: return {} @@ -5549,6 +5556,7 @@ class TelegramGateway: await self._send_html_line_message( lines, failure_context="incident_detail", + reply_markup=_awooop_runs_reply_markup(incident_id), ) except Exception as e: @@ -5687,6 +5695,7 @@ class TelegramGateway: await self._send_html_line_message( lines, failure_context="incident_history", + reply_markup=_awooop_runs_reply_markup(incident_id), ) except Exception as e: @@ -5906,18 +5915,22 @@ class TelegramGateway: *, chat_id: str | int | None = None, failure_context: str, + reply_markup: dict | None = None, ) -> None: """Send a multi-line HTML message without cutting Telegram tags in half.""" chunks = _telegram_html_chunks(lines) for index, chunk in enumerate(chunks): try: + payload: dict = { + "chat_id": chat_id or self.alert_chat_id, + "text": chunk, + "parse_mode": "HTML", + } + if index == 0 and reply_markup: + payload["reply_markup"] = reply_markup await self._send_request( "sendMessage", - { - "chat_id": chat_id or self.alert_chat_id, - "text": chunk, - "parse_mode": "HTML", - }, + payload, ) except Exception as exc: logger.warning( @@ -5927,12 +5940,15 @@ class TelegramGateway: chunk_count=len(chunks), error=str(exc), ) + fallback_payload: dict = { + "chat_id": chat_id or self.alert_chat_id, + "text": _plain_text_from_html(chunk), + } + if index == 0 and reply_markup: + fallback_payload["reply_markup"] = reply_markup await self._send_request( "sendMessage", - { - "chat_id": chat_id or self.alert_chat_id, - "text": _plain_text_from_html(chunk), - }, + fallback_payload, ) async def send_alert_notification( diff --git a/apps/api/tests/test_telegram_message_templates.py b/apps/api/tests/test_telegram_message_templates.py index a2e33fd9..5238288c 100644 --- a/apps/api/tests/test_telegram_message_templates.py +++ b/apps/api/tests/test_telegram_message_templates.py @@ -112,6 +112,7 @@ async def test_send_html_line_message_falls_back_to_plain_text_on_parse_error(mo """Telegram HTML parse 400 時要送純文字 fallback,不可回報成歷史查詢失敗。""" sent_requests = [] gateway = TelegramGateway() + reply_markup = telegram_gateway_module._awooop_runs_reply_markup("INC-20260514-F85F21") async def fake_send_request(method, payload): sent_requests.append((method, payload)) @@ -125,15 +126,46 @@ async def test_send_html_line_message_falls_back_to_plain_text_on_parse_error(mo ["📊 事件歷史統計", "階段: blocked"], chat_id="chat", failure_context="test_history", + reply_markup=reply_markup, ) assert len(sent_requests) == 2 assert sent_requests[0][1]["parse_mode"] == "HTML" + assert sent_requests[0][1]["reply_markup"] == reply_markup assert "parse_mode" not in sent_requests[1][1] + assert sent_requests[1][1]["reply_markup"] == reply_markup assert "" not in sent_requests[1][1]["text"] assert "blocked" in sent_requests[1][1]["text"] +@pytest.mark.asyncio +async def test_send_html_line_message_attaches_awooop_markup_to_first_chunk(monkeypatch): + """詳情/歷史這類 HTML reply 要能帶 AwoooP evidence URL,且長訊息只掛第一段。""" + sent_requests = [] + gateway = TelegramGateway() + reply_markup = telegram_gateway_module._awooop_runs_reply_markup("INC-20260514-F85F21") + + async def fake_send_request(method, payload): + sent_requests.append((method, payload)) + return {"ok": True} + + monkeypatch.setattr(gateway, "_send_request", fake_send_request) + + await gateway._send_html_line_message( + ["📊 事件歷史統計"] + [ + f"INC-20260514-F85F21 trace line {index:03d}" + for index in range(80) + ], + chat_id="chat", + failure_context="test_history", + reply_markup=reply_markup, + ) + + assert len(sent_requests) > 1 + assert sent_requests[0][1]["reply_markup"] == reply_markup + assert all("reply_markup" not in payload for _, payload in sent_requests[1:]) + + class TestTelegramMessageFormat: """測試現有 TelegramMessage 格式化""" diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index a199846d..234e9e6d 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1808,6 +1808,12 @@ "incidentLabel": "Incident ID filter", "incidentPlaceholder": "Enter Incident ID" }, + "incident": { + "column": "Incident", + "empty": "Not linked", + "filterTitle": "Show only {incidentId}", + "more": "+{count} more" + }, "statuses": { "noEvidence": "No dry-run yet", "readOnlyDryRun": "AI dry-run: read-only", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 4cb3b57d..0e763cbf 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -1809,6 +1809,12 @@ "incidentLabel": "Incident ID 篩選", "incidentPlaceholder": "輸入 Incident ID" }, + "incident": { + "column": "Incident", + "empty": "尚未關聯", + "filterTitle": "只看 {incidentId}", + "more": "+{count} 筆" + }, "statuses": { "noEvidence": "尚無試跑", "readOnlyDryRun": "AI 已試跑:只讀", diff --git a/apps/web/src/app/[locale]/awooop/runs/page.tsx b/apps/web/src/app/[locale]/awooop/runs/page.tsx index 4016d768..35795d67 100644 --- a/apps/web/src/app/[locale]/awooop/runs/page.tsx +++ b/apps/web/src/app/[locale]/awooop/runs/page.tsx @@ -379,6 +379,55 @@ function RemediationEvidenceCell({ summary }: { summary?: RemediationSummary | n ); } +function linkedIncidentIds(summary?: RemediationSummary | null): string[] { + const rawIds = summary?.incident_ids ?? []; + return Array.from( + new Set( + rawIds + .map((incidentId) => String(incidentId || "").trim().toUpperCase()) + .filter((incidentId) => INCIDENT_ID_FILTER_RE.test(incidentId)) + ) + ); +} + +function IncidentIdsCell({ run }: { run: Run }) { + const t = useTranslations("awooop.listEvidence"); + const incidentIds = linkedIncidentIds(run.remediation_summary); + const visibleIds = incidentIds.slice(0, 2); + const hiddenCount = Math.max(incidentIds.length - visibleIds.length, 0); + + if (visibleIds.length === 0) { + return ( + + {t("incident.empty")} + + ); + } + + return ( +
+ {visibleIds.map((incidentId) => ( + + {incidentId} + + ))} + {hiddenCount > 0 && ( + + {t("incident.more", { count: hiddenCount })} + + )} +
+ ); +} + function RunRow({ run }: { run: Run }) { const formattedDate = run.created_at ? new Date(run.created_at).toLocaleDateString("zh-TW", { @@ -406,6 +455,9 @@ function RunRow({ run }: { run: Run }) { {run.project_id || "--"} + + + {run.agent_id || "--"} @@ -843,6 +895,9 @@ export default function RunsPage() { Project ID + + {tEvidence("incident.column")} + Agent @@ -870,7 +925,7 @@ export default function RunsPage() { {loading ? ( Array.from({ length: 8 }).map((_, i) => ( - {Array.from({ length: 9 }).map((_, j) => ( + {Array.from({ length: 10 }).map((_, j) => (
@@ -879,7 +934,7 @@ export default function RunsPage() { )) ) : runs.length === 0 && !error ? ( - +