forked from Rockachopa/Timmy-time-dashboard
refactor: Phase 2b — consolidate 28 modules into 14 packages
Complete the module consolidation planned in REFACTORING_PLAN.md: Modules merged: - work_orders/ + task_queue/ → swarm/ (subpackages) - self_modify/ + self_tdd/ + upgrades/ → self_coding/ (subpackages) - tools/ → creative/tools/ - chat_bridge/ + telegram_bot/ + shortcuts/ + voice/ → integrations/ (new) - ws_manager/ + notifications/ + events/ + router/ → infrastructure/ (new) - agents/ + agent_core/ + memory/ → timmy/ (subpackages) Updated across codebase: - 66 source files: import statements rewritten - 13 test files: import + patch() target strings rewritten - pyproject.toml: wheel includes (28→14), entry points updated - CLAUDE.md: singleton paths, module map, entry points table - AGENTS.md: file convention updates - REFACTORING_PLAN.md: execution status, success metrics Extras: - Module-level CLAUDE.md added to 6 key packages (Phase 6.2) - Zero test regressions: 1462 tests passing https://claude.ai/code/session_01JNjWfHqusjT3aiN4vvYgUk
This commit is contained in:
152
src/infrastructure/notifications/push.py
Normal file
152
src/infrastructure/notifications/push.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""Push notification system for swarm events.
|
||||
|
||||
Collects notifications from swarm events (task completed, agent joined,
|
||||
auction won, etc.) and makes them available to the dashboard via polling
|
||||
or WebSocket. On macOS, can optionally trigger native notifications
|
||||
via osascript.
|
||||
|
||||
No cloud push services — everything stays local.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
import platform
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Notification:
|
||||
id: int
|
||||
title: str
|
||||
message: str
|
||||
category: str # swarm | task | agent | system | payment
|
||||
timestamp: str = field(
|
||||
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
||||
)
|
||||
read: bool = False
|
||||
|
||||
|
||||
class PushNotifier:
|
||||
"""Local push notification manager."""
|
||||
|
||||
def __init__(self, max_history: int = 200, native_enabled: bool = True) -> None:
|
||||
self._notifications: deque[Notification] = deque(maxlen=max_history)
|
||||
self._counter = 0
|
||||
self._native_enabled = native_enabled and platform.system() == "Darwin"
|
||||
self._listeners: list = []
|
||||
|
||||
def notify(
|
||||
self,
|
||||
title: str,
|
||||
message: str,
|
||||
category: str = "system",
|
||||
native: bool = False,
|
||||
) -> Notification:
|
||||
"""Create and store a notification."""
|
||||
self._counter += 1
|
||||
notif = Notification(
|
||||
id=self._counter,
|
||||
title=title,
|
||||
message=message,
|
||||
category=category,
|
||||
)
|
||||
self._notifications.appendleft(notif)
|
||||
logger.info("Notification [%s]: %s — %s", category, title, message[:60])
|
||||
|
||||
# Trigger native macOS notification if requested
|
||||
if native and self._native_enabled:
|
||||
self._native_notify(title, message)
|
||||
|
||||
# Notify listeners (for WebSocket push)
|
||||
for listener in self._listeners:
|
||||
try:
|
||||
listener(notif)
|
||||
except Exception as exc:
|
||||
logger.error("Notification listener error: %s", exc)
|
||||
|
||||
return notif
|
||||
|
||||
def _native_notify(self, title: str, message: str) -> None:
|
||||
"""Send a native macOS notification via osascript."""
|
||||
try:
|
||||
script = (
|
||||
f'display notification "{message}" '
|
||||
f'with title "Timmy Time" subtitle "{title}"'
|
||||
)
|
||||
subprocess.Popen(
|
||||
["osascript", "-e", script],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Native notification failed: %s", exc)
|
||||
|
||||
def recent(self, limit: int = 20, category: Optional[str] = None) -> list[Notification]:
|
||||
"""Get recent notifications, optionally filtered by category."""
|
||||
notifs = list(self._notifications)
|
||||
if category:
|
||||
notifs = [n for n in notifs if n.category == category]
|
||||
return notifs[:limit]
|
||||
|
||||
def unread_count(self) -> int:
|
||||
return sum(1 for n in self._notifications if not n.read)
|
||||
|
||||
def mark_read(self, notification_id: int) -> bool:
|
||||
for n in self._notifications:
|
||||
if n.id == notification_id:
|
||||
n.read = True
|
||||
return True
|
||||
return False
|
||||
|
||||
def mark_all_read(self) -> int:
|
||||
count = 0
|
||||
for n in self._notifications:
|
||||
if not n.read:
|
||||
n.read = True
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def clear(self) -> None:
|
||||
self._notifications.clear()
|
||||
|
||||
def add_listener(self, callback) -> None:
|
||||
"""Register a callback for real-time notification delivery."""
|
||||
self._listeners.append(callback)
|
||||
|
||||
|
||||
# Module-level singleton
|
||||
notifier = PushNotifier()
|
||||
|
||||
|
||||
async def notify_briefing_ready(briefing) -> None:
|
||||
"""Notify the owner that a new morning briefing is ready.
|
||||
|
||||
Only triggers a native macOS popup when there are pending approval items.
|
||||
Briefings with 0 approvals are still logged but don't interrupt the user
|
||||
with a notification that leads to an empty-looking page.
|
||||
|
||||
Args:
|
||||
briefing: A timmy.briefing.Briefing instance.
|
||||
"""
|
||||
n_approvals = len(briefing.approval_items) if briefing.approval_items else 0
|
||||
|
||||
if n_approvals == 0:
|
||||
logger.info("Briefing ready but no pending approvals — skipping native notification")
|
||||
return
|
||||
|
||||
message = (
|
||||
f"Your morning briefing is ready. "
|
||||
f"{n_approvals} item(s) await your approval."
|
||||
)
|
||||
notifier.notify(
|
||||
title="Morning Briefing Ready",
|
||||
message=message,
|
||||
category="briefing",
|
||||
native=True,
|
||||
)
|
||||
logger.info("Briefing push notification dispatched (%d approval(s))", n_approvals)
|
||||
Reference in New Issue
Block a user