Files
awoooi/apps/api/src/services/channel_hub.py
Your Name ced36f2521
Some checks failed
CD Pipeline / tests (push) Failing after 6s
CD Pipeline / build-and-deploy (push) Has been skipped
CD Pipeline / post-deploy-checks (push) Has been skipped
Code Review / ai-code-review (push) Failing after 8s
feat(awooop): add source provider freshness heartbeat
2026-05-20 19:32:22 +08:00

1216 lines
43 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Channel Hub — AwoooP 入站事件統一路由 + Progressive Feedback Policy
====================================================================
AwoooP Phase 7: ADR-106channel_event family
2026-05-04 ogt + Claude Sonnet 4.6
功能:
1. Telegram 入站事件鏡像(記錄到 awooop_conversation_event
2. 建立 platform run呼叫 platform_runtime.create_run
3. Progressive Feedback Policy
- run 進入 WAITING_TOOL 狀態 → 30 秒後若未 complete → 發 interim Telegram 訊息
- 訊息記錄到 awooop_outbound_message
4. Shadow Mode不發任何 Telegram 訊息(只記錄到 outbound_message, status='shadow'
Progressive Feedback Policy 設計ADR-106 P2-03
- 用 asyncio.create_task 啟動 30s 計時器
- 30s 後查詢 run state若仍在 WAITING_TOOL → 發 interim 訊息
- interim 訊息「AI 正在分析中,請稍候...」(不洩漏 run 細節)
- Final reply 由 shadow_execute() 完成後觸發Phase 8 實作)
與 legacy telegram_gateway.py 的關係:
- 完全獨立,不修改 legacy gateway
- legacy 繼續處理 legacy flowsignal_worker 觸發的 approval/notification
- AwoooP run 只走本模組
"""
from __future__ import annotations
import asyncio
import hashlib
import html
import json
import re
from datetime import UTC, datetime
from typing import Any
from uuid import NAMESPACE_URL, UUID, uuid5
import structlog
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.awooop_models import AwoooPRunState
from src.services.audit_sink import _redact_string, sanitize
from src.services.platform_runtime import create_run
logger = structlog.get_logger(__name__)
# Progressive Feedback Policy等待超過此秒數才發 interim 訊息
_INTERIM_WAIT_SECONDS = 30
_INBOUND_REDACTION_VERSION = "audit_sink_v1"
_OUTBOUND_REDACTION_VERSION = "audit_sink_v1"
_INCIDENT_ID_RE = re.compile(r"\bINC-\d{8}-[A-Z0-9]{6}\b")
def _db_timestamp_now() -> datetime:
"""Return UTC now in the timestamp shape accepted by the production DB path."""
return datetime.now(UTC).replace(tzinfo=None)
def _compact_unique(values: list[str | None], *, limit: int = 20) -> list[str]:
"""Return stable non-empty values without leaking duplicate source refs."""
return sorted({str(value).strip() for value in values if str(value or "").strip()})[:limit]
def build_inbound_source_envelope(
*,
provider: str,
stage: str,
provider_event_id: str,
raw_event_id: str | None = None,
raw_content: str | None = None,
alertname: str | None = None,
severity: str | None = None,
namespace: str | None = None,
target_resource: str | None = None,
fingerprint: str | None = None,
incident_id: str | None = None,
approval_id: str | None = None,
source_url: str | None = None,
labels: dict[str, Any] | None = None,
annotations: dict[str, Any] | None = None,
extra: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Build a redaction-friendly inbound replay envelope for truth-chain use."""
content_sha256 = hashlib.sha256(raw_content.encode()).hexdigest() if raw_content else None
text_refs = _INCIDENT_ID_RE.findall(raw_content or "")
provider_name = str(provider or "unknown").strip().lower() or "unknown"
is_provider_heartbeat = str(stage or "").strip().lower() == "heartbeat"
source_refs = {
"event_ids": _compact_unique([raw_event_id]),
"incident_ids": _compact_unique([incident_id, *text_refs]),
"approval_ids": _compact_unique([approval_id]),
"alert_ids": _compact_unique([provider_event_id, raw_event_id]),
"fingerprints": _compact_unique([fingerprint]),
"sentry_issue_ids": _compact_unique(
[raw_event_id, provider_event_id]
if provider_name == "sentry" and not is_provider_heartbeat
else []
),
"signoz_alerts": _compact_unique(
[raw_event_id, alertname]
if provider_name == "signoz" and not is_provider_heartbeat
else []
),
}
envelope: dict[str, Any] = {
"schema_version": "inbound_source_envelope_v1",
"redaction_version": _INBOUND_REDACTION_VERSION,
"adapter": f"{provider_name}_webhook",
"provider": provider_name,
"stage": stage,
"provider_event_id": provider_event_id,
"source_url": source_url,
"content_sha256": content_sha256,
"content_length": len(raw_content) if raw_content is not None else 0,
"source_refs": source_refs,
"log_correlation": {
"alertname": alertname,
"severity": severity,
"namespace": namespace,
"target_resource": target_resource,
"fingerprint": fingerprint,
},
"labels": labels or {},
"annotations": annotations or {},
}
if extra:
envelope["extra"] = extra
sanitized = sanitize(envelope)
sanitized["content_sha256"] = content_sha256
return sanitized
def _input_sha256(input_payload: dict[str, Any] | None) -> str | None:
"""計算 Run input 的穩定 hash讓 mirror run 也能保留最小完整性證據。"""
if not input_payload:
return None
canonical = json.dumps(
input_payload,
sort_keys=True,
separators=(",", ":"),
ensure_ascii=False,
)
return hashlib.sha256(canonical.encode()).hexdigest()
async def ensure_completed_shadow_run(
db: AsyncSession,
*,
project_id: str,
run_id: UUID,
agent_id: str,
trigger_type: str,
trigger_ref: str | None,
input_payload: dict[str, Any] | None = None,
) -> bool:
"""為 legacy mirror 資料補一筆 completed shadow run。
AwoooP 在 strangler 階段會先 mirror legacy Telegram / alert-grouping
資料。這些事件不應重新觸發 runtime但需要 run_state 當 Console 的
聚合錨點;因此這裡建立的是已完成的 shadow run不會被 worker pick up。
"""
result = await db.execute(
text("""
INSERT INTO awooop_run_state (
run_id, project_id, agent_id, state,
trigger_type, trigger_ref, is_shadow,
input_sha256,
attempt_count, max_attempts, cost_usd, step_count,
created_at, completed_at, timeout_at
) VALUES (
:run_id, :project_id, :agent_id, 'completed',
:trigger_type, :trigger_ref, TRUE,
:input_sha256,
0, 3, 0.0000, 0,
NOW(), NOW(), NOW()
)
ON CONFLICT (run_id) DO NOTHING
RETURNING run_id
"""),
{
"run_id": run_id,
"project_id": project_id,
"agent_id": agent_id,
"trigger_type": trigger_type,
"trigger_ref": trigger_ref,
"input_sha256": _input_sha256(input_payload),
},
)
inserted = result.fetchone() is not None
if inserted:
logger.info(
"completed_shadow_run_created",
project_id=project_id,
run_id=str(run_id),
agent_id=agent_id,
trigger_type=trigger_type,
)
return inserted
def build_grouped_alert_run_id(project_id: str, provider_event_id: str) -> UUID:
"""為 grouped child alert 建立穩定 run_id讓 Run Monitor 可回查。"""
return uuid5(NAMESPACE_URL, f"awooop:grouped-alert:{project_id}:{provider_event_id}")
def build_alertmanager_provider_event_id(alert_id: str, fingerprint: str, stage: str) -> str:
"""建立 Alertmanager inbound event 的冪等 provider_event_id。"""
safe_alert_id = str(alert_id).strip() or "unknown"
safe_fingerprint = str(fingerprint).strip()[:32] or "no-fingerprint"
safe_stage = str(stage).strip()[:32] or "received"
return f"alertmanager:{safe_stage}:{safe_alert_id}:{safe_fingerprint}"
def build_alertmanager_run_id(project_id: str, provider_event_id: str) -> UUID:
"""為 Alertmanager inbound mirror 建立穩定 shadow run_id。"""
return uuid5(NAMESPACE_URL, f"awooop:alertmanager:{project_id}:{provider_event_id}")
def build_external_alert_provider_event_id(provider: str, event_id: str, stage: str) -> str:
"""建立 Sentry/SignOz 等外部告警 inbound event 的冪等 provider_event_id。"""
safe_provider = str(provider).strip().lower()[:32] or "external"
safe_event_id = str(event_id).strip()[:96] or "unknown"
safe_stage = str(stage).strip()[:32] or "received"
return f"{safe_provider}:{safe_stage}:{safe_event_id}"
def build_external_alert_run_id(project_id: str, provider_event_id: str) -> UUID:
"""為外部告警 inbound mirror 建立穩定 shadow run_id。"""
return uuid5(NAMESPACE_URL, f"awooop:external-alert:{project_id}:{provider_event_id}")
# ─────────────────────────────────────────────────────────────────────────────
# 入站事件記錄
# ─────────────────────────────────────────────────────────────────────────────
async def mirror_inbound_event(
db: AsyncSession,
*,
project_id: str,
channel_type: str,
provider_event_id: str,
platform_subject_id: str | None = None,
channel_user_id: str | None = None,
channel_chat_id: str | None = None,
content_type: str = "text",
raw_content: str | None = None,
source_envelope: dict[str, Any] | None = None,
attachment_sha256: str | None = None,
provider_ts: datetime | None = None,
run_id: UUID | None = None,
is_duplicate: bool = False,
) -> UUID:
"""
記錄入站 channel event 到 awooop_conversation_event。
raw_content 只用於計算 hash 和 preview不入庫明文。
回傳 event_id。
"""
content_hash: str | None = None
content_preview: str | None = None
content_redacted: str | None = None
if raw_content is not None:
content_hash = hashlib.sha256(raw_content.encode()).hexdigest()
# previewredact 後截取前 256 字元
content_redacted = _redact_string(raw_content)
content_preview = (
content_redacted[:256] if len(content_redacted) > 256 else content_redacted
)
if source_envelope and source_envelope.get("schema_version") == "inbound_source_envelope_v1":
original_content_sha256 = source_envelope.get("content_sha256")
envelope = sanitize(source_envelope)
envelope.setdefault("redaction_version", _INBOUND_REDACTION_VERSION)
envelope["content_sha256"] = content_hash or original_content_sha256
envelope.setdefault("content_length", len(raw_content) if raw_content is not None else 0)
else:
envelope = build_inbound_source_envelope(
provider=channel_type,
stage="received",
provider_event_id=provider_event_id,
raw_event_id=provider_event_id,
raw_content=raw_content,
extra=source_envelope,
)
source_envelope_json = json.dumps(envelope, ensure_ascii=False, default=str)
result = await db.execute(
text("""
INSERT INTO awooop_conversation_event (
project_id, channel_type, provider_event_id,
platform_subject_id, channel_user_id, channel_chat_id,
run_id, content_type, content_hash, content_preview,
content_redacted, redaction_version, source_envelope,
attachment_sha256, is_duplicate, provider_ts, received_at
) VALUES (
:project_id, :channel_type, :provider_event_id,
:platform_subject_id, :channel_user_id, :channel_chat_id,
:run_id, :content_type, :content_hash, :content_preview,
:content_redacted, :redaction_version, CAST(:source_envelope AS jsonb),
:attachment_sha256, :is_duplicate, :provider_ts, NOW()
)
ON CONFLICT (project_id, channel_type, provider_event_id) DO UPDATE SET
is_duplicate = TRUE,
run_id = COALESCE(EXCLUDED.run_id, awooop_conversation_event.run_id),
content_redacted = COALESCE(
awooop_conversation_event.content_redacted,
EXCLUDED.content_redacted
),
redaction_version = EXCLUDED.redaction_version,
source_envelope = CASE
WHEN awooop_conversation_event.source_envelope = '{}'::jsonb
THEN EXCLUDED.source_envelope
ELSE awooop_conversation_event.source_envelope
END
RETURNING event_id
"""),
{
"project_id": project_id,
"channel_type": channel_type,
"provider_event_id": provider_event_id,
"platform_subject_id": platform_subject_id,
"channel_user_id": channel_user_id,
"channel_chat_id": channel_chat_id,
"run_id": run_id,
"content_type": content_type,
"content_hash": content_hash,
"content_preview": content_preview,
"content_redacted": content_redacted,
"redaction_version": _INBOUND_REDACTION_VERSION,
"source_envelope": source_envelope_json,
"attachment_sha256": attachment_sha256,
"is_duplicate": is_duplicate,
"provider_ts": provider_ts,
},
)
row = result.fetchone()
event_id: UUID = row[0]
logger.info(
"channel_event_mirrored",
project_id=project_id,
channel_type=channel_type,
event_id=str(event_id),
is_duplicate=is_duplicate,
)
return event_id
def build_grouped_alert_provider_event_id(alert_id: str, fingerprint: str) -> str:
"""建立 grouped child alert 的冪等 provider_event_id。"""
safe_alert_id = str(alert_id).strip() or "unknown"
safe_fingerprint = str(fingerprint).strip()[:32] or "no-fingerprint"
return f"alert-group:{safe_alert_id}:{safe_fingerprint}"
def format_alertmanager_event_content(
*,
stage: str,
alert_id: str,
alertname: str,
severity: str,
namespace: str,
target_resource: str,
fingerprint: str,
notification_type: str | None = None,
alert_category: str | None = None,
incident_id: str | None = None,
approval_id: str | None = None,
repeat_count: int | None = None,
) -> str:
"""格式化 Alertmanager inbound mirror 摘要,讓 truth-chain 可回查。"""
head = f"Incident: {incident_id}" if incident_id else f"Fingerprint: {fingerprint}"
return "\n".join(
[
f"Alertmanager inbound {stage}",
head,
f"Alert ID: {alert_id}",
f"Approval: {approval_id or '-'}",
f"Alert: {alertname}",
f"Severity: {severity}",
f"Namespace: {namespace or 'default'}",
f"Target: {target_resource or '-'}",
f"Fingerprint: {fingerprint}",
f"Notification Type: {notification_type or '-'}",
f"Alert Category: {alert_category or '-'}",
f"Repeat Count: {repeat_count if repeat_count is not None else '-'}",
]
)
def format_grouped_alert_event_content(
*,
alert_id: str,
alertname: str,
severity: str,
namespace: str,
target_resource: str,
group_key: str,
count: int,
parent_fingerprint: str | None,
fingerprint: str,
) -> str:
"""格式化只落 AwoooP、不發 Telegram 的告警收斂事件摘要。"""
parent = parent_fingerprint or "-"
target = target_resource or "-"
ns = namespace or "default"
return "\n".join(
[
"告警已收斂,不發 Telegram",
f"Alert ID: {alert_id}",
f"Alert: {alertname}",
f"Severity: {severity}",
f"Namespace: {ns}",
f"Target: {target}",
f"Group: {group_key}",
f"Group Count: {count}",
f"Parent Fingerprint: {parent}",
f"Child Fingerprint: {fingerprint}",
]
)
def format_grouped_alert_digest_text(
*,
alertname: str,
severity: str,
namespace: str,
target_resource: str,
group_key: str,
count: int,
) -> str:
"""格式化要回覆到父告警卡的短 digest。"""
safe_alert = html.escape(alertname or "unknown")
safe_severity = html.escape(severity or "unknown")
safe_namespace = html.escape(namespace or "default")
safe_target = html.escape(target_resource or "unknown")
safe_group = html.escape(group_key or "unknown")
return "\n".join(
[
"🧩 <b>告警已收斂到父卡</b>",
f"├ 類型:<code>{safe_alert}</code>",
f"├ 等級:<code>{safe_severity}</code>",
f"├ 範圍:<code>{safe_namespace}</code>",
f"├ 最新目標:<code>{safe_target}</code>",
f"├ 群組:<code>{safe_group}</code>",
f"└ 目前視窗:<b>{count}</b> 筆同組告警",
"",
"完整子告警請看 AwoooP Run 監控,不再逐筆發 Telegram。",
]
)
async def maybe_send_grouped_alert_digest(
*,
project_id: str,
alertname: str,
severity: str,
namespace: str,
target_resource: str,
group_key: str,
count: int,
parent_fingerprint: str | None,
) -> bool:
"""若父告警卡已存在,回覆一則低頻 digest找不到父卡則安靜降級。"""
if not parent_fingerprint:
return False
try:
from sqlalchemy import select
from src.db.base import get_db_context
from src.db.models import ApprovalRecord
from src.services.telegram_gateway import get_telegram_gateway
async with get_db_context(project_id) as db:
result = await db.execute(
select(ApprovalRecord.incident_id)
.where(ApprovalRecord.fingerprint == parent_fingerprint)
.where(ApprovalRecord.incident_id.is_not(None))
.order_by(ApprovalRecord.created_at.desc())
.limit(1)
)
incident_id = result.scalar_one_or_none()
if not incident_id:
logger.info(
"grouped_alert_digest_parent_not_ready",
project_id=project_id,
group_key=group_key,
parent_fingerprint=parent_fingerprint,
)
return False
digest_text = format_grouped_alert_digest_text(
alertname=alertname,
severity=severity,
namespace=namespace,
target_resource=target_resource,
group_key=group_key,
count=count,
)
sent = await get_telegram_gateway().append_grouped_alert_digest(
incident_id=str(incident_id),
group_key=group_key,
digest_text=digest_text,
)
logger.info(
"grouped_alert_digest_result",
project_id=project_id,
incident_id=str(incident_id),
group_key=group_key,
count=count,
sent=sent,
)
return sent
except Exception as exc:
logger.warning(
"grouped_alert_digest_failed",
project_id=project_id,
group_key=group_key,
parent_fingerprint=parent_fingerprint,
error=str(exc),
)
return False
async def record_grouped_alert_event(
*,
project_id: str,
alert_id: str,
alertname: str,
severity: str,
namespace: str,
target_resource: str,
group_key: str,
count: int,
parent_fingerprint: str | None,
fingerprint: str,
) -> UUID | None:
"""
將被 AlertGroupingService 收斂的子告警落到 AwoooP conversation_event。
這條路徑刻意不發 Telegram只保留 operator-facing 脈絡:
- 群組不洗版
- Console 仍能看到同組告警正在持續發生
- DB 失敗 fail-open不影響 Alertmanager webhook ACK
"""
try:
from src.db.base import get_db_context
provider_event_id = build_grouped_alert_provider_event_id(alert_id, fingerprint)
content = format_grouped_alert_event_content(
alert_id=alert_id,
alertname=alertname,
severity=severity,
namespace=namespace,
target_resource=target_resource,
group_key=group_key,
count=count,
parent_fingerprint=parent_fingerprint,
fingerprint=fingerprint,
)
async with get_db_context(project_id) as db:
run_id = build_grouped_alert_run_id(project_id, provider_event_id)
await ensure_completed_shadow_run(
db,
project_id=project_id,
run_id=run_id,
agent_id="legacy-alert-grouping",
trigger_type="grouped_alert_event",
trigger_ref=provider_event_id,
input_payload={
"alert_id": alert_id,
"alertname": alertname,
"severity": severity,
"group_key": group_key,
"fingerprint": fingerprint,
},
)
event_id = await mirror_inbound_event(
db,
project_id=project_id,
channel_type="internal",
provider_event_id=provider_event_id,
platform_subject_id="alertmanager",
channel_user_id="alertmanager",
channel_chat_id=f"alert-group:{group_key}",
content_type="text",
raw_content=content,
provider_ts=_db_timestamp_now(),
run_id=run_id,
)
logger.info(
"grouped_alert_event_recorded",
project_id=project_id,
alert_id=alert_id,
event_id=str(event_id),
group_key=group_key,
count=count,
)
await maybe_send_grouped_alert_digest(
project_id=project_id,
alertname=alertname,
severity=severity,
namespace=namespace,
target_resource=target_resource,
group_key=group_key,
count=count,
parent_fingerprint=parent_fingerprint,
)
return event_id
except Exception as exc:
logger.warning(
"grouped_alert_event_record_failed",
project_id=project_id,
alert_id=alert_id,
group_key=group_key,
error=str(exc),
)
return None
async def record_alertmanager_event(
*,
project_id: str,
alert_id: str,
alertname: str,
severity: str,
namespace: str,
target_resource: str,
fingerprint: str,
stage: str,
notification_type: str | None = None,
alert_category: str | None = None,
incident_id: str | None = None,
approval_id: str | None = None,
repeat_count: int | None = None,
is_duplicate: bool = False,
source_url: str | None = None,
labels: dict[str, Any] | None = None,
annotations: dict[str, Any] | None = None,
source_extra: dict[str, Any] | None = None,
) -> UUID | None:
"""
將 Alertmanager inbound alert 鏡像到 AwoooP conversation_event。
Telegram 不應是唯一事實來源;每個 firing alert 至少要有 received
event建立 incident/approval 後再補 incident_linked event 供 truth-chain
依 incident_id 回查。DB 失敗 fail-open不影響 Alertmanager ACK。
"""
try:
from src.db.base import get_db_context
incident_ref = str(incident_id) if incident_id else None
approval_ref = str(approval_id) if approval_id else None
provider_event_id = build_alertmanager_provider_event_id(
alert_id=alert_id,
fingerprint=fingerprint,
stage=stage,
)
content = format_alertmanager_event_content(
stage=stage,
alert_id=alert_id,
alertname=alertname,
severity=severity,
namespace=namespace,
target_resource=target_resource,
fingerprint=fingerprint,
notification_type=notification_type,
alert_category=alert_category,
incident_id=incident_ref,
approval_id=approval_ref,
repeat_count=repeat_count,
)
source_envelope = build_inbound_source_envelope(
provider="alertmanager",
stage=stage,
provider_event_id=provider_event_id,
raw_event_id=alert_id,
raw_content=content,
alertname=alertname,
severity=severity,
namespace=namespace,
target_resource=target_resource,
fingerprint=fingerprint,
incident_id=incident_ref,
approval_id=approval_ref,
source_url=source_url,
labels=labels,
annotations=annotations,
extra={
"notification_type": notification_type,
"alert_category": alert_category,
"repeat_count": repeat_count,
**(source_extra or {}),
},
)
async with get_db_context(project_id) as db:
run_id = build_alertmanager_run_id(project_id, provider_event_id)
await ensure_completed_shadow_run(
db,
project_id=project_id,
run_id=run_id,
agent_id="legacy-alertmanager-webhook",
trigger_type="alertmanager_inbound",
trigger_ref=provider_event_id,
input_payload={
"stage": stage,
"alert_id": alert_id,
"alertname": alertname,
"severity": severity,
"namespace": namespace,
"target_resource": target_resource,
"fingerprint": fingerprint,
"notification_type": notification_type,
"alert_category": alert_category,
"incident_id": incident_ref,
"approval_id": approval_ref,
"repeat_count": repeat_count,
},
)
event_id = await mirror_inbound_event(
db,
project_id=project_id,
channel_type="internal",
provider_event_id=provider_event_id,
platform_subject_id="alertmanager",
channel_user_id="alertmanager",
channel_chat_id=f"alertmanager:{namespace or 'default'}",
content_type="text",
raw_content=content,
source_envelope=source_envelope,
provider_ts=_db_timestamp_now(),
run_id=run_id,
is_duplicate=is_duplicate,
)
logger.info(
"alertmanager_event_recorded",
project_id=project_id,
alert_id=alert_id,
event_id=str(event_id),
stage=stage,
incident_id=incident_ref,
fingerprint=fingerprint,
)
return event_id
except Exception as exc:
logger.warning(
"alertmanager_event_record_failed",
project_id=project_id,
alert_id=alert_id,
stage=stage,
fingerprint=fingerprint,
error=str(exc),
)
return None
async def record_external_alert_event(
*,
project_id: str,
provider: str,
event_id: str,
stage: str,
title: str,
severity: str,
namespace: str | None = None,
target_resource: str | None = None,
fingerprint: str | None = None,
incident_id: str | None = None,
approval_id: str | None = None,
source_url: str | None = None,
labels: dict[str, Any] | None = None,
annotations: dict[str, Any] | None = None,
payload: dict[str, Any] | None = None,
is_duplicate: bool = False,
) -> UUID | None:
"""
將 Sentry / SignOz 等非 Alertmanager 告警鏡像到 conversation_event。
這是 truth-chain 的最低共用入口:只寫 redacted content + source_envelope
不改變原本 webhook 的通知、審批或自動化行為。
"""
provider_name = str(provider or "external").strip().lower() or "external"
provider_event_id = build_external_alert_provider_event_id(provider_name, event_id, stage)
content = "\n".join([
f"{provider_name} inbound {stage}",
f"Event ID: {event_id}",
f"Title: {title}",
f"Severity: {severity}",
f"Namespace: {namespace or '-'}",
f"Target: {target_resource or '-'}",
f"Fingerprint: {fingerprint or '-'}",
f"Incident: {incident_id or '-'}",
f"Approval: {approval_id or '-'}",
f"Source URL: {source_url or '-'}",
])
source_envelope = build_inbound_source_envelope(
provider=provider_name,
stage=stage,
provider_event_id=provider_event_id,
raw_event_id=event_id,
raw_content=content,
alertname=title,
severity=severity,
namespace=namespace,
target_resource=target_resource,
fingerprint=fingerprint,
incident_id=str(incident_id) if incident_id else None,
approval_id=str(approval_id) if approval_id else None,
source_url=source_url,
labels=labels,
annotations=annotations,
extra={
"payload": payload or {},
},
)
try:
from src.db.base import get_db_context
async with get_db_context(project_id) as db:
run_id = build_external_alert_run_id(project_id, provider_event_id)
await ensure_completed_shadow_run(
db,
project_id=project_id,
run_id=run_id,
agent_id=f"legacy-{provider_name}-webhook",
trigger_type=f"{provider_name}_inbound",
trigger_ref=provider_event_id,
input_payload={
"provider": provider_name,
"event_id": event_id,
"stage": stage,
"severity": severity,
"namespace": namespace,
"target_resource": target_resource,
"fingerprint": fingerprint,
"incident_id": str(incident_id) if incident_id else None,
"approval_id": str(approval_id) if approval_id else None,
},
)
event_uuid = await mirror_inbound_event(
db,
project_id=project_id,
channel_type="internal",
provider_event_id=provider_event_id,
platform_subject_id=provider_name,
channel_user_id=provider_name,
channel_chat_id=f"{provider_name}:{namespace or 'default'}",
content_type="text",
raw_content=content,
source_envelope=source_envelope,
provider_ts=_db_timestamp_now(),
run_id=run_id,
is_duplicate=is_duplicate,
)
logger.info(
"external_alert_event_recorded",
project_id=project_id,
provider=provider_name,
event_id=event_id,
stage=stage,
conversation_event_id=str(event_uuid),
incident_id=str(incident_id) if incident_id else None,
approval_id=str(approval_id) if approval_id else None,
)
return event_uuid
except Exception as exc:
logger.warning(
"external_alert_event_record_failed",
project_id=project_id,
provider=provider_name,
event_id=event_id,
stage=stage,
error=str(exc),
)
return None
# ─────────────────────────────────────────────────────────────────────────────
# 出站訊息記錄
# ─────────────────────────────────────────────────────────────────────────────
async def record_outbound_message(
db: AsyncSession,
*,
project_id: str,
run_id: UUID,
channel_type: str,
channel_chat_id: str,
message_type: str, # 'interim' | 'final' | 'error' | 'approval_request'
content: str | None = None,
source_envelope: dict[str, Any] | None = None,
provider_message_id: str | None = None,
send_status: str = "pending",
conversation_event_id: UUID | None = None,
triggered_by_state: str | None = None,
waiting_since: datetime | None = None,
is_shadow: bool = True,
) -> UUID:
"""
記錄出站訊息到 awooop_outbound_message。
is_shadow=Truestatus='shadow'(不實際發送,只記錄)
"""
content_hash: str | None = None
content_preview: str | None = None
content_redacted: str | None = None
if content is not None:
content_hash = hashlib.sha256(content.encode()).hexdigest()
content_redacted = _redact_string(content)
content_preview = content_redacted[:256]
envelope: dict[str, Any] = sanitize(source_envelope or {})
envelope.update({
"schema_version": "outbound_source_envelope_v1",
"redaction_version": _OUTBOUND_REDACTION_VERSION,
"content_sha256": content_hash,
"content_length": len(content) if content is not None else 0,
})
source_envelope_json = json.dumps(envelope, ensure_ascii=False, default=str)
actual_status = "shadow" if is_shadow else send_status
sent_at = (
_db_timestamp_now()
if actual_status == "sent"
else None
)
await ensure_completed_shadow_run(
db,
project_id=project_id,
run_id=run_id,
agent_id="legacy-telegram-gateway",
trigger_type="legacy_outbound",
trigger_ref=provider_message_id,
input_payload={
"channel_type": channel_type,
"channel_chat_id": channel_chat_id,
"message_type": message_type,
"send_status": actual_status,
"triggered_by_state": triggered_by_state,
},
)
result = await db.execute(
text("""
INSERT INTO awooop_outbound_message (
project_id, run_id, conversation_event_id,
channel_type, channel_chat_id, message_type,
content_hash, content_preview, content_redacted,
redaction_version, source_envelope,
provider_message_id,
send_status, queued_at, sent_at,
triggered_by_state, waiting_since
) VALUES (
:project_id, :run_id, :conversation_event_id,
:channel_type, :channel_chat_id, :message_type,
:content_hash, :content_preview, :content_redacted,
:redaction_version, CAST(:source_envelope AS jsonb),
:provider_message_id,
:send_status, NOW(), :sent_at,
:triggered_by_state, :waiting_since
)
RETURNING message_id
"""),
{
"project_id": project_id,
"run_id": run_id,
"conversation_event_id": conversation_event_id,
"channel_type": channel_type,
"channel_chat_id": channel_chat_id,
"message_type": message_type,
"content_hash": content_hash,
"content_preview": content_preview,
"content_redacted": content_redacted,
"redaction_version": _OUTBOUND_REDACTION_VERSION,
"source_envelope": source_envelope_json,
"provider_message_id": provider_message_id,
"send_status": actual_status,
"sent_at": sent_at,
"triggered_by_state": triggered_by_state,
"waiting_since": waiting_since,
},
)
row = result.fetchone()
message_id: UUID = row[0]
logger.info(
"outbound_message_recorded",
project_id=project_id,
run_id=str(run_id),
message_type=message_type,
send_status=actual_status,
message_id=str(message_id),
)
return message_id
# ─────────────────────────────────────────────────────────────────────────────
# Progressive Feedback Policy
# ─────────────────────────────────────────────────────────────────────────────
async def schedule_interim_feedback(
*,
project_id: str,
run_id: UUID,
channel_type: str,
channel_chat_id: str,
conversation_event_id: UUID | None = None,
is_shadow: bool = True,
wait_seconds: int = _INTERIM_WAIT_SECONDS,
) -> None:
"""
Progressive Feedback Policy
等待 wait_seconds 秒後,若 run 仍在 WAITING_TOOL → 發 interim 訊息。
Shadow Mode記錄到 outbound_message(status='shadow'),不實際發 Telegram 訊息。
"""
asyncio.create_task(
_interim_feedback_task(
project_id=project_id,
run_id=run_id,
channel_type=channel_type,
channel_chat_id=channel_chat_id,
conversation_event_id=conversation_event_id,
is_shadow=is_shadow,
wait_seconds=wait_seconds,
),
name=f"interim_feedback_{str(run_id)[:8]}",
)
async def _interim_feedback_task(
*,
project_id: str,
run_id: UUID,
channel_type: str,
channel_chat_id: str,
conversation_event_id: UUID | None,
is_shadow: bool,
wait_seconds: int,
) -> None:
"""等待後查 run state仍 waiting_tool 才發 interim"""
await asyncio.sleep(wait_seconds)
try:
from src.db.base import get_db_context
async with get_db_context(project_id) as db:
result = await db.execute(
select(AwoooPRunState.state, AwoooPRunState.is_shadow).where(
AwoooPRunState.run_id == run_id,
AwoooPRunState.project_id == project_id,
)
)
row = result.first()
if row is None:
logger.warning(
"interim_feedback_run_not_found",
run_id=str(run_id),
)
return
state, run_is_shadow = row
if state != "waiting_tool":
# run 已推進complete/failed 等),不需要 interim
return
waiting_since = datetime.now(UTC)
interim_content = "AI 正在分析中,請稍候... ⏳"
await record_outbound_message(
db,
project_id=project_id,
run_id=run_id,
channel_type=channel_type,
channel_chat_id=channel_chat_id,
message_type="interim",
content=interim_content,
send_status="pending",
conversation_event_id=conversation_event_id,
triggered_by_state="waiting_tool",
waiting_since=waiting_since,
is_shadow=is_shadow or run_is_shadow,
)
if not (is_shadow or run_is_shadow):
# Non-shadow實際發 Telegram 訊息
await _send_telegram_interim(
channel_chat_id=channel_chat_id,
content=interim_content,
run_id=run_id,
)
logger.info(
"interim_feedback_sent",
project_id=project_id,
run_id=str(run_id),
is_shadow=is_shadow or run_is_shadow,
)
except Exception as exc:
logger.exception(
"interim_feedback_task_error",
run_id=str(run_id),
error=str(exc),
)
async def _send_telegram_interim(
*,
channel_chat_id: str,
content: str,
run_id: UUID,
) -> None:
"""實際發送 Telegram interim 訊息non-shadow 專用)"""
try:
import os
import httpx
bot_token = os.environ.get("TELEGRAM_BOT_TOKEN")
if not bot_token:
logger.warning("interim_telegram_no_token", run_id=str(run_id))
return
async with httpx.AsyncClient(timeout=10) as client:
await client.post(
f"https://api.telegram.org/bot{bot_token}/sendMessage",
json={
"chat_id": channel_chat_id,
"text": content,
"parse_mode": "HTML",
},
)
except Exception as exc:
logger.warning(
"interim_telegram_send_failed",
run_id=str(run_id),
error=str(exc),
)
# ─────────────────────────────────────────────────────────────────────────────
# Channel Hub 主入口Telegram inbound
# ─────────────────────────────────────────────────────────────────────────────
async def handle_telegram_inbound(
db: AsyncSession,
*,
project_id: str,
agent_id: str,
message_id: str,
user_id: str,
chat_id: str,
text: str | None = None,
is_shadow: bool = True,
) -> dict[str, Any]:
"""
Telegram 入站訊息的統一處理入口:
1. mirror_inbound_event記錄
2. create_run建立 platform run
3. schedule_interim_feedbackProgressive Feedback
4. 回傳 {event_id, run_id, is_duplicate}
"""
# Step 1: 嘗試建立 run有冪等保護
run_id, is_duplicate = await create_run(
project_id=project_id,
agent_id=agent_id,
trigger_type="channel_event",
trigger_ref=f"telegram:{message_id}",
input_payload={"chat_id": chat_id, "user_id": user_id},
channel_type="telegram",
provider_event_id=message_id,
)
# Step 2: Mirror event含 run_id
event_id = await mirror_inbound_event(
db,
project_id=project_id,
channel_type="telegram",
provider_event_id=message_id,
channel_user_id=user_id,
channel_chat_id=chat_id,
content_type="text" if text else "callback_query",
raw_content=text,
run_id=run_id,
is_duplicate=is_duplicate,
)
# Step 3: Progressive Feedback30s 計時器)
if not is_duplicate:
await schedule_interim_feedback(
project_id=project_id,
run_id=run_id,
channel_type="telegram",
channel_chat_id=chat_id,
conversation_event_id=event_id,
is_shadow=is_shadow,
)
return {
"event_id": str(event_id),
"run_id": str(run_id),
"is_duplicate": is_duplicate,
}