Skip to main content

honk_me/
message.rs

1use std::collections::BTreeMap;
2use std::fmt;
3use std::str::FromStr;
4use std::time::SystemTime;
5
6use serde::{Deserialize, Serialize};
7
8macro_rules! wire_enum {
9    ($(#[$meta:meta])* $name:ident { $($(#[$vmeta:meta])* $variant:ident = $wire:literal),+ $(,)? }) => {
10        $(#[$meta])*
11        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
12        #[serde(rename_all = "lowercase")]
13        pub enum $name {
14            $($(#[$vmeta])* $variant),+
15        }
16
17        impl $name {
18            /// Every value, in the API's order.
19            pub const ALL: &'static [$name] = &[$($name::$variant),+];
20
21            /// The value as sent on the wire.
22            pub fn as_str(self) -> &'static str {
23                match self {
24                    $($name::$variant => $wire),+
25                }
26            }
27        }
28
29        impl fmt::Display for $name {
30            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31                f.write_str(self.as_str())
32            }
33        }
34    };
35}
36
37wire_enum! {
38    /// Severity, lowest to highest. `Error` and `Critical` raise the effective priority to at
39    /// least `High`. Every severity has a horn name on the Honk scale: [`Severity::LIGHT`],
40    /// [`Severity::BEEP`], [`Severity::LOUD`], [`Severity::LONG`], [`Severity::BLAST`].
41    ///
42    /// ```
43    /// use honk_me::Severity;
44    /// assert_eq!(Severity::LOUD, Severity::Warning);
45    /// assert_eq!("Loud".parse::<Severity>().unwrap(), Severity::Warning);
46    /// assert_eq!(Severity::BLAST.to_string(), "critical"); // always sent canonical
47    /// ```
48    Severity {
49        /// `info` (light honk). The default.
50        Info = "info",
51        /// `success` (beep-beep).
52        Success = "success",
53        /// `warning` (loud honk).
54        Warning = "warning",
55        /// `error` (long honk). Pushes at least as `high` priority.
56        Error = "error",
57        /// `critical` (blast). Pushes at least as `high` priority.
58        Critical = "critical",
59    }
60}
61
62impl Severity {
63    /// Light honk: an alias of [`Severity::Info`].
64    pub const LIGHT: Severity = Severity::Info;
65    /// Beep-beep: an alias of [`Severity::Success`].
66    pub const BEEP: Severity = Severity::Success;
67    /// Loud honk: an alias of [`Severity::Warning`].
68    pub const LOUD: Severity = Severity::Warning;
69    /// Long honk: an alias of [`Severity::Error`].
70    pub const LONG: Severity = Severity::Error;
71    /// Blast: an alias of [`Severity::Critical`].
72    pub const BLAST: Severity = Severity::Critical;
73
74    /// The horn name: `light`, `beep`, `loud`, `long` or `blast`.
75    pub fn horn(self) -> &'static str {
76        match self {
77            Severity::Info => "light",
78            Severity::Success => "beep",
79            Severity::Warning => "loud",
80            Severity::Error => "long",
81            Severity::Critical => "blast",
82        }
83    }
84
85    /// Parses a canonical value or horn name, case-insensitively (`"LOUD"` and `"warning"` are
86    /// [`Severity::Warning`]).
87    pub fn parse(value: &str) -> Option<Severity> {
88        let v = value.trim().to_ascii_lowercase();
89        Severity::ALL
90            .iter()
91            .copied()
92            .find(|s| s.as_str() == v || s.horn() == v)
93    }
94}
95
96/// The error of [`Severity::from_str`] and the other enums' `from_str`: names the valid values.
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct ParseEnumError(pub(crate) String);
99
100impl fmt::Display for ParseEnumError {
101    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102        f.write_str(&self.0)
103    }
104}
105
106impl std::error::Error for ParseEnumError {}
107
108impl FromStr for Severity {
109    type Err = ParseEnumError;
110
111    fn from_str(s: &str) -> Result<Self, Self::Err> {
112        Severity::parse(s).ok_or_else(|| {
113            ParseEnumError(format!(
114                "unknown severity {s:?}: use light (info), beep (success), loud (warning), long (error) or blast (critical)"
115            ))
116        })
117    }
118}
119
120wire_enum! {
121    /// The priority declared by the source. `Urgent` needs an ingestion key with
122    /// `allow_urgent` (otherwise `403 priority_not_allowed`).
123    Priority {
124        /// `low`.
125        Low = "low",
126        /// `normal`. The default.
127        Normal = "normal",
128        /// `high`.
129        High = "high",
130        /// `urgent`: may break through Focus on the phone.
131        Urgent = "urgent",
132    }
133}
134
135wire_enum! {
136    /// A problem opens an incident for its group, a recovery closes it. Both need a group key.
137    EventType {
138        /// `event`. The default.
139        Event = "event",
140        /// `problem`: opens or continues the group's incident.
141        Problem = "problem",
142        /// `recovery`: closes the group's open incident.
143        Recovery = "recovery",
144    }
145}
146
147wire_enum! {
148    /// The category (taxonomy v1).
149    Category {
150        /// `infrastructure`.
151        Infrastructure = "infrastructure",
152        /// `security`.
153        Security = "security",
154        /// `backups`.
155        Backups = "backups",
156        /// `deployments`.
157        Deployments = "deployments",
158        /// `payments`.
159        Payments = "payments",
160        /// `customers`.
161        Customers = "customers",
162        /// `sales`.
163        Sales = "sales",
164        /// `automation`.
165        Automation = "automation",
166        /// `personal`.
167        Personal = "personal",
168        /// `other`.
169        Other = "other",
170    }
171}
172
173macro_rules! from_str_enum {
174    ($name:ident, $what:literal) => {
175        impl FromStr for $name {
176            type Err = ParseEnumError;
177
178            fn from_str(s: &str) -> Result<Self, Self::Err> {
179                let v = s.trim().to_ascii_lowercase();
180                $name::ALL
181                    .iter()
182                    .copied()
183                    .find(|x| x.as_str() == v)
184                    .ok_or_else(|| {
185                        let names: Vec<&str> = $name::ALL.iter().map(|x| x.as_str()).collect();
186                        ParseEnumError(format!(
187                            "unknown {} {s:?}: use one of {}",
188                            $what,
189                            names.join(", ")
190                        ))
191                    })
192            }
193        }
194    };
195}
196
197from_str_enum!(Priority, "priority");
198from_str_enum!(EventType, "event type");
199from_str_enum!(Category, "category");
200
201/// A metadata value: a string, a number or a boolean.
202#[derive(Debug, Clone, PartialEq, Serialize)]
203#[serde(untagged)]
204pub enum MetadataValue {
205    /// A string (at most 512 characters).
206    String(String),
207    /// An integer.
208    Integer(i64),
209    /// A finite number.
210    Float(f64),
211    /// A boolean.
212    Bool(bool),
213}
214
215macro_rules! metadata_from {
216    ($($t:ty => $variant:ident $(as $cast:ty)?),+ $(,)?) => {
217        $(impl From<$t> for MetadataValue {
218            fn from(v: $t) -> Self {
219                MetadataValue::$variant(v $(as $cast)?)
220            }
221        })+
222    };
223}
224
225metadata_from! {
226    i64 => Integer, i32 => Integer as i64, i16 => Integer as i64, i8 => Integer as i64,
227    u32 => Integer as i64, u16 => Integer as i64, u8 => Integer as i64,
228    f64 => Float, f32 => Float as f64, bool => Bool, String => String,
229}
230
231impl From<&str> for MetadataValue {
232    fn from(v: &str) -> Self {
233        MetadataValue::String(v.to_owned())
234    }
235}
236
237impl From<&String> for MetadataValue {
238    fn from(v: &String) -> Self {
239        MetadataValue::String(v.clone())
240    }
241}
242
243/// A button on a message. Honk never opens the URL; the phone does, when you tap the button.
244///
245/// ```
246/// use honk_me::{Action, Message};
247///
248/// let msg = Message::new("Emily Carter asked for a quote")
249///     .action("Reply", "mailto:emily@example.com?subject=Your%20quote")
250///     .action("Call", "tel:+15550134");
251/// assert_eq!(msg.actions[1], Action::new("Call", "tel:+15550134"));
252/// ```
253#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
254#[non_exhaustive]
255pub struct Action {
256    /// 1–40 characters, one line, shown as sent.
257    pub title: String,
258    /// `https://` (no credentials), `mailto:`, `tel:` or `sms:`, at most 2048 bytes.
259    pub url: String,
260}
261
262impl Action {
263    /// A button with this title and URL.
264    pub fn new(title: impl Into<String>, url: impl Into<String>) -> Self {
265        Action {
266            title: title.into(),
267            url: url.into(),
268        }
269    }
270}
271
272/// One event for `POST /v1/messages`. Only the message text is required; unset fields are
273/// omitted so the server defaults apply (severity info, priority normal, source `api`,
274/// environment `default`, channel `general`, event type event, TTL 3600 s). Empty strings
275/// count as unset.
276///
277/// ```
278/// use honk_me::{Category, Message, Priority, Severity};
279///
280/// let msg = Message::new("db-1 /var is at 91%")
281///     .title("Disk 91% full")
282///     .severity(Severity::LOUD)
283///     .priority(Priority::High)
284///     .category(Category::Infrastructure)
285///     .group_key("disk/db-1/var")
286///     .metadata("host", "db-1")
287///     .metadata("used_percent", 91);
288/// assert_eq!(msg.severity, Some(Severity::Warning));
289/// ```
290#[derive(Debug, Clone, PartialEq, Default)]
291#[non_exhaustive]
292pub struct Message {
293    /// The text: plain, 1–8192 bytes of UTF-8. Line breaks and tabs are allowed.
294    pub message: String,
295    /// One line, at most 160 characters. Defaults to the first line of the message.
296    pub title: Option<String>,
297    /// Default info (light).
298    pub severity: Option<Severity>,
299    /// Default normal.
300    pub priority: Option<Priority>,
301    /// The category.
302    pub category: Option<Category>,
303    /// At most 64 characters. Defaults to the client's default, then `api`.
304    pub source: Option<String>,
305    /// At most 32 characters. Defaults to the client's default, then `default`.
306    pub environment: Option<String>,
307    /// At most 64 characters. Defaults to the client's default, then `general`.
308    pub channel: Option<String>,
309    /// At most 128 characters. Messages with the same key (per environment, source and
310    /// channel) form one group: the first one pushes, repeats update it calmly. Use one key per
311    /// customer request (`requests/<id>`), a shared key only for repeats of the same problem.
312    pub group_key: Option<String>,
313    /// Default event.
314    pub event_type: Option<EventType>,
315    /// When it happened at the source (informational).
316    pub occurred_at: Option<SystemTime>,
317    /// An https link shown as "Open link" (no credentials, at most 2048 bytes).
318    pub url: Option<String>,
319    /// An https image the server fetches after ingestion (no credentials or fragment, at most
320    /// 2048 bytes).
321    pub image_url: Option<String>,
322    /// Up to 3 buttons, in display order (the first is the primary). Empty: none.
323    pub actions: Vec<Action>,
324    /// At most 16 keys matching `[A-Za-z0-9_.-]{1,64}`.
325    pub metadata: BTreeMap<String, MetadataValue>,
326    /// The push lifetime, 60–86400 seconds. Default 3600.
327    pub ttl_seconds: Option<u32>,
328    /// A monotonic counter per source stream (0 … 2^53-1), so a delayed recovery can never
329    /// close a newer problem. Needs a group key.
330    pub source_sequence: Option<u64>,
331}
332
333impl Message {
334    /// A message with this text and nothing else set.
335    pub fn new(message: impl Into<String>) -> Self {
336        Message {
337            message: message.into(),
338            ..Message::default()
339        }
340    }
341
342    /// Sets the title.
343    pub fn title(mut self, title: impl Into<String>) -> Self {
344        self.title = Some(title.into());
345        self
346    }
347
348    /// Sets the severity (a horn alias like [`Severity::LOUD`] or a canonical value).
349    pub fn severity(mut self, severity: Severity) -> Self {
350        self.severity = Some(severity);
351        self
352    }
353
354    /// Sets the priority.
355    pub fn priority(mut self, priority: Priority) -> Self {
356        self.priority = Some(priority);
357        self
358    }
359
360    /// Sets the category.
361    pub fn category(mut self, category: Category) -> Self {
362        self.category = Some(category);
363        self
364    }
365
366    /// Sets the source.
367    pub fn source(mut self, source: impl Into<String>) -> Self {
368        self.source = Some(source.into());
369        self
370    }
371
372    /// Sets the environment.
373    pub fn environment(mut self, environment: impl Into<String>) -> Self {
374        self.environment = Some(environment.into());
375        self
376    }
377
378    /// Sets the channel.
379    pub fn channel(mut self, channel: impl Into<String>) -> Self {
380        self.channel = Some(channel.into());
381        self
382    }
383
384    /// Sets the group key, e.g. `requests/4812`.
385    pub fn group_key(mut self, group_key: impl Into<String>) -> Self {
386        self.group_key = Some(group_key.into());
387        self
388    }
389
390    /// Sets the event type.
391    pub fn event_type(mut self, event_type: EventType) -> Self {
392        self.event_type = Some(event_type);
393        self
394    }
395
396    /// Sets when the event happened at the source.
397    pub fn occurred_at(mut self, occurred_at: SystemTime) -> Self {
398        self.occurred_at = Some(occurred_at);
399        self
400    }
401
402    /// Sets the https link shown as "Open link".
403    pub fn url(mut self, url: impl Into<String>) -> Self {
404        self.url = Some(url.into());
405        self
406    }
407
408    /// Sets an https image the server fetches after ingestion.
409    pub fn image_url(mut self, image_url: impl Into<String>) -> Self {
410        self.image_url = Some(image_url.into());
411        self
412    }
413
414    /// Adds a button (at most 3, in display order), e.g. `.action("Call", "tel:+15550134")`.
415    pub fn action(mut self, title: impl Into<String>, url: impl Into<String>) -> Self {
416        self.actions.push(Action::new(title, url));
417        self
418    }
419
420    /// Adds one metadata entry (a string, number or boolean).
421    pub fn metadata(mut self, key: impl Into<String>, value: impl Into<MetadataValue>) -> Self {
422        self.metadata.insert(key.into(), value.into());
423        self
424    }
425
426    /// Sets the push lifetime in seconds (60–86400).
427    pub fn ttl_seconds(mut self, ttl_seconds: u32) -> Self {
428        self.ttl_seconds = Some(ttl_seconds);
429        self
430    }
431
432    /// Sets the monotonic source sequence (needs a group key).
433    pub fn source_sequence(mut self, source_sequence: u64) -> Self {
434        self.source_sequence = Some(source_sequence);
435        self
436    }
437}
438
439impl From<&str> for Message {
440    fn from(message: &str) -> Self {
441        Message::new(message)
442    }
443}
444
445impl From<String> for Message {
446    fn from(message: String) -> Self {
447        Message::new(message)
448    }
449}
450
451/// Values applied to every message that leaves these fields unset.
452#[derive(Debug, Clone, PartialEq, Eq, Default)]
453#[non_exhaustive]
454pub struct Defaults {
455    /// The default source.
456    pub source: Option<String>,
457    /// The default environment.
458    pub environment: Option<String>,
459    /// The default channel.
460    pub channel: Option<String>,
461}
462
463impl Defaults {
464    /// No defaults.
465    pub fn new() -> Self {
466        Defaults::default()
467    }
468
469    /// Sets the default source.
470    pub fn source(mut self, source: impl Into<String>) -> Self {
471        self.source = Some(source.into());
472        self
473    }
474
475    /// Sets the default environment.
476    pub fn environment(mut self, environment: impl Into<String>) -> Self {
477        self.environment = Some(environment.into());
478        self
479    }
480
481    /// Sets the default channel.
482    pub fn channel(mut self, channel: impl Into<String>) -> Self {
483        self.channel = Some(channel.into());
484        self
485    }
486}
487
488/// The `202` answer: the message is durably stored. It does not mean pushed, delivered or read.
489#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
490#[non_exhaustive]
491pub struct Accepted {
492    /// The message id (`msg_…`); the original id when `duplicate` is true.
493    pub id: String,
494    /// True when this idempotency key was already accepted with the same payload in the last
495    /// 24 hours (nothing new was stored).
496    #[serde(default)]
497    pub duplicate: bool,
498    /// When the server accepted it (the first time, for a duplicate), as RFC 3339.
499    #[serde(default)]
500    pub received_at: String,
501}
502
503impl Accepted {
504    /// [`received_at`](Accepted::received_at) as a [`SystemTime`], when it parses.
505    pub fn received_at_time(&self) -> Option<SystemTime> {
506        crate::time::parse_rfc3339(&self.received_at)
507    }
508}