diff --git a/apps/api/src/services/telegram_gateway.py b/apps/api/src/services/telegram_gateway.py
index efba14c5..743df2ce 100644
--- a/apps/api/src/services/telegram_gateway.py
+++ b/apps/api/src/services/telegram_gateway.py
@@ -40,10 +40,12 @@ 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 (
+ AWOOOP_WEB_BASE_URL,
incident_alerts_url,
incident_runs_url,
incident_truth_chain_button_row,
incident_truth_chain_reply_markup,
+ work_item_url,
)
from src.services.approval_action_classifier import (
is_executable_repair_approval_action,
@@ -983,6 +985,58 @@ def _format_manual_asset_ledger_lines(*, compact: bool = False) -> list[str]:
]
+def _normalize_awooop_url(url: str) -> str:
+ raw = str(url or "").strip()
+ if not raw:
+ return ""
+ if raw.startswith(("https://", "http://")):
+ return raw
+ if raw.startswith("/awooop/"):
+ return f"{AWOOOP_WEB_BASE_URL}/zh-TW{raw}"
+ if raw.startswith("/"):
+ return f"{AWOOOP_WEB_BASE_URL}{raw}"
+ return raw
+
+
+def _repair_candidate_work_item_url(
+ *,
+ incident_id: str = "",
+ repair_candidate_work_item_href: str = "",
+ repair_candidate_work_item_id: str = "",
+) -> str:
+ normalized_href = _normalize_awooop_url(repair_candidate_work_item_href)
+ if normalized_href:
+ return normalized_href
+ if repair_candidate_work_item_id:
+ return work_item_url(
+ repair_candidate_work_item_id,
+ incident_id=incident_id,
+ project_id="awoooi",
+ )
+ return ""
+
+
+def _repair_candidate_work_item_refs_from_metadata(metadata: object) -> tuple[str, str]:
+ if not isinstance(metadata, dict):
+ return "", ""
+ package = metadata.get("repair_candidate_draft_package")
+ if not isinstance(package, dict):
+ package = {}
+ work_item = package.get("awooop_work_item")
+ if not isinstance(work_item, dict):
+ work_item = {}
+ href = (
+ str(work_item.get("work_item_url") or "")
+ or str(work_item.get("work_item_href") or "")
+ or str(metadata.get("repair_candidate_work_item_href") or "")
+ )
+ work_item_id = (
+ str(work_item.get("work_item_id") or "")
+ or str(metadata.get("repair_candidate_work_item_id") or "")
+ )
+ return href, work_item_id
+
+
def _format_manual_handoff_package_lines(
*,
incident_id: str,
@@ -1064,7 +1118,12 @@ def _format_manual_handoff_package_lines(
f"├ PlayBook 草案欄位:{html.escape(field_text)}",
)
insert_at += 1
- if repair_candidate_work_item_href:
+ work_item_deep_link = _repair_candidate_work_item_url(
+ incident_id=incident_ref,
+ repair_candidate_work_item_href=repair_candidate_work_item_href,
+ repair_candidate_work_item_id=repair_candidate_work_item_id,
+ )
+ if work_item_deep_link:
work_item_label = "AwoooP 修復候選草案"
if repair_candidate_work_item_id:
work_item_label = (
@@ -1075,7 +1134,7 @@ def _format_manual_handoff_package_lines(
insert_at,
(
"├ 工作項目:"
- f""
+ f""
f"{work_item_label}"
),
)
@@ -1088,7 +1147,13 @@ def _format_manual_handoff_package_lines(
),
)
if not compact:
- lines.append("按鈕:處置包 看完整證據,重診 重新收集,Runs 追蹤狀態")
+ if work_item_deep_link:
+ lines.append(
+ "按鈕:Work Item 開啟 owner review,"
+ "處置包 看完整證據,Runs 追蹤狀態"
+ )
+ else:
+ lines.append("按鈕:處置包 看完整證據,重診 重新收集,Runs 追蹤狀態")
return lines
@@ -4854,6 +4919,8 @@ class TelegramGateway:
notification_type: str = "",
# 2026-04-27 Claude Sonnet 4.6: B3 — LLM 動態按鈕(ActionPlan,可選)
action_plan: object = None,
+ repair_candidate_work_item_href: str = "",
+ repair_candidate_work_item_id: str = "",
) -> dict:
"""
建立 Inline Keyboard
@@ -4901,11 +4968,21 @@ class TelegramGateway:
info_row: list[dict] = []
secondary_row: list[dict] = []
if incident_id:
+ work_item_deep_link = _repair_candidate_work_item_url(
+ incident_id=incident_id,
+ repair_candidate_work_item_href=repair_candidate_work_item_href,
+ repair_candidate_work_item_id=repair_candidate_work_item_id,
+ )
+ if work_item_deep_link:
+ info_row.append({
+ "text": "🧾 Work Item",
+ "url": work_item_deep_link,
+ })
info_row.extend([
{"text": "🧰 處置包", "callback_data": f"detail:{incident_id}"},
- {"text": "🔄 重診", "callback_data": f"reanalyze:{incident_id}"},
])
secondary_row.extend([
+ {"text": "🔄 重診", "callback_data": f"reanalyze:{incident_id}"},
{"text": "📊 歷史", "callback_data": f"history:{incident_id}"},
{"text": "🔕 靜默", "callback_data": silence_nonce},
])
@@ -5369,6 +5446,8 @@ class TelegramGateway:
suggested_action=suggested_action,
alert_category=alert_category,
notification_type=notification_type,
+ repair_candidate_work_item_href=repair_candidate_work_item_href,
+ repair_candidate_work_item_id=repair_candidate_work_item_id,
)
# 發送訊息:2026-04-30 統帥指示,告警卡片完整切到 SRE 戰情室群組。
@@ -9964,6 +10043,8 @@ class TelegramGateway:
username: str,
execution_triggered: bool,
approval_action: str | None = None,
+ repair_candidate_work_item_href: str = "",
+ repair_candidate_work_item_id: str = "",
) -> None:
"""
2026-04-09 Claude Sonnet 4.6: 批准/拒絕後立即更新 Telegram 訊息狀態。
@@ -10005,7 +10086,16 @@ class TelegramGateway:
)
if no_action_approval:
status_emoji = "🟠"
- suffix = "已轉人工處置包;請按處置包或重診補修復候選,這不是執行中"
+ work_item_deep_link = _repair_candidate_work_item_url(
+ incident_id=incident_id,
+ repair_candidate_work_item_href=repair_candidate_work_item_href,
+ repair_candidate_work_item_id=repair_candidate_work_item_id,
+ )
+ suffix = (
+ "已轉 owner review;請開 Work Item / 處置包補齊修復候選,這不是執行中"
+ if work_item_deep_link
+ else "已轉人工處置包;請按處置包或重診補修復候選,這不是執行中"
+ )
else:
suffix = "⚡ 執行中..." if execution_triggered else "已簽核,等待更多簽核"
else:
@@ -10020,12 +10110,27 @@ class TelegramGateway:
try:
# 1. 移除批准/拒絕按鈕(只保留資訊按鈕列)
if no_action_approval:
- info_buttons = [[
- {"text": "🧰 處置包", "callback_data": f"detail:{incident_id}"},
- {"text": "🔄 重診", "callback_data": f"reanalyze:{incident_id}"},
- ], [
- {"text": "📊 歷史", "callback_data": f"history:{incident_id}"},
- ]]
+ no_action_first_row: list[dict[str, str]] = []
+ work_item_deep_link = _repair_candidate_work_item_url(
+ incident_id=incident_id,
+ repair_candidate_work_item_href=repair_candidate_work_item_href,
+ repair_candidate_work_item_id=repair_candidate_work_item_id,
+ )
+ if work_item_deep_link:
+ no_action_first_row.append({
+ "text": "🧾 Work Item",
+ "url": work_item_deep_link,
+ })
+ no_action_first_row.append(
+ {"text": "🧰 處置包", "callback_data": f"detail:{incident_id}"}
+ )
+ info_buttons = [
+ no_action_first_row,
+ [
+ {"text": "🔄 重診", "callback_data": f"reanalyze:{incident_id}"},
+ {"text": "📊 歷史", "callback_data": f"history:{incident_id}"},
+ ],
+ ]
else:
info_buttons = [[
{"text": "📋 詳情", "callback_data": f"detail:{incident_id}"},
@@ -10132,6 +10237,9 @@ class TelegramGateway:
# 2026-04-22 Claude Sonnet 4.6: 只有真正轉為 APPROVED 才發「執行中...」
# 非 PENDING 狀態下 sign_approval early-return → approval 是舊 record
# 此時不應發「執行中...」,應告知用戶告警已處理過
+ _work_item_href, _work_item_id = _repair_candidate_work_item_refs_from_metadata(
+ getattr(approval, "metadata", None)
+ )
if approval.status == ApprovalStatus.APPROVED and execution_triggered:
_execution_allowed = not is_no_action_approval_action(
getattr(approval, "action", None)
@@ -10144,6 +10252,8 @@ class TelegramGateway:
username=username,
execution_triggered=execution_triggered and _execution_allowed,
approval_action=getattr(approval, "action", None),
+ repair_candidate_work_item_href=_work_item_href,
+ repair_candidate_work_item_id=_work_item_id,
)
elif (
approval.status == ApprovalStatus.APPROVED
@@ -10156,6 +10266,8 @@ class TelegramGateway:
username=username,
execution_triggered=False,
approval_action=getattr(approval, "action", None),
+ repair_candidate_work_item_href=_work_item_href,
+ repair_candidate_work_item_id=_work_item_id,
)
else:
# 告警已是 execution_failed / execution_success / rejected 等終態
diff --git a/apps/api/tests/test_telegram_ai_automation_block.py b/apps/api/tests/test_telegram_ai_automation_block.py
index 9faca99d..a06aa545 100644
--- a/apps/api/tests/test_telegram_ai_automation_block.py
+++ b/apps/api/tests/test_telegram_ai_automation_block.py
@@ -84,7 +84,8 @@ def test_repair_candidate_missing_card_exposes_manual_handoff_package() -> None:
assert "排程/監控:Observability" in body
assert "Verifier:事件時間線" in body
assert "不可視為自動化完成" in body
- assert "按鈕:處置包" in body
+ assert "按鈕:Work Item 開啟 owner review" in body
+ assert "處置包 看完整證據" in body
assert "修復候選狀態" in body
assert "等待人工批准" not in body
@@ -141,6 +142,7 @@ def test_repair_candidate_draft_ready_card_exposes_owner_review_handoff() -> Non
assert "把診斷命令保留為 MCP evidence collector" in body
assert "AwoooP 修復候選草案" in body
assert "自動化資產總帳" in body
+ assert "按鈕:Work Item 開啟 owner review" in body
assert "缺少可執行修復候選" not in body
assert "等待人工批准" not in body
diff --git a/apps/api/tests/test_telegram_message_templates.py b/apps/api/tests/test_telegram_message_templates.py
index 3898b754..65211d42 100644
--- a/apps/api/tests/test_telegram_message_templates.py
+++ b/apps/api/tests/test_telegram_message_templates.py
@@ -1337,6 +1337,53 @@ async def test_build_inline_keyboard_hides_approval_for_no_action() -> None:
} in buttons
+@pytest.mark.asyncio
+async def test_build_inline_keyboard_links_work_item_for_no_action_handoff() -> None:
+ """NO_ACTION 卡片要直接開 owner review Work Item,不能只丟給人工找頁面。"""
+ gateway = TelegramGateway()
+
+ keyboard = await gateway._build_inline_keyboard(
+ approval_id="approval-no-repair-work-item",
+ include_auto_tuning=False,
+ incident_id="INC-20260625-977E5F",
+ suggested_action=(
+ "DRAFT_READY - REPAIR_CANDIDATE_OWNER_REVIEW_REQUIRED: "
+ "PlayBook 只有觀察或診斷步驟"
+ ),
+ repair_candidate_work_item_href=(
+ "/awooop/work-items?project_id=awoooi"
+ "&incident_id=INC-20260625-977E5F"
+ "&work_item_id=repair-candidate-draft%3Aawoooi%3AINC-20260625-977E5F"
+ ),
+ )
+ buttons = [
+ button
+ for row in keyboard["inline_keyboard"]
+ for button in row
+ ]
+ button_texts = {button["text"] for button in buttons}
+
+ assert "✅ 批准" not in button_texts
+ assert "❌ 拒絕" not in button_texts
+ assert {
+ "text": "🧾 Work Item",
+ "url": (
+ "https://awoooi.wooo.work/zh-TW/awooop/work-items"
+ "?project_id=awoooi&incident_id=INC-20260625-977E5F"
+ "&work_item_id=repair-candidate-draft%3Aawoooi%3AINC-20260625-977E5F"
+ ),
+ } in buttons
+ assert "🧰 處置包" in button_texts
+ assert "🔄 重診" in button_texts
+ assert {
+ "text": "🧭 Runs",
+ "url": (
+ "https://awoooi.wooo.work/zh-TW/awooop/runs"
+ "?project_id=awoooi&incident_id=INC-20260625-977E5F"
+ ),
+ } in buttons
+
+
@pytest.mark.asyncio
async def test_send_request_strips_awooop_callback_metadata_before_telegram_api(monkeypatch):
"""AwoooP truth-chain metadata must be mirrored, not sent to Telegram Bot API."""