From 534862f9684494d6b65da4b7ba4f4a0cd01dc2e4 Mon Sep 17 00:00:00 2001 From: AhmadHammad21 Date: Mon, 18 May 2026 21:36:32 +0300 Subject: [PATCH 1/4] feat: replace emoji integration icons with SVG brand icons - New IntegrationIcon component with proper SVG paths for Slack, Telegram, GitHub, PagerDuty, Datadog, SNS, and Email - SettingsPage: Slack card and Coming Soon cards now use SVG icons - AlertDetailPage: notification channel rows use SVG icons instead of emoji - Closes prep work for #41 (Telegram integration) --- .../src/components/icons/IntegrationIcon.tsx | 95 +++++++++++++++++++ frontend/src/pages/AlertDetailPage.tsx | 12 +-- frontend/src/pages/SettingsPage.tsx | 13 +-- 3 files changed, 104 insertions(+), 16 deletions(-) create mode 100644 frontend/src/components/icons/IntegrationIcon.tsx 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..f3a60bb 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, IntegrationIcon } from '../components/icons/IntegrationIcon'; import { toast } from 'sonner'; import { apiFetch } from '../lib/api'; import { useAuth } from '../context/AuthContext'; @@ -27,10 +28,10 @@ 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' }, + { name: 'Telegram', desc: 'Get alerts via Telegram bot', iconKey: 'telegram' }, ]; 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]'; @@ -275,7 +276,7 @@ export default function SettingsPage() {
- πŸ’¬ +
Slack Notifications
@@ -308,7 +309,7 @@ export default function SettingsPage() { {COMING_SOON_INTEGRATIONS.map((intg, i) => (
- {intg.icon} +
{intg.name}
From 3c3a2091e9fe6ccb55818792de8fff15898ca72c Mon Sep 17 00:00:00 2001 From: AhmadHammad21 Date: Mon, 18 May 2026 21:44:06 +0300 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20Telegram=20integration=20=E2=80=94?= =?UTF-8?q?=20bot=20notifications=20for=20investigations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #41 - src/integrations/telegram.py: post_investigation + post_failed_investigation using Telegram Bot API sendMessage with HTML parse mode - appsettings.py: TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID env vars - integrations router: POST /api/integrations/telegram/test endpoint - event_consumer + poller: deliver to Telegram after Slack; tracked in alert_notifications with channel='telegram' - SettingsPage: Telegram promoted from Coming Soon to live card with configured/not-configured badge, .env hint, and Send test message button - tests/integration/test_telegram.py: 7 tests covering message rendering and bot API delivery (mirrors test_slack.py pattern) - .env.example: document TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID --- .env.example | 4 + frontend/src/pages/SettingsPage.tsx | 78 +++++++++++++++-- src/agent/event_consumer.py | 21 +++++ src/agent/poller.py | 28 +++++- src/api/routers/integrations.py | 35 ++++++++ src/config/appsettings.py | 4 + src/integrations/telegram.py | 104 ++++++++++++++++++++++ tests/integration/test_telegram.py | 128 ++++++++++++++++++++++++++++ 8 files changed, 391 insertions(+), 11 deletions(-) create mode 100644 src/integrations/telegram.py create mode 100644 tests/integration/test_telegram.py 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/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index f3a60bb..4086993 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -1,6 +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, IntegrationIcon } from '../components/icons/IntegrationIcon'; +import { SlackIcon, TelegramIcon, IntegrationIcon } from '../components/icons/IntegrationIcon'; import { toast } from 'sonner'; import { apiFetch } from '../lib/api'; import { useAuth } from '../context/AuthContext'; @@ -28,10 +28,9 @@ const SVC: Record = { }; const COMING_SOON_INTEGRATIONS = [ - { 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' }, - { name: 'Telegram', desc: 'Get alerts via Telegram bot', iconKey: 'telegram' }, + { 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]'; @@ -44,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(''); @@ -75,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(() => {}); }, []); @@ -177,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; @@ -304,6 +324,50 @@ 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) => ( 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..5a52478 100644 --- a/src/api/routers/integrations.py +++ b/src/api/routers/integrations.py @@ -42,3 +42,38 @@ 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 post_investigation + + 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()) + await post_investigation( + bot_token=settings.telegram_bot_token, + chat_id=settings.telegram_chat_id, + result=test_result, + session_id=session_id, + is_test=True, + ) + return {"ok": True} 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..c4a3b4a --- /dev/null +++ b/src/integrations/telegram.py @@ -0,0 +1,104 @@ +"""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) -> bool: + 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: + logger.warning("Telegram API returned {}: {}", resp.status_code, resp.text) + return False + return True + except Exception as e: + logger.error("Telegram notification failed: {}", e) + return False + + +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"] From 1a897a5d45b96b017a7672d72b2cac4387ced13e Mon Sep 17 00:00:00 2001 From: AhmadHammad21 Date: Mon, 18 May 2026 21:46:12 +0300 Subject: [PATCH 3/4] docs: add telegram.md and update README and monitoring.md for Telegram integration --- README.md | 6 +- docs/monitoring.md | 3 +- docs/telegram.md | 150 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 docs/telegram.md 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 | From 9260d6628ea12442135148c82c74fd2583d55d75 Mon Sep 17 00:00:00 2001 From: AhmadHammad21 Date: Mon, 18 May 2026 21:57:07 +0300 Subject: [PATCH 4/4] fix: surface Telegram API error detail in test endpoint and add env vars to Settings - _post() now returns (bool, error_detail) tuple so callers can see the raw Telegram error response instead of just False - POST /api/integrations/telegram/test raises HTTP 502 with the actual Telegram error body when delivery fails (was silently returning ok: true) - Settings Environment tab now shows TELEGRAM_BOT_TOKEN (masked) and TELEGRAM_CHAT_ID so users can verify both vars are loaded --- src/api/routers/integrations.py | 14 +++++++++----- src/api/routers/settings.py | 6 ++++-- src/integrations/telegram.py | 16 +++++++++------- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/src/api/routers/integrations.py b/src/api/routers/integrations.py index 5a52478..2774498 100644 --- a/src/api/routers/integrations.py +++ b/src/api/routers/integrations.py @@ -55,7 +55,7 @@ async def test_telegram( detail="TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID must both be configured", ) - from integrations.telegram import post_investigation + from integrations.telegram import _build_message, _post test_result = { "root_cause_category": "SYSTEM_CHANGE", @@ -69,11 +69,15 @@ async def test_telegram( "services_affected": ["OpenDevOps Agent"], } session_id = str(uuid.uuid4()) - await post_investigation( + 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, - result=test_result, - session_id=session_id, - is_test=True, + 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/integrations/telegram.py b/src/integrations/telegram.py index c4a3b4a..8e171d1 100644 --- a/src/integrations/telegram.py +++ b/src/integrations/telegram.py @@ -46,7 +46,8 @@ def _build_message( return "\n".join(lines) -async def _post(bot_token: str, chat_id: str, text: str) -> bool: +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" @@ -58,12 +59,13 @@ async def _post(bot_token: str, chat_id: str, text: str) -> bool: "disable_web_page_preview": True, }) if resp.status_code != 200: - logger.warning("Telegram API returned {}: {}", resp.status_code, resp.text) - return False - return True + 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 + return False, str(e) async def post_investigation( @@ -75,7 +77,7 @@ async def post_investigation( ) -> 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) + ok, _ = await _post(bot_token, chat_id, text) if ok: logger.info("Telegram notification sent for session {}", session_id[:8]) return ok @@ -98,7 +100,7 @@ async def post_failed_investigation( f"Error: {error}\n\n" f"Session {session_id[:8]}" ) - ok = await _post(bot_token, chat_id, text) + ok, _ = await _post(bot_token, chat_id, text) if ok: logger.info("Telegram failure notification sent for session {}", session_id[:8]) return ok