diff --git a/apps/api/src/services/approval_execution.py b/apps/api/src/services/approval_execution.py index fb46127ea..ee03ed817 100644 --- a/apps/api/src/services/approval_execution.py +++ b/apps/api/src/services/approval_execution.py @@ -27,7 +27,6 @@ import asyncio import html import time from typing import TYPE_CHECKING, Any -from urllib.parse import quote from uuid import UUID import structlog @@ -40,9 +39,10 @@ from src.plugins.mcp.gateway import GatewayContext, McpGateway, McpGatewayError from src.plugins.mcp.interfaces import MCPToolResult from src.services.approval_action_classifier import is_no_action_approval_action from src.services.approval_db import get_approval_service, get_timeline_service +from src.services.awooop_deeplinks import incident_truth_chain_button_row from src.services.executor import ExecutionResult, OperationType, get_executor -from src.services.operator_outcome import build_operator_outcome from src.services.operation_parser import parse_operation_from_action +from src.services.operator_outcome import build_operator_outcome if TYPE_CHECKING: from src.services.notifications import ExecutionStatus @@ -1046,24 +1046,19 @@ class ApprovalExecutionService: km_info=km_info, outcome=outcome, ) - reply_markup = { - "inline_keyboard": [[ - { - "text": "🧭 AwoooP", - "url": ( - "https://awoooi.wooo.work/zh-TW/awooop/runs" - f"?project_id=awoooi&incident_id={quote(approval.incident_id or '')}" - ), - } - ]] - } + truth_chain_row = incident_truth_chain_button_row( + approval.incident_id or "" + ) payload: dict[str, Any] = { "chat_id": target_chat_id, "text": text, "parse_mode": "HTML", - "reply_markup": reply_markup, "disable_web_page_preview": True, } + if truth_chain_row: + payload["reply_markup"] = { + "inline_keyboard": [truth_chain_row], + } if orig_msg_id is not None: payload["reply_to_message_id"] = orig_msg_id diff --git a/apps/api/src/services/awooop_deeplinks.py b/apps/api/src/services/awooop_deeplinks.py new file mode 100644 index 000000000..8e0fcbc86 --- /dev/null +++ b/apps/api/src/services/awooop_deeplinks.py @@ -0,0 +1,63 @@ +"""Shared AwoooP operator deeplinks used by Telegram-facing services.""" + +from urllib.parse import quote + +AWOOOP_WEB_BASE_URL = "https://awoooi.wooo.work" + + +def incident_runs_url( + incident_id: str, + *, + project_id: str = "awoooi", + locale: str = "zh-TW", +) -> str: + safe_project_id = quote(str(project_id or "awoooi"), safe="") + safe_incident_id = quote(str(incident_id or ""), safe="") + return ( + f"{AWOOOP_WEB_BASE_URL}/{locale}/awooop/runs" + f"?project_id={safe_project_id}&incident_id={safe_incident_id}" + ) + + +def incident_alerts_url( + incident_id: str, + *, + project_id: str = "awoooi", + locale: str = "zh-TW", +) -> str: + safe_project_id = quote(str(project_id or "awoooi"), safe="") + safe_incident_id = quote(str(incident_id or ""), safe="") + return ( + f"{AWOOOP_WEB_BASE_URL}/{locale}/alerts" + f"?project_id={safe_project_id}&incident_id={safe_incident_id}" + ) + + +def incident_truth_chain_button_row( + incident_id: str, + *, + project_id: str = "awoooi", +) -> list[dict[str, str]]: + if not incident_id: + return [] + return [ + { + "text": "πŸ”Ž ηœŸη›Έιˆ", + "url": incident_alerts_url(incident_id, project_id=project_id), + }, + { + "text": "🧭 Runs", + "url": incident_runs_url(incident_id, project_id=project_id), + }, + ] + + +def incident_truth_chain_reply_markup( + incident_id: str, + *, + project_id: str = "awoooi", +) -> dict | None: + row = incident_truth_chain_button_row(incident_id, project_id=project_id) + if not row: + return None + return {"inline_keyboard": [row]} diff --git a/apps/api/src/services/telegram_gateway.py b/apps/api/src/services/telegram_gateway.py index ef8f3662a..b6cb60263 100644 --- a/apps/api/src/services/telegram_gateway.py +++ b/apps/api/src/services/telegram_gateway.py @@ -30,7 +30,6 @@ 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 @@ -40,13 +39,19 @@ from opentelemetry import trace from src.core.config import settings from src.core.redis_client import get_redis from src.services.awooop_ansible_audit_service import summarize_ansible_execution +from src.services.awooop_deeplinks import ( + incident_alerts_url, + incident_runs_url, + incident_truth_chain_button_row, + incident_truth_chain_reply_markup, +) +from src.services.chat_manager import get_chat_manager from src.services.operator_outcome import build_operator_outcome from src.services.security_interceptor import ( NonceReplayError, UserNotWhitelistedError, get_security_interceptor, ) -from src.services.chat_manager import get_chat_manager # ============================================================================= # Snooze/Silence Redis Keys (2026-03-27 P1 ε„ͺεŒ–) @@ -78,7 +83,6 @@ _AI_ADVISORY_CALLBACK_RE = re.compile( ) _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" _AWOOOP_SOURCE_ENVELOPE_EXTRA_KEY = "_awooop_source_envelope_extra" @@ -249,27 +253,32 @@ 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}" - ) + return incident_runs_url(incident_id) + + +def _awooop_alerts_url_for_incident(incident_id: str) -> str: + return incident_alerts_url(incident_id) def _awooop_runs_button_row(incident_id: str) -> list[dict[str, str]]: if not incident_id: return [] return [{ - "text": "🧭 AwoooP", + "text": "🧭 Runs", "url": _awooop_runs_url_for_incident(incident_id), }] +def _awooop_truth_chain_button_row(incident_id: str) -> list[dict[str, str]]: + return incident_truth_chain_button_row(incident_id) + + +def _awooop_truth_chain_reply_markup(incident_id: str) -> dict | None: + return incident_truth_chain_reply_markup(incident_id) + + 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]} + return _awooop_truth_chain_reply_markup(incident_id) def _latest_remediation_history_item(history: dict[str, object] | None) -> dict[str, object]: @@ -3578,7 +3587,7 @@ 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) + awooop_row = _awooop_truth_chain_button_row(incident_id) if awooop_row: buttons.append(awooop_row) @@ -3627,7 +3636,7 @@ class TelegramGateway: {"text": "πŸ“‹ θ©³ζƒ…", "callback_data": f"detail:{incident_id}"}, {"text": "πŸ”• εΏ½η•₯", "callback_data": silence_nonce}, ]) - awooop_row = _awooop_runs_button_row(incident_id) + awooop_row = _awooop_truth_chain_button_row(incident_id) if awooop_row: rows.append(awooop_row) buttons = rows @@ -3647,7 +3656,7 @@ class TelegramGateway: {"text": "πŸ”„ 重診", "callback_data": f"reanalyze:{incident_id}"}, {"text": "πŸ“Š 歷史", "callback_data": f"history:{incident_id}"}, ]) - awooop_row = _awooop_runs_button_row(incident_id) + awooop_row = _awooop_truth_chain_button_row(incident_id) if awooop_row: buttons.append(awooop_row) @@ -4242,7 +4251,7 @@ class TelegramGateway: {"text": "πŸ“‹ θ©³ζƒ…", "callback_data": f"detail:{incident_id}"}, {"text": "πŸ“Š 歷史", "callback_data": f"history:{incident_id}"}, ]] - awooop_row = _awooop_runs_button_row(incident_id) + awooop_row = _awooop_truth_chain_button_row(incident_id) if awooop_row: inline_keyboard.append(awooop_row) keyboard = {"inline_keyboard": inline_keyboard} @@ -6628,7 +6637,7 @@ class TelegramGateway: {"text": "πŸ“Š 歷史", "callback_data": f"history:{incident_id}"}, ], ] - awooop_row = _awooop_runs_button_row(incident_id) + awooop_row = _awooop_truth_chain_button_row(incident_id) if awooop_row: inline_keyboard.append(awooop_row) new_keyboard = {"inline_keyboard": inline_keyboard} @@ -6997,7 +7006,7 @@ class TelegramGateway: await self._send_html_line_message( lines, failure_context="incident_detail", - reply_markup=_awooop_runs_reply_markup(incident_id), + reply_markup=_awooop_truth_chain_reply_markup(incident_id), incident_id=incident_id, callback_action="detail", km_stale_completion_summary=km_completion_summary, @@ -7182,7 +7191,7 @@ class TelegramGateway: await self._send_html_line_message( lines, failure_context="incident_history", - reply_markup=_awooop_runs_reply_markup(incident_id), + reply_markup=_awooop_truth_chain_reply_markup(incident_id), incident_id=incident_id, callback_action="history", km_stale_completion_summary=km_completion_summary, @@ -8580,7 +8589,7 @@ class TelegramGateway: {"text": "πŸ“‹ θ©³ζƒ…", "callback_data": f"detail:{incident_id}"}, {"text": "πŸ“Š 歷史", "callback_data": f"history:{incident_id}"}, ]] - awooop_row = _awooop_runs_button_row(incident_id) + awooop_row = _awooop_truth_chain_button_row(incident_id) if awooop_row: info_buttons.append(awooop_row) await self._send_request( diff --git a/apps/api/tests/test_telegram_message_templates.py b/apps/api/tests/test_telegram_message_templates.py index 607906543..8822ef097 100644 --- a/apps/api/tests/test_telegram_message_templates.py +++ b/apps/api/tests/test_telegram_message_templates.py @@ -672,9 +672,47 @@ def test_awooop_runs_url_for_incident_uses_public_incident_filter() -> None: ) +def test_awooop_alerts_url_for_incident_is_canonical_truth_chain() -> None: + """Telegram θ©³ζƒ…/歷史要能直ζŽ₯導到 Alerts Incident truth-chain。""" + url = telegram_gateway_module._awooop_alerts_url_for_incident( + "INC-20260514-F85F21" + ) + + assert url == ( + "https://awoooi.wooo.work/zh-TW/alerts" + "?project_id=awoooi&incident_id=INC-20260514-F85F21" + ) + + +def test_awooop_reply_markup_prefers_truth_chain_then_runs() -> None: + """θ©³ζƒ…/歷史 callback reply ηš„η¬¬δΈ€ι‘† URL θ¦ζ˜―ε‘Šθ­¦ηœŸη›Έιˆγ€‚""" + reply_markup = telegram_gateway_module._awooop_runs_reply_markup( + "INC-20260514-F85F21" + ) + + assert reply_markup == { + "inline_keyboard": [[ + { + "text": "πŸ”Ž ηœŸη›Έιˆ", + "url": ( + "https://awoooi.wooo.work/zh-TW/alerts" + "?project_id=awoooi&incident_id=INC-20260514-F85F21" + ), + }, + { + "text": "🧭 Runs", + "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。""" + """δΈ»ε‘Šθ­¦ε‘ηš„ read-only ζŒ‰ιˆ•εˆ—θ¦θƒ½ε›žεˆ° Incident truth-chain。""" gateway = TelegramGateway() keyboard = await gateway._build_inline_keyboard( @@ -688,14 +726,20 @@ async def test_build_inline_keyboard_includes_awooop_deep_link() -> None: for button in row ] - awooop_buttons = [button for button in buttons if button["text"] == "🧭 AwoooP"] - assert awooop_buttons == [{ - "text": "🧭 AwoooP", + assert { + "text": "πŸ”Ž ηœŸη›Έιˆ", + "url": ( + "https://awoooi.wooo.work/zh-TW/alerts" + "?project_id=awoooi&incident_id=INC-20260514-F85F21" + ), + } in buttons + assert { + "text": "🧭 Runs", "url": ( "https://awoooi.wooo.work/zh-TW/awooop/runs" "?project_id=awoooi&incident_id=INC-20260514-F85F21" ), - }] + } in buttons @pytest.mark.asyncio