from typing import Annotated, Literal from pydantic import BaseModel, Field, field_validator, model_validator _DESTINATION_NAME_CHARS = set("abcdefghijklmnopqrstuvwxyz0123456789._-") SUPPORTED_AUTO_EVENT_TYPES = {"approval_required", "error", "turn_complete"} class SlackDestinationConfig(BaseModel): provider: Literal["slack"] = "slack" token: str channel: str allow_agent_tool: bool = False allow_auto_events: bool = False username: str | None = None icon_emoji: str | None = None @field_validator("token", "channel") @classmethod def _require_non_empty(cls, value: str) -> str: value = value.strip() if not value: raise ValueError("must not be empty") return value DestinationConfig = Annotated[SlackDestinationConfig, Field(discriminator="provider")] class MessagingConfig(BaseModel): enabled: bool = False auto_event_types: list[str] = Field( default_factory=lambda: ["approval_required", "error", "turn_complete"] ) destinations: dict[str, DestinationConfig] = Field(default_factory=dict) @field_validator("destinations") @classmethod def _validate_destination_names( cls, destinations: dict[str, DestinationConfig] ) -> dict[str, DestinationConfig]: for name in destinations: if not name or any(char not in _DESTINATION_NAME_CHARS for char in name): raise ValueError( "destination names must use lowercase letters, digits, '.', '_' or '-'" ) return destinations @field_validator("auto_event_types") @classmethod def _validate_auto_event_types(cls, event_types: list[str]) -> list[str]: if not event_types: return [] normalized: list[str] = [] seen: set[str] = set() for event_type in event_types: if event_type not in SUPPORTED_AUTO_EVENT_TYPES: raise ValueError(f"unsupported auto event type '{event_type}'") if event_type not in seen: normalized.append(event_type) seen.add(event_type) return normalized @model_validator(mode="after") def _require_destinations_when_enabled(self) -> "MessagingConfig": if self.enabled and not self.destinations: raise ValueError("messaging.enabled requires at least one destination") return self def get_destination(self, name: str) -> DestinationConfig | None: return self.destinations.get(name) def can_agent_tool_send(self, name: str) -> bool: destination = self.get_destination(name) return bool(destination and destination.allow_agent_tool) def can_auto_send(self, name: str) -> bool: destination = self.get_destination(name) return bool(destination and destination.allow_auto_events) def default_auto_destinations(self) -> list[str]: if not self.enabled: return [] return [name for name in self.destinations if self.can_auto_send(name)] class NotificationRequest(BaseModel): destination: str title: str | None = None message: str severity: Literal["info", "success", "warning", "error"] = "info" metadata: dict[str, str] = Field(default_factory=dict) event_type: str | None = None @field_validator("destination", "message") @classmethod def _require_text(cls, value: str) -> str: value = value.strip() if not value: raise ValueError("must not be empty") return value @field_validator("title") @classmethod def _normalize_title(cls, value: str | None) -> str | None: if value is None: return None value = value.strip() return value or None class NotificationResult(BaseModel): destination: str ok: bool provider: str error: str | None = None external_id: str | None = None