diff --git a/.env.example b/.env.example index 1727d26..a626f4b 100644 --- a/.env.example +++ b/.env.example @@ -61,6 +61,10 @@ CHECKPOINT_BACKEND=memory # Slack — leave unset to disable notifications # SLACK_WEBHOOK_URL=https://hooks.slack.com/services/xxx/yyy/zzz +# Telegram — leave unset to disable notifications +# TELEGRAM_BOT_TOKEN=123456:ABC-... # from @BotFather +# TELEGRAM_CHAT_ID=-100123456789 # group/channel ID (negative = group) + # Proactive polling — set POLL_INTERVAL_SECONDS>0 to enable # POLL_INTERVAL_SECONDS=300 # how often to check (seconds); 0 = disabled; 20 for dev testing # POLL_ERROR_THRESHOLD=5.0 # Lambda error rate % that triggers an investigation diff --git a/README.md b/README.md index 29aea28..c927d5c 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ and gives actionable mitigation plans — without the AWS DevOps Agent price tag - **Streaming responses** — FastAPI SSE endpoint streams agent tokens in real time as the LLM reasons; tool calls appear as they complete - **Event-driven incident detection** — EventBridge → SQS → long-poll consumer; 9 EventBridge rules cover CloudWatch alarms, ECS task failures, Lambda async errors, RDS events, EC2 state changes, CodePipeline failures, and AWS Health events; runs alongside the metric poller — see [docs/event_detection.md](docs/event_detection.md) - **Context enrichment** — before the LLM runs, deterministic boto3 calls fetch facts about the affected resource (alarm details, recent logs, function config, etc.) to reduce tool call count and speed up investigations -- **SNS alert delivery** — after each event-driven investigation, findings are published to a configured SNS topic as structured JSON; fires alongside Slack so both channels receive results simultaneously +- **SNS alert delivery** — after each event-driven investigation, findings are published to a configured SNS topic as structured JSON; fires alongside Slack and Telegram so all channels receive results simultaneously - **Monitoring dashboard** — live incident feed showing all event-driven investigations: confidence level (or FAILED badge), affected service, root cause summary, SNS status; each alert links back to its original investigation session via **View investigation** so you can follow up without losing context — see [docs/monitoring.md](docs/monitoring.md) - **AWS Configuration settings tab** — admin-only editable tab in Settings for SNS Topic ARN, SQS Queue URL, and AWS Region; shared org-wide (server-side `init.json`); includes an inline IAM permission checker per service - **Web UI** — React + Vite SPA served by FastAPI: @@ -208,6 +208,8 @@ docs/ # Feature reference — auth, schema, skills, databases, | `INVESTIGATION_TIMEOUT` | `120` | Timeout in seconds | | `TOOL_RESPONSE_MAX_CHARS` | `40000` | Truncate tool responses larger than this before feeding to the LLM; `0` disables | | `SLACK_WEBHOOK_URL` | none | Slack incoming webhook URL; leave unset to disable notifications | +| `TELEGRAM_BOT_TOKEN` | none | Telegram bot token from @BotFather; leave unset to disable | +| `TELEGRAM_CHAT_ID` | none | Target chat/group/channel ID (negative number for groups) | | `POLL_INTERVAL_MINUTES` | `0` | Proactive polling interval in minutes; `0` disables the poller | | `POLL_ERROR_THRESHOLD` | `5.0` | Lambda error rate % that triggers an automatic investigation | | `POLL_REINVESTIGATE_HOURS` | `1` | Cooldown period — skip re-investigating the same alarm within N hours | @@ -247,7 +249,7 @@ docs/ # Feature reference — auth, schema, skills, databases, - [ ] **Guardrails** — input/output validation, PII scrubbing, query scope enforcement - [ ] **Multi-model escalation** — route simple queries to cheaper/smaller models, escalate hard investigations to larger ones - [x] **Fun streaming labels** — contextual loading copy ("Digging through CloudTrail…", "Lemonizing metrics…", "Cooking up a root cause…") -- [x] **Slack notifications** — reactive: posts a color-coded Block Kit message to a Slack webhook after every investigation; proactive: background poller checks CloudWatch alarms and Lambda error rates on a configurable interval, auto-investigates new anomalies, and posts findings without any human trigger; set `SLACK_WEBHOOK_URL` and `POLL_INTERVAL_MINUTES` in `.env` to enable +- [x] **Slack & Telegram notifications** — reactive: posts after every investigation to Slack (Block Kit) and/or Telegram (HTML bot message); proactive: background poller checks CloudWatch alarms and Lambda error rates, auto-investigates, and delivers to both channels; set `SLACK_WEBHOOK_URL` and/or `TELEGRAM_BOT_TOKEN`+`TELEGRAM_CHAT_ID` in `.env`; see [docs/telegram.md](docs/telegram.md) - [x] **Event-driven incident detection** — EventBridge → SQS → long-poll consumer; 9 EventBridge rules covering CloudWatch alarms, ECS, Lambda, RDS, EC2, CodePipeline, and AWS Health; runs in parallel with the metric poller; see [docs/event_detection.md](docs/event_detection.md) - [x] **SNS alert delivery** — findings published to SNS after each event-driven investigation; fires alongside Slack; structured JSON payload for easy downstream processing - [x] **Context enrichment** — deterministic boto3 calls per event type before LLM runs; reduces tool call count by front-loading relevant resource facts diff --git a/docs/monitoring.md b/docs/monitoring.md index 49865f3..7e7f96a 100644 --- a/docs/monitoring.md +++ b/docs/monitoring.md @@ -6,7 +6,7 @@ The Monitoring page shows a live feed of every incident detected and investigate ## Detection Modes -OpenDevOps Agent has two complementary detection modes. Both write to the same `/monitoring` page and Slack channel. +OpenDevOps Agent has two complementary detection modes. Both write to the same `/monitoring` page and can deliver to Slack and Telegram. | | Proactive Polling | Event-Driven (EventBridge → SQS) | |---|---|---| @@ -18,6 +18,7 @@ OpenDevOps Agent has two complementary detection modes. Both write to the same ` | **Event types covered** | CloudWatch alarms in ALARM state + Lambda error rates | CloudWatch alarms, ECS task failures, Lambda async errors, RDS events, EC2 state changes, CodePipeline failures, GuardDuty findings, AWS Health events | | **Appears in /monitoring** | Yes | Yes | | **Slack notifications** | Yes (if `SLACK_WEBHOOK_URL` set) | Yes (if `SLACK_WEBHOOK_URL` set) | +| **Telegram notifications** | Yes (if `TELEGRAM_BOT_TOKEN` + `TELEGRAM_CHAT_ID` set) | Yes (if `TELEGRAM_BOT_TOKEN` + `TELEGRAM_CHAT_ID` set) | **Recommendation:** enable both. Polling works immediately with zero AWS infrastructure and is faster for Lambda error detection. Event-driven catches a wider surface area (ECS, RDS, EC2, GuardDuty, etc.) once infrastructure is created. diff --git a/docs/telegram.md b/docs/telegram.md new file mode 100644 index 0000000..3c86ae4 --- /dev/null +++ b/docs/telegram.md @@ -0,0 +1,150 @@ +# Telegram Notifications + +OpenDevOps Agent can send investigation results to a Telegram chat via a bot. Both the event consumer (EventBridge → SQS) and the proactive poller deliver to Telegram alongside Slack — they are independent and can both be active at the same time. + +--- + +## Quick Setup + +### 1. Create a bot + +Open Telegram and message **@BotFather**: + +``` +/newbot +``` + +Follow the prompts — you'll receive a token like: + +``` +123456789:AAFxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +### 2. Get your chat ID + +Add the bot to the group or channel you want alerts in, then send any message to it and open this URL in your browser (replace the token): + +``` +https://api.telegram.org/bot/getUpdates +``` + +Find `"chat": { "id": -100123456789 }` in the response. **Group/channel IDs are negative numbers.** + +For a personal chat: message the bot directly, then check `getUpdates` — your personal chat ID is a positive number. + +### 3. Add to `.env` + +```bash +TELEGRAM_BOT_TOKEN=123456789:AAFxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +TELEGRAM_CHAT_ID=-100123456789 +``` + +Restart the server. OpenDevOps will start delivering to Telegram immediately. + +--- + +## Verify it works + +Go to **Settings → Integrations → Telegram** and click **Send test message**. You should see a formatted test investigation appear in your chat within a few seconds. + +You can also call the API directly: + +```bash +curl -X POST http://localhost:8000/api/integrations/telegram/test \ + -H "Authorization: Bearer " # omit if auth is disabled +``` + +--- + +## Message format + +Messages use Telegram's **HTML parse mode**. A successful investigation looks like: + +``` +🔍 OpenDevOps — Investigation Complete + +🔴 HIGH confidence · RESOURCE_LIMIT + +Root Cause +Lambda exhausted memory under burst traffic. + +Services affected: Lambda, CloudWatch + +Evidence +• MemoryUtilization reached 99% +• OOM errors spiked in CloudWatch logs + +Mitigation Steps +1. Increase Lambda memory to 1024 MB +2. Add CloudWatch memory alarm at 80% + +Session 1a2b3c4d +``` + +A failed investigation (agent hit the tool call limit or errored) looks like: + +``` +⚠️ OpenDevOps — Investigation Failed + +Service: payment-service +Error: Investigation failed: agent timeout after 120s + +Session 1a2b3c4d +``` + +--- + +## Delivery tracking + +Every Telegram notification is recorded in the `alert_notifications` table: + +```sql +SELECT channel, status, sent_at +FROM alert_notifications +WHERE alert_id = '' + AND channel = 'telegram'; +``` + +The **Monitoring → alert detail** page shows this under "Notified via" with a green check (delivered) or red X (failed). + +--- + +## Combining Slack and Telegram + +Both channels fire independently. Set both `SLACK_WEBHOOK_URL` and `TELEGRAM_BOT_TOKEN` / `TELEGRAM_CHAT_ID` to receive notifications in both places. Each delivery is tracked separately in `alert_notifications`. + +--- + +## Configuration reference + +| Variable | Required | Description | +|---|---|---| +| `TELEGRAM_BOT_TOKEN` | Yes | Bot token from @BotFather | +| `TELEGRAM_CHAT_ID` | Yes | Target chat/group/channel ID | + +Both must be set. If either is missing, Telegram delivery is silently skipped. + +--- + +## Troubleshooting + +| Symptom | Likely cause | Fix | +|---|---|---| +| "Not configured" badge in Settings | One or both env vars missing or `(not set)` | Add both vars to `.env` and restart | +| Test message returns 400 | Vars not loaded yet | Verify `.env` has both vars; restart the server | +| Message not received | Wrong chat ID, or bot not in the group | Re-run `getUpdates` to confirm chat ID; make sure the bot is a member of the group/channel | +| `chat not found` in server logs | Chat ID is wrong or bot was never added | Add bot to the group first, then send a message before checking `getUpdates` | +| Notifications show as `failed` in alert detail | Bot API returned non-200 | Check server logs for the exact Telegram error message | + +--- + +## Source files + +| File | Purpose | +|---|---| +| `src/integrations/telegram.py` | Bot API client — message builder + HTTP delivery | +| `src/agent/event_consumer.py` | Delivers to Telegram after each event-driven investigation | +| `src/agent/poller.py` | Delivers to Telegram after each polling investigation | +| `src/api/routers/integrations.py` | `POST /api/integrations/telegram/test` endpoint | +| `src/config/appsettings.py` | `telegram_bot_token` + `telegram_chat_id` settings | +| `tests/integration/test_telegram.py` | Unit tests for message rendering and delivery | diff --git a/frontend/src/components/icons/IntegrationIcon.tsx b/frontend/src/components/icons/IntegrationIcon.tsx new file mode 100644 index 0000000..733ff49 --- /dev/null +++ b/frontend/src/components/icons/IntegrationIcon.tsx @@ -0,0 +1,95 @@ +interface IconProps { + size?: number; + className?: string; +} + +export function SlackIcon({ size = 16, className }: IconProps) { + return ( + + {/* top-left: pink */} + + + {/* top-right: green */} + + + {/* bottom-right: yellow */} + + + {/* bottom-left: blue */} + + + + ); +} + +export function TelegramIcon({ size = 16, className }: IconProps) { + return ( + + + + + ); +} + +export function GitHubIcon({ size = 16, className }: IconProps) { + return ( + + + + ); +} + +export function PagerDutyIcon({ size = 16, className }: IconProps) { + return ( + + + + + + ); +} + +export function DatadogIcon({ size = 16, className }: IconProps) { + return ( + + + + + + + ); +} + +export function SnsIcon({ size = 16, className }: IconProps) { + return ( + + + + + ); +} + +export function EmailIcon({ size = 16, className }: IconProps) { + return ( + + + + + ); +} + +const ICONS: Record JSX.Element> = { + slack: SlackIcon, + telegram: TelegramIcon, + github: GitHubIcon, + pagerduty: PagerDutyIcon, + datadog: DatadogIcon, + sns: SnsIcon, + email: EmailIcon, +}; + +export function IntegrationIcon({ name, size = 16, className }: IconProps & { name: string }) { + const Icon = ICONS[name.toLowerCase()]; + if (!Icon) return null; + return ; +} diff --git a/frontend/src/pages/AlertDetailPage.tsx b/frontend/src/pages/AlertDetailPage.tsx index 9e5f83d..eb56939 100644 --- a/frontend/src/pages/AlertDetailPage.tsx +++ b/frontend/src/pages/AlertDetailPage.tsx @@ -4,6 +4,7 @@ import { ArrowLeft, Clock, MessageSquare, Loader2, CheckCircle2, XCircle, Radio import { cn } from '../lib/utils'; import { fetchAlert } from '../lib/api'; import type { Alert, AlertNotification } from '../types'; +import { IntegrationIcon } from '../components/icons/IntegrationIcon'; const CHANNEL_LABELS: Record = { slack: 'Slack', @@ -13,20 +14,11 @@ const CHANNEL_LABELS: Record = { pagerduty: 'PagerDuty', }; -const CHANNEL_ICONS: Record = { - slack: '💬', - sns: '📡', - telegram: '✈️', - email: '📧', - pagerduty: '📟', -}; - function NotificationRow({ n }: { n: AlertNotification }) { const label = CHANNEL_LABELS[n.channel] ?? n.channel; - const icon = CHANNEL_ICONS[n.channel] ?? '🔔'; return (
- {icon} + {label} {n.status === 'delivered' ? diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 0730cdd..4086993 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react'; import { ExternalLink, Eye, EyeOff, Key, CheckCircle, XCircle, AlertTriangle, Loader2, Shield, Radio, Trash2, Zap, Send } from 'lucide-react'; +import { SlackIcon, TelegramIcon, IntegrationIcon } from '../components/icons/IntegrationIcon'; import { toast } from 'sonner'; import { apiFetch } from '../lib/api'; import { useAuth } from '../context/AuthContext'; @@ -27,10 +28,9 @@ const SVC: Record = { }; const COMING_SOON_INTEGRATIONS = [ - { name: 'GitHub', desc: 'Connect your repositories', icon: '🐱' }, - { name: 'PagerDuty', desc: 'Alert on failures', icon: '📟' }, - { name: 'Datadog', desc: 'Send metrics and traces', icon: '📊' }, - { name: 'Telegram', desc: 'Get alerts via Telegram bot', icon: '✈️' }, + { name: 'GitHub', desc: 'Connect your repositories', iconKey: 'github' }, + { name: 'PagerDuty', desc: 'Alert on failures', iconKey: 'pagerduty' }, + { name: 'Datadog', desc: 'Send metrics and traces', iconKey: 'datadog' }, ]; const inputCls = 'w-full font-mono text-[12px] text-gray-700 dark:text-[#CBD5E1] bg-white dark:bg-[#0F0F12] border border-gray-200 dark:border-[#27272F] rounded-md px-3 py-1.5 outline-none focus:border-indigo-500 dark:focus:border-[#818CF8] focus:ring-1 focus:ring-indigo-500/20 dark:focus:ring-[#818CF8]/20 transition-all placeholder:text-gray-300 dark:placeholder:text-[#3F3F47]'; @@ -43,8 +43,10 @@ export default function SettingsPage() { const [data, setData] = useState(null); // Integrations tab state - const [slackConfigured, setSlackConfigured] = useState(false); - const [slackTesting, setSlackTesting] = useState(false); + const [slackConfigured, setSlackConfigured] = useState(false); + const [slackTesting, setSlackTesting] = useState(false); + const [telegramConfigured, setTelegramConfigured] = useState(false); + const [telegramTesting, setTelegramTesting] = useState(false); // AWS tab state const [snsArn, setSnsArn] = useState(''); @@ -74,6 +76,12 @@ export default function SettingsPage() { setData(sd); const slackEnv = sd.env.find(e => e.key === 'SLACK_WEBHOOK_URL'); setSlackConfigured(!!slackEnv && slackEnv.value !== '(not set)'); + const tgToken = sd.env.find(e => e.key === 'TELEGRAM_BOT_TOKEN'); + const tgChat = sd.env.find(e => e.key === 'TELEGRAM_CHAT_ID'); + setTelegramConfigured( + !!tgToken && tgToken.value !== '(not set)' && + !!tgChat && tgChat.value !== '(not set)' + ); }) .catch(() => {}); }, []); @@ -176,6 +184,19 @@ export default function SettingsPage() { } }; + const testTelegram = async () => { + setTelegramTesting(true); + try { + const r = await apiFetch('/api/integrations/telegram/test', { method: 'POST' }); + if (!r.ok) throw new Error('Request failed'); + toast.success('Test message sent to Telegram'); + } catch { + toast.error('Failed to send test message'); + } finally { + setTelegramTesting(false); + } + }; + const passed = Object.values(perms).filter(r => r.passed).length; const total = Object.keys(perms).length; @@ -275,7 +296,7 @@ export default function SettingsPage() {
- 💬 +
Slack Notifications
@@ -303,12 +324,56 @@ export default function SettingsPage() {
+ {/* Telegram — live integration */} +
+
+ Telegram + + {telegramConfigured ? 'Configured' : 'Not configured'} + +
+
+
+ +
+
+
Telegram Notifications
+
+ Sends a formatted message to your Telegram bot after each investigation completes. +
+ {!telegramConfigured && ( +
+
Add TELEGRAM_BOT_TOKEN=123456:ABC-… to your .env file
+
Add TELEGRAM_CHAT_ID=-100… to your .env file and restart
+
+ )} + {telegramConfigured && ( + + )} +
+
+
+ {/* Coming soon integrations */}
{COMING_SOON_INTEGRATIONS.map((intg, i) => (
- {intg.icon} +
{intg.name}
diff --git a/src/agent/event_consumer.py b/src/agent/event_consumer.py index 5e4c462..566921f 100644 --- a/src/agent/event_consumer.py +++ b/src/agent/event_consumer.py @@ -333,6 +333,25 @@ async def _deliver(result: dict[str, Any], event: dict, dedup_key: str, session_ except Exception as e: logger.error("Slack delivery failed from event consumer: {}", e) + telegram_sent = False + if settings.telegram_bot_token and settings.telegram_chat_id: + try: + from integrations.telegram import post_failed_investigation as tg_fail + from integrations.telegram import post_investigation as tg_ok + + if status == "failed": + telegram_sent = await tg_fail( + settings.telegram_bot_token, settings.telegram_chat_id, + service, root_cause, session_id, is_test=is_test, + ) + else: + telegram_sent = await tg_ok( + settings.telegram_bot_token, settings.telegram_chat_id, + result, session_id, is_test=is_test, + ) + except Exception as e: + logger.error("Telegram delivery failed from event consumer: {}", e) + update_service(service, status, root_cause) alert_id = await add_alert( service=service, @@ -350,6 +369,8 @@ async def _deliver(result: dict[str, Any], event: dict, dedup_key: str, session_ await add_notification(alert_id, "sns", "delivered" if sns_sent else "failed") if settings.slack_webhook_url: await add_notification(alert_id, "slack", "delivered" if slack_sent else "failed") + if settings.telegram_bot_token and settings.telegram_chat_id: + await add_notification(alert_id, "telegram", "delivered" if telegram_sent else "failed") logger.info("Alert delivered: service={} confidence={} sns={}", service, confidence, sns_sent) diff --git a/src/agent/poller.py b/src/agent/poller.py index 6f0d98f..571df18 100644 --- a/src/agent/poller.py +++ b/src/agent/poller.py @@ -185,6 +185,25 @@ async def _persist_and_notify( else: slack_sent = await notify_slack(session_id, tool_calls_log) + telegram_sent = False + if settings.telegram_bot_token and settings.telegram_chat_id: + try: + from integrations.telegram import post_failed_investigation as tg_fail + from integrations.telegram import post_investigation as tg_ok + + if status == "failed": + telegram_sent = await tg_fail( + settings.telegram_bot_token, settings.telegram_chat_id, + service, root_cause, session_id, + ) + else: + telegram_sent = await tg_ok( + settings.telegram_bot_token, settings.telegram_chat_id, + result, session_id, + ) + except Exception as e: + logger.error("Telegram delivery failed from poller: {}", e) + update_service(service, status, root_cause) alert_id = await add_alert( service=service, @@ -197,10 +216,11 @@ async def _persist_and_notify( session_id=session_id, trigger_source="poller", ) - if alert_id and settings.slack_webhook_url: - await add_notification( - alert_id, "slack", "delivered" if slack_sent else "failed" - ) + if alert_id: + if settings.slack_webhook_url: + await add_notification(alert_id, "slack", "delivered" if slack_sent else "failed") + if settings.telegram_bot_token and settings.telegram_chat_id: + await add_notification(alert_id, "telegram", "delivered" if telegram_sent else "failed") async def _check_alarms() -> None: diff --git a/src/api/routers/integrations.py b/src/api/routers/integrations.py index 059ce41..2774498 100644 --- a/src/api/routers/integrations.py +++ b/src/api/routers/integrations.py @@ -42,3 +42,42 @@ async def test_slack( is_test=True, ) return {"ok": True} + + +@router.post("/telegram/test") +async def test_telegram( + _user: Annotated[dict | None, Depends(get_current_user)], +) -> dict: + """Send a test Telegram message to verify the bot token and chat ID are working.""" + if not settings.telegram_bot_token or not settings.telegram_chat_id: + raise HTTPException( + status_code=400, + detail="TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID must both be configured", + ) + + from integrations.telegram import _build_message, _post + + test_result = { + "root_cause_category": "SYSTEM_CHANGE", + "root_cause_summary": ( + "This is a test message from OpenDevOps Agent " + "to verify your Telegram integration is working correctly." + ), + "confidence": "HIGH", + "evidence": ["Test triggered from Settings → Integrations"], + "mitigation_steps": ["No action needed — this is a test"], + "services_affected": ["OpenDevOps Agent"], + } + session_id = str(uuid.uuid4()) + text = _build_message(test_result, session_id, is_test=True) + ok, error_detail = await _post( + bot_token=settings.telegram_bot_token, + chat_id=settings.telegram_chat_id, + text=text, + ) + if not ok: + raise HTTPException( + status_code=502, + detail=f"Telegram API rejected the message: {error_detail}", + ) + return {"ok": True} diff --git a/src/api/routers/settings.py b/src/api/routers/settings.py index c3ecbaf..ec6f79e 100644 --- a/src/api/routers/settings.py +++ b/src/api/routers/settings.py @@ -39,8 +39,10 @@ async def get_settings( {"key": "AWS_SECRET_ACCESS_KEY", "value": _mask(os.environ.get("AWS_SECRET_ACCESS_KEY")), "secret": True}, {"key": "CHECKPOINT_BACKEND", "value": settings.checkpoint_backend, "secret": False}, {"key": "DATABASE_URL", "value": _mask(settings.database_url), "secret": True}, - {"key": "SLACK_WEBHOOK_URL", "value": _mask(settings.slack_webhook_url), "secret": True}, - {"key": "JWT_SECRET", "value": _mask(settings.jwt_secret), "secret": True}, + {"key": "SLACK_WEBHOOK_URL", "value": _mask(settings.slack_webhook_url), "secret": True}, + {"key": "TELEGRAM_BOT_TOKEN", "value": _mask(settings.telegram_bot_token), "secret": True}, + {"key": "TELEGRAM_CHAT_ID", "value": settings.telegram_chat_id or "(not set)", "secret": False}, + {"key": "JWT_SECRET", "value": _mask(settings.jwt_secret), "secret": True}, ] agent = [ diff --git a/src/config/appsettings.py b/src/config/appsettings.py index 4688449..deb328d 100644 --- a/src/config/appsettings.py +++ b/src/config/appsettings.py @@ -54,6 +54,10 @@ class Settings(BaseSettings): # Slack — leave unset to disable notifications slack_webhook_url: str | None = None + # Telegram — leave unset to disable notifications + telegram_bot_token: str | None = None + telegram_chat_id: str | None = None + # Proactive polling — set poll_interval_seconds=0 to disable poll_interval_seconds: int = 0 # disabled by default poll_error_threshold: float = 5.0 # % Lambda error rate that triggers investigation diff --git a/src/integrations/telegram.py b/src/integrations/telegram.py new file mode 100644 index 0000000..8e171d1 --- /dev/null +++ b/src/integrations/telegram.py @@ -0,0 +1,106 @@ +"""Telegram Bot API integration — posts investigation results as formatted messages.""" + +from __future__ import annotations + +from typing import Any + +from loguru import logger + +_CONFIDENCE_EMOJI: dict[str, str] = { + "HIGH": "🔴", "MEDIUM": "🟡", "LOW": "🟢", +} + + +def _build_message( + result: dict[str, Any], + session_id: str, + is_test: bool = False, +) -> str: + category = result.get("root_cause_category", "UNKNOWN") + summary = result.get("root_cause_summary", "No summary provided.") + confidence = result.get("confidence", "LOW") + evidence = result.get("evidence", []) + mitigation = result.get("mitigation_steps", []) + services = result.get("services_affected", []) + conf_emoji = _CONFIDENCE_EMOJI.get(confidence, "🟢") + + header = "🧪 [TEST] OpenDevOps — Investigation Complete" if is_test else \ + "🔍 OpenDevOps — Investigation Complete" + + lines = [header, ""] + lines += [f"{conf_emoji} {confidence} confidence · {category}", ""] + lines += [f"Root Cause\n{summary}", ""] + + if services: + lines += [f"Services affected: {', '.join(f'{s}' for s in services)}", ""] + + if evidence: + ev_text = "\n".join(f"• {e}" for e in evidence[:5]) + lines += [f"Evidence\n{ev_text}", ""] + + if mitigation: + mt_text = "\n".join(f"{i+1}. {s}" for i, s in enumerate(mitigation)) + lines += [f"Mitigation Steps\n{mt_text}", ""] + + lines.append(f"Session {session_id[:8]}") + return "\n".join(lines) + + +async def _post(bot_token: str, chat_id: str, text: str) -> tuple[bool, str]: + """Return (success, error_detail). error_detail is empty on success.""" + try: + import httpx + url = f"https://api.telegram.org/bot{bot_token}/sendMessage" + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.post(url, json={ + "chat_id": chat_id, + "text": text, + "parse_mode": "HTML", + "disable_web_page_preview": True, + }) + if resp.status_code != 200: + detail = resp.text + logger.warning("Telegram API returned {}: {}", resp.status_code, detail) + return False, detail + return True, "" + except Exception as e: + logger.error("Telegram notification failed: {}", e) + return False, str(e) + + +async def post_investigation( + bot_token: str, + chat_id: str, + result: dict[str, Any], + session_id: str, + is_test: bool = False, +) -> bool: + """Post a completed investigation result to Telegram. Returns True on success.""" + text = _build_message(result, session_id, is_test=is_test) + ok, _ = await _post(bot_token, chat_id, text) + if ok: + logger.info("Telegram notification sent for session {}", session_id[:8]) + return ok + + +async def post_failed_investigation( + bot_token: str, + chat_id: str, + service: str, + error: str, + session_id: str, + is_test: bool = False, +) -> bool: + """Post a failed investigation alert to Telegram. Returns True on success.""" + header = "🧪 [TEST] OpenDevOps — Investigation Failed" if is_test else \ + "⚠️ OpenDevOps — Investigation Failed" + text = ( + f"{header}\n\n" + f"Service: {service}\n" + f"Error: {error}\n\n" + f"Session {session_id[:8]}" + ) + ok, _ = await _post(bot_token, chat_id, text) + if ok: + logger.info("Telegram failure notification sent for session {}", session_id[:8]) + return ok diff --git a/tests/integration/test_telegram.py b/tests/integration/test_telegram.py new file mode 100644 index 0000000..d9509cb --- /dev/null +++ b/tests/integration/test_telegram.py @@ -0,0 +1,128 @@ +"""Assertive tests for Telegram message rendering and bot API delivery.""" + +from __future__ import annotations + +import asyncio + +from integrations.telegram import _build_message, post_failed_investigation, post_investigation + +SAMPLE_RESULT = { + "root_cause_category": "RESOURCE_LIMIT", + "root_cause_summary": "Lambda exhausted memory under burst traffic.", + "confidence": "HIGH", + "evidence": [ + "MemoryUtilization reached 99%", + "OOM errors spiked in CloudWatch logs", + ], + "mitigation_steps": [ + "Increase Lambda memory", + "Add memory alarm at 80%", + ], + "services_affected": ["Lambda", "CloudWatch"], +} + + +def test_build_message_contains_confidence_category_and_summary(): + text = _build_message(SAMPLE_RESULT, "session-1234") + + assert "HIGH" in text + assert "RESOURCE_LIMIT" in text + assert "Lambda exhausted memory" in text + assert "session-1234"[:8] in text + + +def test_build_message_test_flag_adds_test_header(): + text = _build_message(SAMPLE_RESULT, "session-abcd", is_test=True) + assert "[TEST]" in text + + +def test_build_message_includes_mitigation_steps(): + text = _build_message(SAMPLE_RESULT, "session-1234") + assert "Increase Lambda memory" in text + assert "Add memory alarm at 80%" in text + + +def test_build_message_includes_services_affected(): + text = _build_message(SAMPLE_RESULT, "session-1234") + assert "Lambda" in text + assert "CloudWatch" in text + + +def _make_fake_client(captured: dict, status: int = 200): + class FakeResponse: + status_code = status + text = "ok" if status == 200 else "error" + + class FakeClient: + def __init__(self, timeout): + self.timeout = timeout + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + _ = (exc_type, exc, tb) + + async def post(self, url, json): + captured["url"] = url + captured["json"] = json + return FakeResponse() + + return FakeClient + + +def test_post_investigation_sends_to_bot_api(monkeypatch): + captured: dict = {} + monkeypatch.setattr("httpx.AsyncClient", _make_fake_client(captured)) + + result = asyncio.run( + post_investigation( + bot_token="123456:TEST-TOKEN", + chat_id="-100123456789", + result=SAMPLE_RESULT, + session_id="session-1234", + ) + ) + + assert result is True + assert "api.telegram.org" in captured["url"] + assert "123456:TEST-TOKEN" in captured["url"] + assert captured["json"]["chat_id"] == "-100123456789" + assert captured["json"]["parse_mode"] == "HTML" + assert "Lambda exhausted memory" in captured["json"]["text"] + + +def test_post_investigation_returns_false_on_non_200(monkeypatch): + captured: dict = {} + monkeypatch.setattr("httpx.AsyncClient", _make_fake_client(captured, status=400)) + + result = asyncio.run( + post_investigation( + bot_token="123456:TEST-TOKEN", + chat_id="-100123456789", + result=SAMPLE_RESULT, + session_id="session-1234", + ) + ) + + assert result is False + + +def test_post_failed_investigation_sends_service_and_error(monkeypatch): + captured: dict = {} + monkeypatch.setattr("httpx.AsyncClient", _make_fake_client(captured)) + + result = asyncio.run( + post_failed_investigation( + bot_token="123456:TEST-TOKEN", + chat_id="-100123456789", + service="payment-service", + error="Agent timeout after 120s", + session_id="session-abcd", + ) + ) + + assert result is True + assert "payment-service" in captured["json"]["text"] + assert "Agent timeout after 120s" in captured["json"]["text"] + assert "⚠️" in captured["json"]["text"]