From 6868a9a93d0cc348f5bced7185d709333089fe7b Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 17 May 2026 22:17:21 +0800 Subject: [PATCH] feat(awooop): link telegram alerts to incident runs --- apps/api/src/api/v1/platform/operator_runs.py | 4 +- .../src/services/platform_operator_service.py | 34 ++++++++++-- apps/api/src/services/telegram_gateway.py | 54 ++++++++++++++++--- .../test_awooop_operator_timeline_labels.py | 13 +++++ .../tests/test_telegram_message_templates.py | 36 +++++++++++++ apps/web/messages/en.json | 4 +- apps/web/messages/zh-TW.json | 4 +- .../web/src/app/[locale]/awooop/runs/page.tsx | 29 +++++++++- 8 files changed, 163 insertions(+), 15 deletions(-) diff --git a/apps/api/src/api/v1/platform/operator_runs.py b/apps/api/src/api/v1/platform/operator_runs.py index 357b9015..ae858d22 100644 --- a/apps/api/src/api/v1/platform/operator_runs.py +++ b/apps/api/src/api/v1/platform/operator_runs.py @@ -97,7 +97,7 @@ class DecideApprovalResponse(BaseModel): response_model=ListRunsResponse, summary="列出 Runs", description=( - "返回 awooop_run_state 記錄,支援 project_id / state / remediation_status filter 與分頁。\n\n" + "返回 awooop_run_state 記錄,支援 project_id / state / remediation_status / incident_id filter 與分頁。\n\n" "- 按 created_at DESC 排序\n" "- 注意:此路徑為 /runs/list 以避免與 runs.py 的 /runs/{run_id} 衝突" ), @@ -109,6 +109,7 @@ async def list_runs( None, description="AI 補救證據狀態 filter(no_evidence/read_only_dry_run/write_observed/blocked/observed)", ), + incident_id: str | None = Query(None, description="關聯 Incident ID filter(可選)"), page: int = Query(1, ge=1, description="頁碼,從 1 開始"), per_page: int = Query(_DEFAULT_PER_PAGE, ge=1, le=_MAX_PER_PAGE, description="每頁筆數"), ) -> dict[str, Any]: @@ -116,6 +117,7 @@ async def list_runs( project_id=project_id, state=state, remediation_status=remediation_status, + incident_id=incident_id, page=page, per_page=per_page, ) diff --git a/apps/api/src/services/platform_operator_service.py b/apps/api/src/services/platform_operator_service.py index e929bd17..50248a6a 100644 --- a/apps/api/src/services/platform_operator_service.py +++ b/apps/api/src/services/platform_operator_service.py @@ -141,11 +141,13 @@ async def list_runs( project_id: str | None, state: str | None, remediation_status: str | None, + incident_id: str | None, page: int, per_page: int, ) -> dict[str, Any]: - """列出 runs,支援 project_id、state、remediation_status filter 與分頁。""" + """列出 runs,支援 project_id、state、remediation_status、incident_id filter 與分頁。""" _validate_remediation_status_filter(remediation_status) + _validate_incident_id_filter(incident_id) async with get_db_context("awoooi") as db: stmt = select(AwoooPRunState).order_by(AwoooPRunState.created_at.desc()) @@ -155,7 +157,7 @@ async def list_runs( stmt = stmt.where(AwoooPRunState.state == state) offset = (page - 1) * per_page - if remediation_status: + if remediation_status or incident_id: result = await db.execute(stmt) candidate_rows = list(result.scalars().all()) context_limit = _list_filter_context_limit(len(candidate_rows)) @@ -176,6 +178,10 @@ async def list_runs( remediation_summaries.get(row.run_id), remediation_status, ) + and _remediation_summary_matches_incident_id( + remediation_summaries.get(row.run_id), + incident_id, + ) ] total = len(filtered_rows) rows = filtered_rows[offset : offset + per_page] @@ -521,14 +527,36 @@ def _validate_remediation_status_filter(value: str | None) -> None: ) +def _validate_incident_id_filter(value: str | None) -> None: + if value is None: + return + if not _INCIDENT_ID_RE.fullmatch(value): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="incident_id 格式錯誤,必須是 INC-YYYYMMDD-XXXX", + ) + + def _remediation_summary_matches_status( summary: dict[str, Any] | None, - remediation_status: str, + remediation_status: str | None, ) -> bool: + if remediation_status is None: + return True status_value = str((summary or {}).get("status") or "no_evidence") return status_value == remediation_status +def _remediation_summary_matches_incident_id( + summary: dict[str, Any] | None, + incident_id: str | None, +) -> bool: + if incident_id is None: + return True + incident_ids = (summary or {}).get("incident_ids") + return isinstance(incident_ids, list) and incident_id in incident_ids + + async def _build_run_remediation_summaries( *, runs: list[AwoooPRunState], diff --git a/apps/api/src/services/telegram_gateway.py b/apps/api/src/services/telegram_gateway.py index 45c1a771..423d26f7 100644 --- a/apps/api/src/services/telegram_gateway.py +++ b/apps/api/src/services/telegram_gateway.py @@ -30,6 +30,7 @@ import os import re from dataclasses import dataclass from datetime import UTC, datetime +from urllib.parse import quote from uuid import NAMESPACE_URL, UUID, uuid5 import httpx @@ -71,6 +72,7 @@ _TELEGRAM_BOT_URL_RE = re.compile(r"(api\.telegram\.org/bot)[^/\s]+") _INCIDENT_ID_RE = re.compile(r"\bINC-\d{8}-[A-Z0-9]{4,}\b") _CODE_REF_RE = re.compile(r"([0-9a-f]{7,12})", re.IGNORECASE) _TELEGRAM_HTML_CHUNK_LIMIT = 3600 +_AWOOOP_WEB_BASE_URL = "https://awoooi.wooo.work" def _top_gateway_bucket( @@ -196,6 +198,23 @@ def _format_remediation_history_lines(history: dict[str, object] | None) -> list ] +def _awooop_runs_url_for_incident(incident_id: str) -> str: + safe_incident_id = quote(str(incident_id or ""), safe="") + return ( + f"{_AWOOOP_WEB_BASE_URL}/zh-TW/awooop/runs" + f"?project_id=awoooi&incident_id={safe_incident_id}" + ) + + +def _awooop_runs_button_row(incident_id: str) -> list[dict[str, str]]: + if not incident_id: + return [] + return [{ + "text": "🧭 AwoooP", + "url": _awooop_runs_url_for_incident(incident_id), + }] + + def _latest_remediation_history_item(history: dict[str, object] | None) -> dict[str, object]: if not history: return {} @@ -2187,6 +2206,10 @@ class TelegramGateway: tuning_nonce = self._security.generate_callback_nonce(approval_id, "tune") buttons.append([{"text": "⚡ 執行自動調優", "callback_data": tuning_nonce}]) + awooop_row = _awooop_runs_button_row(incident_id) + if awooop_row: + buttons.append(awooop_row) + return {"inline_keyboard": buttons} # ── YAML Fallback 路徑(原有邏輯,不改動任何行為)──────────────────── @@ -2232,6 +2255,9 @@ class TelegramGateway: {"text": "📋 詳情", "callback_data": f"detail:{incident_id}"}, {"text": "🔕 忽略", "callback_data": silence_nonce}, ]) + awooop_row = _awooop_runs_button_row(incident_id) + if awooop_row: + rows.append(awooop_row) buttons = rows else: # 舊版通用鍵(向下相容) @@ -2249,6 +2275,9 @@ class TelegramGateway: {"text": "🔄 重診", "callback_data": f"reanalyze:{incident_id}"}, {"text": "📊 歷史", "callback_data": f"history:{incident_id}"}, ]) + awooop_row = _awooop_runs_button_row(incident_id) + if awooop_row: + buttons.append(awooop_row) logger.info( "telegram_keyboard_built", @@ -2837,12 +2866,14 @@ class TelegramGateway: # read-only 查類按鈕(2-part info 格式,handler 已在 handle_callback 實作) # detail/history 均在 INFO_ACTIONS 白名單,無 nonce 無副作用 - keyboard = { - "inline_keyboard": [[ - {"text": "📋 詳情", "callback_data": f"detail:{incident_id}"}, - {"text": "📊 歷史", "callback_data": f"history:{incident_id}"}, - ]] - } + inline_keyboard = [[ + {"text": "📋 詳情", "callback_data": f"detail:{incident_id}"}, + {"text": "📊 歷史", "callback_data": f"history:{incident_id}"}, + ]] + awooop_row = _awooop_runs_button_row(incident_id) + if awooop_row: + inline_keyboard.append(awooop_row) + keyboard = {"inline_keyboard": inline_keyboard} return await self._send_request( "sendMessage", { @@ -5189,13 +5220,17 @@ class TelegramGateway: # Step 1: 換掉按鈕 (移除 Row 1 批准/拒絕/靜默,保留 Row 2 資訊按鈕) if keep_info_buttons: - new_keyboard = {"inline_keyboard": [ + inline_keyboard = [ [ {"text": "📋 詳情", "callback_data": f"detail:{incident_id}"}, {"text": "🔄 重診", "callback_data": f"reanalyze:{incident_id}"}, {"text": "📊 歷史", "callback_data": f"history:{incident_id}"}, ], - ]} + ] + awooop_row = _awooop_runs_button_row(incident_id) + if awooop_row: + inline_keyboard.append(awooop_row) + new_keyboard = {"inline_keyboard": inline_keyboard} else: new_keyboard = {"inline_keyboard": []} @@ -6942,6 +6977,9 @@ class TelegramGateway: {"text": "📋 詳情", "callback_data": f"detail:{incident_id}"}, {"text": "📊 歷史", "callback_data": f"history:{incident_id}"}, ]] + awooop_row = _awooop_runs_button_row(incident_id) + if awooop_row: + info_buttons.append(awooop_row) await self._send_request( "editMessageReplyMarkup", { diff --git a/apps/api/tests/test_awooop_operator_timeline_labels.py b/apps/api/tests/test_awooop_operator_timeline_labels.py index 86ffe2ec..452521ad 100644 --- a/apps/api/tests/test_awooop_operator_timeline_labels.py +++ b/apps/api/tests/test_awooop_operator_timeline_labels.py @@ -6,6 +6,7 @@ from src.services.platform_operator_service import ( _list_filter_context_limit, _outbound_timeline_title, _run_remediation_list_summary, + _remediation_summary_matches_incident_id, _remediation_summary_matches_status, _remediation_timeline_summary, _timeline_sort_key, @@ -172,6 +173,18 @@ def test_remediation_summary_matches_status_filter() -> None: assert _remediation_summary_matches_status(None, "no_evidence") +def test_remediation_summary_matches_incident_id_filter() -> None: + assert _remediation_summary_matches_incident_id( + {"incident_ids": ["INC-20260514-F85F21"]}, + "INC-20260514-F85F21", + ) + assert not _remediation_summary_matches_incident_id( + {"incident_ids": ["INC-20260514-F85F21"]}, + "INC-20260513-79ED5E", + ) + assert _remediation_summary_matches_incident_id(None, None) + + def test_list_filter_context_limit_scales_with_candidate_rows() -> None: assert _list_filter_context_limit(2) == 500 assert _list_filter_context_limit(4176) == 16704 diff --git a/apps/api/tests/test_telegram_message_templates.py b/apps/api/tests/test_telegram_message_templates.py index bbd92d68..a2e33fd9 100644 --- a/apps/api/tests/test_telegram_message_templates.py +++ b/apps/api/tests/test_telegram_message_templates.py @@ -71,6 +71,42 @@ def test_telegram_html_chunks_preserve_complete_lines() -> None: assert all(chunk.count("") == chunk.count("") for chunk in chunks) +def test_awooop_runs_url_for_incident_uses_public_incident_filter() -> None: + """Telegram URL button 必須導到公開 AwoooP Run list,並帶 incident filter。""" + url = telegram_gateway_module._awooop_runs_url_for_incident("INC-20260514-F85F21") + + assert url == ( + "https://awoooi.wooo.work/zh-TW/awooop/runs" + "?project_id=awoooi&incident_id=INC-20260514-F85F21" + ) + + +@pytest.mark.asyncio +async def test_build_inline_keyboard_includes_awooop_deep_link() -> None: + """主告警卡的 read-only 按鈕列要能回到 AwoooP evidence view。""" + gateway = TelegramGateway() + + keyboard = await gateway._build_inline_keyboard( + approval_id="INC-20260514-F85F21", + include_auto_tuning=False, + incident_id="INC-20260514-F85F21", + ) + buttons = [ + button + for row in keyboard["inline_keyboard"] + for button in row + ] + + awooop_buttons = [button for button in buttons if button["text"] == "🧭 AwoooP"] + assert awooop_buttons == [{ + "text": "🧭 AwoooP", + "url": ( + "https://awoooi.wooo.work/zh-TW/awooop/runs" + "?project_id=awoooi&incident_id=INC-20260514-F85F21" + ), + }] + + @pytest.mark.asyncio async def test_send_html_line_message_falls_back_to_plain_text_on_parse_error(monkeypatch): """Telegram HTML parse 400 時要送純文字 fallback,不可回報成歷史查詢失敗。""" diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 6dbaa7d1..a199846d 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1804,7 +1804,9 @@ "manualGate": "Next: human approval", "filters": { "label": "AI evidence filter", - "all": "All AI evidence" + "all": "All AI evidence", + "incidentLabel": "Incident ID filter", + "incidentPlaceholder": "Enter Incident ID" }, "statuses": { "noEvidence": "No dry-run yet", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 16df25e9..4cb3b57d 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -1805,7 +1805,9 @@ "manualGate": "下一步:人工審批", "filters": { "label": "AI 證據篩選", - "all": "所有 AI 證據" + "all": "所有 AI 證據", + "incidentLabel": "Incident ID 篩選", + "incidentPlaceholder": "輸入 Incident ID" }, "statuses": { "noEvidence": "尚無試跑", diff --git a/apps/web/src/app/[locale]/awooop/runs/page.tsx b/apps/web/src/app/[locale]/awooop/runs/page.tsx index 49a4b9af..4016d768 100644 --- a/apps/web/src/app/[locale]/awooop/runs/page.tsx +++ b/apps/web/src/app/[locale]/awooop/runs/page.tsx @@ -116,6 +116,7 @@ interface RecentEventsResponse { const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ""; const PER_PAGE = 50; const AUTO_REFRESH_INTERVAL = 30_000; // 30 秒 +const INCIDENT_ID_FILTER_RE = /^INC-\d{8}-[A-Z0-9]{4,}$/; const STATE_CONFIG: Record< RunState, @@ -518,10 +519,20 @@ export default function RunsPage() { const [projectFilter, setProjectFilter] = useState(""); const [statusFilter, setStatusFilter] = useState(""); const [evidenceFilter, setEvidenceFilter] = useState<"" | RemediationStatus>(""); + const [incidentFilter, setIncidentFilter] = useState(""); const [page, setPage] = useState(1); const [lastRefresh, setLastRefresh] = useState(null); const intervalRef = useRef | null>(null); + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const linkedProject = params.get("project_id") || ""; + const linkedIncident = params.get("incident_id") || ""; + if (linkedProject) setProjectFilter(linkedProject); + if (linkedIncident) setIncidentFilter(linkedIncident); + if (linkedProject || linkedIncident) setPage(1); + }, []); + // 取得租戶清單 useEffect(() => { fetch(`${API_BASE}/api/v1/platform/tenants`) @@ -540,6 +551,10 @@ export default function RunsPage() { if (projectFilter) params.set("project_id", projectFilter); if (statusFilter) params.set("state", statusFilter); if (evidenceFilter) params.set("remediation_status", evidenceFilter); + const normalizedIncidentFilter = incidentFilter.trim().toUpperCase(); + if (INCIDENT_ID_FILTER_RE.test(normalizedIncidentFilter)) { + params.set("incident_id", normalizedIncidentFilter); + } params.set("page", String(page)); params.set("per_page", String(PER_PAGE)); @@ -572,7 +587,7 @@ export default function RunsPage() { } finally { setLoading(false); } - }, [projectFilter, statusFilter, evidenceFilter, page]); + }, [projectFilter, statusFilter, evidenceFilter, incidentFilter, page]); // 初次載入 useEffect(() => { @@ -791,6 +806,18 @@ export default function RunsPage() {