From b4b1b2b17a28ffc3317ab1dc56200736745441f2 Mon Sep 17 00:00:00 2001 From: Shikhar-404exe Date: Tue, 2 Jun 2026 22:29:41 +0530 Subject: [PATCH 1/2] feat: add webhook notification system for Slack, Discord, and email alerts - Add modular provider system (Slack/Discord/Email) with abstract base class - Implement fire-and-forget dispatch isolated from core scheduler flow - Add per-event-type rate limiting (default 300s window) - Fire notifications after scrape/discover/draft cycles: jobs:new, contacts:found, emails:drafted, scrape:error, cycle:complete - Add Gateway REST endpoints: GET/PUT /api/notifications/config, POST /api/notifications/test - Add Settings tab to dashboard with webhook URL inputs, event-type checkboxes, and test buttons for each channel - Reuse existing Gmail SMTP infrastructure for email notifications - Document new env vars and add to docker-compose.yml --- docker-compose.yml | 13 ++- gateway/dashboard.html | 156 ++++++++++++++++++++++++++ gateway/main.py | 115 ++++++++++++++++++- scheduler/README.md | 5 + scheduler/main.py | 27 +++++ scheduler/notifier.py | 188 ++++++++++++++++++++++++++++++++ scheduler/providers/__init__.py | 6 + scheduler/providers/base.py | 24 ++++ scheduler/providers/discord.py | 42 +++++++ scheduler/providers/email.py | 51 +++++++++ scheduler/providers/slack.py | 57 ++++++++++ scheduler/tasks.py | 43 ++++++++ 12 files changed, 725 insertions(+), 2 deletions(-) create mode 100644 scheduler/notifier.py create mode 100644 scheduler/providers/__init__.py create mode 100644 scheduler/providers/base.py create mode 100644 scheduler/providers/discord.py create mode 100644 scheduler/providers/email.py create mode 100644 scheduler/providers/slack.py diff --git a/docker-compose.yml b/docker-compose.yml index 2302ae5..d04c714 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -158,7 +158,12 @@ services: SCRAPER_URL: http://scraper:8000 CONTACT_URL: http://contact:8000 EMAIL_GEN_URL: http://email-gen:8000 - SUMMARY_PATH: /data/run_summary.json + SUMMARY_PATH: /data/run_summary.json + NOTIFY_CONFIG_PATH: /data/notify_config.json + SLACK_WEBHOOK_URL: ${SLACK_WEBHOOK_URL:-} + DISCORD_WEBHOOK_URL: ${DISCORD_WEBHOOK_URL:-} + NOTIFICATION_EMAIL: ${NOTIFICATION_EMAIL:-} + NOTIFY_RATE_LIMIT_SECS: ${NOTIFY_RATE_LIMIT_SECS:-300} volumes: - scheduler_data:/data depends_on: @@ -192,6 +197,12 @@ services: SCRAPER_WAIT_SECS: ${SCRAPER_WAIT_SECS:-60} DISCOVER_DELAY_SECS: ${DISCOVER_DELAY_SECS:-30} SUMMARY_PATH: /data/run_summary.json + # Notification / Webhook config + SLACK_WEBHOOK_URL: ${SLACK_WEBHOOK_URL:-} + DISCORD_WEBHOOK_URL: ${DISCORD_WEBHOOK_URL:-} + NOTIFICATION_EMAIL: ${NOTIFICATION_EMAIL:-} + NOTIFY_RATE_LIMIT_SECS: ${NOTIFY_RATE_LIMIT_SECS:-300} + NOTIFY_EVENTS: ${NOTIFY_EVENTS:-} volumes: - scheduler_data:/data # shared with gateway for GET /api/summary depends_on: diff --git a/gateway/dashboard.html b/gateway/dashboard.html index 2aff088..80d7311 100644 --- a/gateway/dashboard.html +++ b/gateway/dashboard.html @@ -801,6 +801,76 @@ .summary-row:last-child { border-bottom: none; } .summary-row span:first-child { color: var(--text-dim); } + /* ============================================================ + SETTINGS + ============================================================ */ + .settings-group { + max-width: 680px; + margin: 0 auto; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 28px 32px; + } + .settings-group h3 { + font-size: 20px; + font-weight: 600; + color: var(--text); + margin-bottom: 4px; + } + .settings-desc { + font-size: 13px; + color: var(--text-dim); + margin-bottom: 20px; + } + .settings-group .field-group { + margin-bottom: 16px; + } + .settings-group .field-group label { + display: block; + font-size: 13px; + font-weight: 500; + color: var(--text-dim); + margin-bottom: 4px; + } + .settings-group .field-group input { + width: 100%; + padding: 10px 12px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-2); + color: var(--text); + font-size: 14px; + } + .settings-group .field-group input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-low); + } + .toggle-row { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 0; + cursor: pointer; + font-size: 14px; + color: var(--text); + border-bottom: 1px solid var(--border); + } + .toggle-row input[type="checkbox"] { + width: 18px; + height: 18px; + accent-color: var(--accent); + cursor: pointer; + } + .toggle-row:hover { color: var(--accent); } + .settings-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 24px; + } + /* ============================================================ MODAL ============================================================ */ @@ -1098,6 +1168,7 @@ + @@ -1240,6 +1311,49 @@

