"""Microsoft Teams Workflows(수신 webhook) 로 어댑티브 카드 한 장을 보낸다. ★ 이 파일은 "HTTP 로 카드 하나 보내기" 딱 그것만 안다 — 언제 보낼지·무엇을 보낼지는 services/alert_service.py 가 정한다(관심사 분리, 재사용). search_console_alerts.py 가 쓰는 별도의 좁은 어댑터와는 다른 자리다 — 그건 색인 감시 전용이고 이건 잡 큐·발행 전반의 장애 알림 전용이다. 카드 포맷이 같은 이유로 합치자는 제안이 오면, 두 기능의 배포 주기가 다르다는 것과(색인 감시는 스케줄러 전용, 이건 워커 코드 곳곳에서 부른다) 지금 결합 이득이 적다는 것을 근거로 우선 보류한다. ★ webhook 이 설정 안 됐으면 보내지 않는다(is_configured). 값이 없어도 서버는 그대로 뜬다 — 운영 연결(실제 채널 지정)은 사용자 승인 후 별도로 한다. """ import os import httpx from common.logger import LOG WEBHOOK_URL_ENV = "TEAMS_WEBHOOK_URL" TIMEOUT_SEC = 10.0 def is_configured() -> bool: return bool(os.environ.get(WEBHOOK_URL_ENV, "").strip()) def _webhook_url() -> str: return os.environ.get(WEBHOOK_URL_ENV, "").strip() def _card(title: str, detail: str) -> dict: """Adaptive Card 1.2 — Teams Workflows 가 받는 최소 모양(search_console_alerts.py 와 같은 스키마).""" return { "type": "message", "attachments": [{ "contentType": "application/vnd.microsoft.card.adaptive", "contentUrl": None, "content": { "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", "type": "AdaptiveCard", "version": "1.2", "body": [ {"type": "TextBlock", "text": title, "weight": "Bolder", "wrap": True}, {"type": "TextBlock", "text": detail, "wrap": True}, ], }, }], } async def send(title: str, detail: str) -> bool: """설정된 경우에만 보낸다. 실패는 로그로 남기고 삼킨다 — 호출측(alert_service)이 재시도를 관리한다. ★ webhook 주소 자체가 인증 수단이다(URL 에 서명이 박혀 있다) — 예외 문자열에 그 URL 이 실릴 수 있어 로그에는 안 남긴다(search_console_alerts.py 와 같은 규칙).""" url = _webhook_url() if not url: return False if not url.startswith("https://"): LOG.w("[alert] TEAMS_WEBHOOK_URL_INVALID — https 가 아니다") return False try: async with httpx.AsyncClient(timeout=TIMEOUT_SEC, follow_redirects=False) as client: response = await client.post(url, json=_card(title, detail)) response.raise_for_status() return True except httpx.HTTPError: LOG.w("[alert] TEAMS_DELIVERY_FAILED") return False