File size: 3,942 Bytes
6155b26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
754345f
6155b26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
754345f
6155b26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
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