Last Pipeline Run

+ +
+
+

๐Ÿ”” Notifications

+

Configure webhook and email notifications for automation events.

+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +

Event Types

+

Choose which events should trigger notifications.

+
+ + + + + + +
+ +
+ + + + +
+
+
+ @@ -1526,6 +1640,41 @@

Keyboard Shortcuts

navigator.clipboard?.writeText(text).then(() => toast('Copied!', 'success')); } + // =========================================================================== + // NOTIFICATION SETTINGS + // =========================================================================== + async function loadNotifyConfig() { + const data = await apiFetch('/notifications/config'); + if (!data) return; + document.getElementById('notify-slack-url').value = data.slack_webhook_url || ''; + document.getElementById('notify-discord-url').value = data.discord_webhook_url || ''; + document.getElementById('notify-email').value = data.notification_email || ''; + document.getElementById('notify-rate-limit').value = data.rate_limit_secs || 300; + const checks = document.querySelectorAll('#notify-events-list input[type="checkbox"]'); + const enabled = new Set(data.notify_events || []); + checks.forEach(cb => { cb.checked = enabled.has(cb.value); }); + } + + async function saveNotifyConfig() { + const checks = document.querySelectorAll('#notify-events-list input[type="checkbox"]:checked'); + const events = Array.from(checks).map(cb => cb.value); + const body = { + slack_webhook_url: document.getElementById('notify-slack-url').value.trim(), + discord_webhook_url: document.getElementById('notify-discord-url').value.trim(), + notification_email: document.getElementById('notify-email').value.trim(), + notify_events: events, + rate_limit_secs: parseInt(document.getElementById('notify-rate-limit').value) || 300, + }; + const res = await apiFetch('/notifications/config', { method: 'PUT', body }); + if (res) toast('Notification settings saved!', 'success'); + } + + async function testNotification(channel) { + const res = await apiFetch(`/notifications/test?channel=${channel}`, { method: 'POST' }); + if (res) toast(`Test ${channel} notification sent!`, 'success'); + else toast(`Test ${channel} notification failed`, 'error'); + } + // =========================================================================== // JOBS VIEW // =========================================================================== @@ -2062,6 +2211,7 @@

Emails

