""" Channel Hub — AwoooP 入站事件統一路由 + Progressive Feedback Policy ==================================================================== AwoooP Phase 7: ADR-106(channel_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 flow(signal_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() # preview:redact 後截取前 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( [ "🧩 告警已收斂到父卡", f"├ 類型:{safe_alert}", f"├ 等級:{safe_severity}", f"├ 範圍:{safe_namespace}", f"├ 最新目標:{safe_target}", f"├ 群組:{safe_group}", f"└ 目前視窗:{count} 筆同組告警", "", "完整子告警請看 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=True:status='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_feedback(Progressive 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 Feedback(30s 計時器) 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, }