1616log = logging .getLogger ("clayde.webhook.notify" )
1717
1818
19+ # ntfy header values are sent through httpx, which encodes headers as
20+ # latin-1. Anything outside that range raises UnicodeEncodeError before
21+ # the request goes out, so the user never sees the notification. We
22+ # normalise common typographic Unicode to ASCII and replace anything
23+ # left over with '?'.
24+ _UNICODE_TO_ASCII = str .maketrans ({
25+ "—" : "-" , # em dash
26+ "–" : "-" , # en dash
27+ "−" : "-" , # minus sign
28+ "‘" : "'" , # left single quote
29+ "’" : "'" , # right single quote / apostrophe
30+ "“" : '"' , # left double quote
31+ "”" : '"' , # right double quote
32+ "…" : "..." , # ellipsis
33+ " " : " " , # non-breaking space
34+ })
35+
36+
37+ def _to_ascii (text : str ) -> str :
38+ """Coerce arbitrary text to safe ASCII for use in HTTP headers."""
39+ return text .translate (_UNICODE_TO_ASCII ).encode ("ascii" , "replace" ).decode ("ascii" )
40+
41+
1942class NotificationPayload (BaseModel ):
2043 """Outcome of a Pebble run, as emitted by Claude in the JSON tail.
2144
2245 Title is clamped to 40 chars and body to 300 chars at construction
2346 time so accidental over-long values never propagate to ntfy headers.
47+ Title is additionally coerced to ASCII because it travels as an HTTP
48+ header and httpx rejects non-latin-1 header values.
2449 """
2550
2651 title : str
@@ -30,7 +55,9 @@ class NotificationPayload(BaseModel):
3055 @field_validator ("title" , mode = "before" )
3156 @classmethod
3257 def _clamp_title (cls , v ):
33- return v [:40 ] if isinstance (v , str ) else v
58+ if not isinstance (v , str ):
59+ return v
60+ return _to_ascii (v )[:40 ]
3461
3562 @field_validator ("body" , mode = "before" )
3663 @classmethod
0 commit comments