else if (tab === 'contacts') loadContacts(); else if (tab === 'emails') loadEmails(); else if (tab === 'stats') loadStats(); + else if (tab === 'settings') loadNotifyConfig(); } function isTypingTarget(target) { @@ -2306,6 +2456,12 @@

Emails

document.getElementById('btn-dismiss-banner').addEventListener('click', hideBanner); + // Notification buttons + document.getElementById('btn-save-notify')?.addEventListener('click', saveNotifyConfig); + document.getElementById('btn-test-slack')?.addEventListener('click', () => testNotification('slack')); + document.getElementById('btn-test-discord')?.addEventListener('click', () => testNotification('discord')); + document.getElementById('btn-test-email')?.addEventListener('click', () => testNotification('email')); + // =========================================================================== // INIT // =========================================================================== diff --git a/gateway/main.py b/gateway/main.py index 55c294f..5852854 100644 --- a/gateway/main.py +++ b/gateway/main.py @@ -41,6 +41,7 @@ DASHBOARD_PATH = Path(__file__).parent / "dashboard.html" SUMMARY_PATH = Path(os.environ.get("SUMMARY_PATH", "/data/run_summary.json")) +NOTIFY_CONFIG_PATH = Path(os.environ.get("NOTIFY_CONFIG_PATH", "/data/notify_config.json")) # --------------------------------------------------------------------------- @@ -118,6 +119,118 @@ async def run_summary(): raise HTTPException(status_code=500, detail=f"Could not read summary: {exc}") +# --------------------------------------------------------------------------- +# Notification config โ€” stored in shared volume /data/notify_config.json +# --------------------------------------------------------------------------- + +_NOTIFY_DEFAULTS = { + "slack_webhook_url": os.environ.get("SLACK_WEBHOOK_URL", ""), + "discord_webhook_url": os.environ.get("DISCORD_WEBHOOK_URL", ""), + "notification_email": os.environ.get("NOTIFICATION_EMAIL", ""), + "notify_events": ["jobs:new", "contacts:found", "emails:drafted", "emails:sent", "scrape:error", "cycle:complete"], + "rate_limit_secs": int(os.environ.get("NOTIFY_RATE_LIMIT_SECS", "300")), +} + + +def _load_notify_config() -> dict: + if NOTIFY_CONFIG_PATH.exists(): + try: + return json.loads(NOTIFY_CONFIG_PATH.read_text()) + except Exception: + pass + return dict(_NOTIFY_DEFAULTS) + + +def _save_notify_config(data: dict) -> None: + NOTIFY_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + NOTIFY_CONFIG_PATH.write_text(json.dumps(data, indent=2)) + + +@app.get("/api/notifications/config", tags=["notifications"]) +async def get_notify_config(): + return JSONResponse(content=_load_notify_config()) + + +class NotifyConfigUpdate(BaseModel): + slack_webhook_url: str = "" + discord_webhook_url: str = "" + notification_email: str = "" + notify_events: list[str] = ["jobs:new", "contacts:found", "emails:drafted", "emails:sent", "scrape:error", "cycle:complete"] + rate_limit_secs: int = 300 + + +@app.put("/api/notifications/config", tags=["notifications"]) +async def update_notify_config(body: NotifyConfigUpdate): + _save_notify_config(body.model_dump()) + return JSONResponse(content={"status": "saved", **body.model_dump()}) + + +@app.post("/api/notifications/test", tags=["notifications"]) +async def send_test_notification(channel: str = "slack"): + """ + Send a test notification to verify channel configuration. + The gateway sends this directly to avoid requiring an HTTP endpoint on the scheduler. + channel: slack | discord | email + """ + config = _load_notify_config() + payload = { + "title": "Test Notification from Arachnode", + "message": "This is a test notification. Your notification channel is configured correctly!", + "fields": {"service": "Arachnode Gateway", "version": "1.0.0", "channel": channel}, + "severity": "info", + } + + try: + if channel == "slack": + url = config.get("slack_webhook_url", "") + if not url: + raise HTTPException(status_code=400, detail="Slack webhook URL not configured") + blocks = [ + {"type": "header", "text": {"type": "plain_text", "text": payload["title"]}}, + {"type": "section", "text": {"type": "mrkdwn", "text": payload["message"]}}, + {"type": "section", "fields": [{"type": "mrkdwn", "text": f"*{k}:* {v}"} for k, v in payload["fields"].items()]}, + ] + body = {"attachments": [{"color": "#4f9eff", "blocks": blocks}]} + + elif channel == "discord": + url = config.get("discord_webhook_url", "") + if not url: + raise HTTPException(status_code=400, detail="Discord webhook URL not configured") + embed = { + "title": payload["title"], + "description": payload["message"], + "color": 0x4F9EFF, + "fields": [{"name": k, "value": str(v), "inline": True} for k, v in payload["fields"].items()], + } + body = {"embeds": [embed]} + + elif channel == "email": + to_addr = config.get("notification_email", "") + gmail = os.environ.get("GMAIL_ADDRESS", "") + if not to_addr: + raise HTTPException(status_code=400, detail="Notification email not configured") + if not gmail: + raise HTTPException(status_code=400, detail="GMAIL_ADDRESS not set โ€” email notifications require SMTP config") + return JSONResponse(content={ + "status": "config_valid", + "channel": channel, + "detail": "Email config saved. Test email will be sent on next scheduler cycle.", + }) + + else: + raise HTTPException(status_code=400, detail=f"Unknown channel: {channel}") + + async with httpx.AsyncClient(timeout=15) as c: + resp = await c.post(url, json=body) + resp.raise_for_status() + return JSONResponse(content={"status": "ok", "channel": channel}) + + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=502, detail=f"Test notification failed: {exc}") + + # --------------------------------------------------------------------------- # Proxy routes โ€” /api/jobs/* # --------------------------------------------------------------------------- @@ -254,7 +367,7 @@ async def workflow_apply(body: WorkflowRequest): logger.warning("Email generation failed: %s", exc) email = None - return { + return { "job": job, "contacts": contacts, "draft_email": email, diff --git a/scheduler/README.md b/scheduler/README.md index 1203fe5..f5f25d5 100644 --- a/scheduler/README.md +++ b/scheduler/README.md @@ -56,6 +56,11 @@ scheduler/ | `DISCOVER_DELAY_SECS` | `30` | Delay between each per-job discover call | | `SCRAPY_PROJECT_DIR` | `/crawler` | CWD for `scrapy crawl` subprocess | | `SUMMARY_PATH` | `/data/run_summary.json` | Output path for run summary | +| `SLACK_WEBHOOK_URL` | โ€” | Slack Incoming Webhook URL for notifications | +| `DISCORD_WEBHOOK_URL` | โ€” | Discord Webhook URL for notifications | +| `NOTIFICATION_EMAIL` | โ€” | Email address for email notifications (uses Gmail SMTP) | +| `NOTIFY_RATE_LIMIT_SECS` | `300` | Rate limit window per event type (seconds) | +| `NOTIFY_EVENTS` | _(all)_ | Comma-separated enabled event types (e.g. `jobs:new,emails:drafted`) | --- diff --git a/scheduler/main.py b/scheduler/main.py index 3f71291..7896649 100644 --- a/scheduler/main.py +++ b/scheduler/main.py @@ -32,6 +32,7 @@ import tasks from logger import get_logger +from notifier import notifier, configure as configure_notifications log = get_logger("scheduler.main") @@ -79,10 +80,28 @@ def _run(task_fn, task_name: str) -> None: task_fn() _write_summary() log.info("Task complete", task=task_name) + + # Notify on errors that occurred during the cycle + errors = tasks._summary.get("errors", []) + if errors: + notifier( + "scrape:error" if task_name == "scrape" else "cycle:complete", + f"{task_name.title()} Cycle โ€” Errors Detected", + f"The {task_name} cycle completed with {len(errors)} error(s).", + fields={"errors": "; ".join(e.get("detail", "?")[:200] for e in errors[:5])}, + severity="error", + ) except Exception as exc: log.exception("Task raised an exception", task=task_name, error=str(exc)) tasks._summary["errors"].append({"context": task_name, "detail": str(exc)}) _write_summary() + notifier( + "scrape:error" if task_name == "scrape" else "cycle:complete", + f"{task_name.title()} Cycle Failed", + f"The {task_name} cycle raised an unhandled exception.", + fields={"error": str(exc)[:300]}, + severity="error", + ) finally: _task_lock.release() @@ -227,6 +246,14 @@ def main() -> None: role=os.environ.get("JOBSEEKER_ROLE", "Backend Engineer"), ) + # Load notification config + enabled_raw = os.environ.get("NOTIFY_EVENTS", "").strip() + if enabled_raw: + configure_notifications([e.strip() for e in enabled_raw.split(",") if e.strip()]) + log.info("Notification events filtered", enabled=enabled_raw) + else: + configure_notifications(None) # all enabled + _maybe_manual_run() # exits if MANUAL_TASK is set scheduler = build_scheduler() diff --git a/scheduler/notifier.py b/scheduler/notifier.py new file mode 100644 index 0000000..3d22292 --- /dev/null +++ b/scheduler/notifier.py @@ -0,0 +1,188 @@ +""" +notifier.py โ€” Webhook & notification dispatcher for the Scheduler Service. + +Keeps notification delivery isolated from core scheduler flow: + - All provider calls are wrapped in try/except โ€” failures never + propagate to the caller (fire-and-forget with logging). + - Rate limiting prevents spam: at most 1 notification per event type + per RATE_LIMIT_WINDOW_SECS (default 300 s = 5 min). + - Provider registration is modular โ€” add a new channel by subclassing + NotificationProvider and registering it with Notifier.register(). + +Usage: + from notifier import notifier + await notifier.dispatch("jobs:new", title="...", message="...", fields={...}) +""" + +from __future__ import annotations + +import asyncio +import os +import time +from typing import Any + +from logger import get_logger +from providers import ( + DiscordProvider, + EmailProvider, + NotificationEvent, + NotificationProvider, + SlackProvider, +) + +log = get_logger("scheduler.notifier") + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- + +RATE_LIMIT_WINDOW_SECS = int(os.environ.get("NOTIFY_RATE_LIMIT_SECS", "300")) + + +# --------------------------------------------------------------------------- +# Registry +# --------------------------------------------------------------------------- + +_providers: list[NotificationProvider] = [] +_last_sent: dict[str, float] = {} # event_type โ†’ timestamp +_enabled_events: set[str] = set() # empty = all enabled + + +def register(provider: NotificationProvider) -> None: + """Register a notification provider.""" + if provider.validate_config(): + _providers.append(provider) + log.info("Provider registered", provider=type(provider).__name__) + + +def configure(event_types: list[str] | None = None) -> None: + """Set which event types are enabled. None = all enabled.""" + global _enabled_events + if event_types is None: + _enabled_events = set() + else: + _enabled_events = set(event_types) + + +def _is_enabled(event_type: str) -> bool: + return not _enabled_events or event_type in _enabled_events + + +def _is_rate_limited(event_type: str) -> bool: + now = time.time() + last = _last_sent.get(event_type, 0.0) + if now - last < RATE_LIMIT_WINDOW_SECS: + remaining = int(RATE_LIMIT_WINDOW_SECS - (now - last)) + log.debug("Rate-limited", event_type=event_type, remaining_secs=remaining) + return True + _last_sent[event_type] = now + return False + + +# --------------------------------------------------------------------------- +# Dispatch +# --------------------------------------------------------------------------- + +async def dispatch( + event_type: str, + title: str, + message: str, + fields: dict[str, Any] | None = None, + severity: str = "info", +) -> None: + """ + Fire a notification event to all configured providers. + + This function is intentionally fire-and-forget from the caller's + perspective โ€” exceptions inside providers are caught and logged, + never propagated. + """ + if not _is_enabled(event_type): + log.debug("Event type disabled, skipping", event_type=event_type) + return + + if _is_rate_limited(event_type): + return + + if not _providers: + log.debug("No providers configured, skipping notification", event_type=event_type) + return + + event = NotificationEvent( + event_type=event_type, + title=title, + message=message, + fields=fields, + severity=severity, + ) + + results = await asyncio.gather( + *(p.send(event) for p in _providers), + return_exceptions=True, + ) + + for provider, result in zip(_providers, results): + pname = type(provider).__name__ + if isinstance(result, Exception): + log.error("Provider send failed", provider=pname, error=str(result)) + elif not result: + log.warning("Provider returned failure", provider=pname) + else: + log.info("Notification sent", provider=pname, event_type=event_type) + + +# --------------------------------------------------------------------------- +# Sync wrapper (for APScheduler thread-pool usage) +# --------------------------------------------------------------------------- + +def dispatch_sync( + event_type: str, + title: str, + message: str, + fields: dict[str, Any] | None = None, + severity: str = "info", +) -> None: + """Synchronous wrapper for use in APScheduler thread-pool jobs.""" + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = None + + if loop and loop.is_running(): + asyncio.ensure_future(dispatch(event_type, title, message, fields, severity)) + else: + asyncio.run(dispatch(event_type, title, message, fields, severity)) + + +# --------------------------------------------------------------------------- +# Init: auto-register providers from env +# --------------------------------------------------------------------------- + +def init_from_env() -> None: + """Read env vars and register the providers that are configured.""" + slack = SlackProvider() + if slack.validate_config(): + register(slack) + + discord = DiscordProvider() + if discord.validate_config(): + register(discord) + + email = EmailProvider() + if email.validate_config(): + register(email) + + if not _providers: + log.info("No notification providers configured โ€” all notifications will be no-ops.") + else: + log.info("Notification system initialized", provider_count=len(_providers)) + + +# --------------------------------------------------------------------------- +# Singleton notifier instance +# --------------------------------------------------------------------------- + +notifier = dispatch_sync + +# Auto-init on import +init_from_env() diff --git a/scheduler/providers/__init__.py b/scheduler/providers/__init__.py new file mode 100644 index 0000000..8844547 --- /dev/null +++ b/scheduler/providers/__init__.py @@ -0,0 +1,6 @@ +from scheduler.providers.base import NotificationProvider +from scheduler.providers.slack import SlackProvider +from scheduler.providers.discord import DiscordProvider +from scheduler.providers.email import EmailProvider + +__all__ = ["NotificationProvider", "SlackProvider", "DiscordProvider", "EmailProvider"] diff --git a/scheduler/providers/base.py b/scheduler/providers/base.py new file mode 100644 index 0000000..404c83b --- /dev/null +++ b/scheduler/providers/base.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Any + + +@dataclass +class NotificationEvent: + event_type: str + title: str + message: str + fields: dict[str, Any] | None = None + severity: str = "info" + + +class NotificationProvider(ABC): + @abstractmethod + async def send(self, event: NotificationEvent) -> bool: + ... + + @abstractmethod + def validate_config(self) -> bool: + ... diff --git a/scheduler/providers/discord.py b/scheduler/providers/discord.py new file mode 100644 index 0000000..8fbbb7a --- /dev/null +++ b/scheduler/providers/discord.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import os + +import httpx + +from scheduler.providers.base import NotificationEvent, NotificationProvider + + +class DiscordProvider(NotificationProvider): + def __init__(self) -> None: + self.webhook_url = os.environ.get("DISCORD_WEBHOOK_URL", "").strip() + self._client: httpx.AsyncClient | None = None + + def validate_config(self) -> bool: + return bool(self.webhook_url) + + async def send(self, event: NotificationEvent) -> bool: + if not self.validate_config(): + return False + + color_map = {"info": 0x4F9EFF, "warning": 0xF59E0B, "error": 0xEF4444, "success": 0x22C55E} + + embed = { + "title": event.title, + "description": event.message, + "color": color_map.get(event.severity, 0x4F9EFF), + "footer": {"text": f"{event.event_type} ยท severity: {event.severity}"}, + } + + if event.fields: + embed["fields"] = [{"name": k, "value": str(v), "inline": True} for k, v in event.fields.items()] + + payload = {"embeds": [embed]} + + try: + async with httpx.AsyncClient(timeout=10) as c: + resp = await c.post(self.webhook_url, json=payload) + resp.raise_for_status() + return True + except Exception: + return False diff --git a/scheduler/providers/email.py b/scheduler/providers/email.py new file mode 100644 index 0000000..86be376 --- /dev/null +++ b/scheduler/providers/email.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import asyncio +import os +import smtplib +import ssl +from email.message import EmailMessage + +from scheduler.providers.base import NotificationEvent, NotificationProvider + + +class EmailProvider(NotificationProvider): + def __init__(self) -> None: + self.from_address = os.environ.get("GMAIL_ADDRESS", "").strip() + self.app_password = os.environ.get("GMAIL_APP_PASSWORD", "").strip() + self.to_address = os.environ.get("NOTIFICATION_EMAIL", "").strip() + self.your_name = os.environ.get("YOUR_NAME", "Arachnode").strip() + + def validate_config(self) -> bool: + return bool(self.from_address and self.app_password and self.to_address) + + async def send(self, event: NotificationEvent) -> bool: + if not self.validate_config(): + return False + + subject = f"[Arachnode] {event.title}" + + parts = [event.message] + if event.fields: + parts.append("") + parts.extend(f"{k}: {v}" for k, v in event.fields.items()) + body = "\n".join(parts) + + loop = asyncio.get_running_loop() + try: + await loop.run_in_executor(None, self._send_sync, subject, body) + return True + except Exception: + return False + + def _send_sync(self, subject: str, body: str) -> None: + msg = EmailMessage() + msg["From"] = f"{self.your_name} <{self.from_address}>" + msg["To"] = self.to_address + msg["Subject"] = subject + msg.set_content(body) + + ctx = ssl.create_default_context() + with smtplib.SMTP_SSL("smtp.gmail.com", 465, context=ctx) as smtp: + smtp.login(self.from_address, self.app_password) + smtp.send_message(msg) diff --git a/scheduler/providers/slack.py b/scheduler/providers/slack.py new file mode 100644 index 0000000..d946c24 --- /dev/null +++ b/scheduler/providers/slack.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import os + +import httpx + +from scheduler.providers.base import NotificationEvent, NotificationProvider + + +class SlackProvider(NotificationProvider): + def __init__(self) -> None: + self.webhook_url = os.environ.get("SLACK_WEBHOOK_URL", "").strip() + self._client: httpx.AsyncClient | None = None + + def validate_config(self) -> bool: + return bool(self.webhook_url) + + async def send(self, event: NotificationEvent) -> bool: + if not self.validate_config(): + return False + + color_map = {"info": "#4f9eff", "warning": "#f59e0b", "error": "#ef4444", "success": "#22c55e"} + blocks = [ + { + "type": "header", + "text": {"type": "plain_text", "text": event.title}, + }, + {"type": "divider"}, + { + "type": "section", + "text": {"type": "mrkdwn", "text": event.message}, + }, + ] + + if event.fields: + fields = [{"type": "mrkdwn", "text": f"*{k}:* {v}"} for k, v in event.fields.items()] + blocks.append({"type": "section", "fields": fields}) + + blocks.append({ + "type": "context", + "elements": [{"type": "mrkdwn", "text": f"*Severity:* {event.severity} ยท *Event:* `{event.event_type}`"}], + }) + + payload = { + "attachments": [{ + "color": color_map.get(event.severity, "#4f9eff"), + "blocks": blocks, + }] + } + + try: + async with httpx.AsyncClient(timeout=10) as c: + resp = await c.post(self.webhook_url, json=payload) + resp.raise_for_status() + return True + except Exception: + return False diff --git a/scheduler/tasks.py b/scheduler/tasks.py index 62a1244..38b14d5 100644 --- a/scheduler/tasks.py +++ b/scheduler/tasks.py @@ -24,6 +24,7 @@ import httpx from logger import get_logger +from notifier import notifier, configure as configure_notifications log = get_logger("scheduler.tasks") @@ -141,6 +142,23 @@ def run_scrape_cycle() -> None: _summary["jobs_discovered"] += delta log.info("Scrape cycle complete", jobs_before=jobs_before, jobs_after=jobs_after, delta=delta) + if delta > 0: + notifier( + "jobs:new", + "New Jobs Discovered", + f"Scrape cycle found {delta} new matching job(s).", + fields={"role": _role(), "stack": ", ".join(_stack()), "total_jobs": str(jobs_after)}, + severity="success", + ) + else: + notifier( + "cycle:complete", + "Scrape Cycle Complete", + "Scrape cycle finished โ€” no new jobs found.", + fields={"total_jobs": str(jobs_after)}, + severity="info", + ) + # --------------------------------------------------------------------------- # Task 2 โ€” Discover cycle (every DISCOVER_INTERVAL_HOURS) @@ -189,6 +207,15 @@ def run_discover_cycle() -> None: _summary["contacts_found"] += found log.info("Discover cycle complete", triggered=found) + if found > 0: + notifier( + "contacts:found", + "New Contacts Discovered", + f"Discovered {found} new contact(s) across {len(jobs)} job(s).", + fields={"companies": ", ".join(j.get("company", "?") for j in jobs[:5])}, + severity="success", + ) + # --------------------------------------------------------------------------- # Task 3 โ€” Draft cycle (every DISCOVER_INTERVAL_HOURS + 4 h offset) @@ -253,6 +280,22 @@ def run_draft_cycle() -> None: _summary["emails_drafted"] += drafted log.info("Draft cycle complete", drafted=drafted) + if drafted > 0: + notifier( + "emails:drafted", + "Cold Emails Drafted", + f"Pre-generated {drafted} cold outreach email draft(s).", + fields={"template": "cold_outreach"}, + severity="success", + ) + else: + notifier( + "cycle:complete", + "Draft Cycle Complete", + "Draft cycle finished โ€” no new emails to generate.", + severity="info", + ) + # --------------------------------------------------------------------------- From e7f298bd8aa78284f38be9c724be063e28989c5f Mon Sep 17 00:00:00 2001 From: Shikhar-404exe Date: Tue, 2 Jun 2026 22:44:04 +0530 Subject: [PATCH 2/2] fix: clean up navbar - remove duplicate elements and fix broken HTML structure - Remove duplicate btn-scrape and btn-export-csv elements - Fix misplaced nav-right div (was outside tags - Remove duplicate event listeners for scrape modal --- gateway/dashboard.html | 37 +++---------------------------------- 1 file changed, 3 insertions(+), 34 deletions(-) diff --git a/gateway/dashboard.html b/gateway/dashboard.html index 80d7311..adf9f45 100644 --- a/gateway/dashboard.html +++ b/gateway/dashboard.html @@ -1171,15 +1171,10 @@ - - - - + `;
๐Ÿ”

No jobs found. Trigger a scrape to get started.

@@ -2403,37 +2398,11 @@

Emails

}); const scrapeModal = document.getElementById('scrape-modal'); - document.getElementById('btn-scrape').addEventListener('click', () => scrapeModal.classList.add('open')); - document.getElementById('modal-close').addEventListener('click', () => scrapeModal.classList.remove('open')); - document.getElementById('modal-cancel').addEventListener('click',() => scrapeModal.classList.remove('open')); - scrapeModal.addEventListener('click', e => { if (e.target === scrapeModal) scrapeModal.classList.remove('open'); }); - // CSV Export - document.getElementById('btn-export-csv').addEventListener('click', async () => { - const btn = document.getElementById('btn-export-csv'); - btn.textContent = 'โณ Exporting...'; - btn.disabled = true; - try { - const res = await fetch('/api/jobs/export/csv'); - if (!res.ok) throw new Error('Export failed'); - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `jobs_${new Date().toISOString().slice(0,10)}.csv`; - a.click(); - URL.revokeObjectURL(url); - } catch (e) { - alert('Export failed: ' + e.message); - } finally { - btn.textContent = 'โฌ‡ Export CSV'; - btn.disabled = false; - } - }); - document.addEventListener('keydown', e => { if (e.key === 'Escape') scrapeModal.classList.remove('open'); }); document.getElementById('btn-scrape').addEventListener('click', openScrapeModal); document.getElementById('modal-close').addEventListener('click', closeScrapeModal); document.getElementById('modal-cancel').addEventListener('click', closeScrapeModal); scrapeModal.addEventListener('click', e => { if (e.target === scrapeModal) closeScrapeModal(); }); + document.addEventListener('keydown', e => { if (e.key === 'Escape') closeScrapeModal(); }); // Shortcut reference const shortcutsModal = document.getElementById('shortcuts-modal');