Skip to main content

nula_core/nips/
nip88.rs

1//! [NIP-88] Polls.
2//!
3//! Two regular kinds:
4//!
5//! - `kind: 1068` — poll author event. `.content` carries the poll
6//!   label; `option`, `relay`, `polltype`, and `endsAt` tags carry
7//!   structured metadata.
8//! - `kind: 1018` — poll response. References the poll via `e` and
9//!   carries one or more `response` tags pointing at option ids.
10//!
11//! [NIP-88]: https://github.com/nostr-protocol/nips/blob/master/88.md
12
13use thiserror::Error;
14
15use crate::event::{
16    Alphabet, Event, EventBuilder, EventId, EventIdError, Kind, SingleLetterTag, Tag, TagKind,
17};
18use crate::types::{RelayUrl, RelayUrlError, Timestamp, TimestampError};
19
20/// `kind: 1068` — poll event.
21pub const KIND_POLL: Kind = Kind::POLL;
22
23/// `kind: 1018` — poll response event.
24pub const KIND_POLL_RESPONSE: Kind = Kind::POLL_RESPONSE;
25
26const OPTION_TAG: &str = "option";
27const RELAY_TAG: &str = "relay";
28const POLLTYPE_TAG: &str = "polltype";
29const ENDS_AT_TAG: &str = "endsAt";
30const RESPONSE_TAG: &str = "response";
31
32/// Poll behaviour: spec defines `singlechoice` (default) and
33/// `multiplechoice`; unknown values pass through as `Custom`.
34#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
35pub enum PollType {
36    /// `singlechoice` — only the first response counts.
37    #[default]
38    SingleChoice,
39    /// `multiplechoice` — first response per option id counts.
40    MultipleChoice,
41    /// Forward-compatible passthrough for unknown tokens.
42    Custom(String),
43}
44
45impl PollType {
46    /// Wire token.
47    #[must_use]
48    #[expect(
49        clippy::missing_const_for_fn,
50        reason = "`Self::Custom` borrows from a heap `String`"
51    )]
52    pub fn as_str(&self) -> &str {
53        match self {
54            Self::SingleChoice => "singlechoice",
55            Self::MultipleChoice => "multiplechoice",
56            Self::Custom(s) => s.as_str(),
57        }
58    }
59
60    /// Parse a wire token. Always succeeds; missing tags MUST be
61    /// treated as [`Self::SingleChoice`] per spec.
62    #[must_use]
63    pub fn parse(token: &str) -> Self {
64        match token {
65            "singlechoice" => Self::SingleChoice,
66            "multiplechoice" => Self::MultipleChoice,
67            _ => Self::Custom(token.to_owned()),
68        }
69    }
70}
71
72/// A single `option` row.
73#[derive(Debug, Clone, PartialEq, Eq, Hash)]
74pub struct PollOption {
75    /// Alphanumeric option identifier (referenced by responses).
76    pub id: String,
77    /// Display label.
78    pub label: String,
79}
80
81/// Typed bundle for a `kind: 1068` poll event.
82#[derive(Debug, Clone, PartialEq, Eq, Default)]
83pub struct Poll {
84    /// Free-form poll label (mirrors `.content`).
85    pub label: String,
86    /// `option` rows in display order.
87    pub options: Vec<PollOption>,
88    /// Recommended response relays.
89    pub relays: Vec<RelayUrl>,
90    /// Optional poll type.
91    pub poll_type: Option<PollType>,
92    /// Optional `endsAt` Unix timestamp.
93    pub ends_at: Option<Timestamp>,
94    /// Forward-compatible passthrough for unknown tags.
95    pub extra_tags: Vec<Tag>,
96}
97
98/// Typed bundle for a `kind: 1018` poll-response event.
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct PollResponse {
101    /// Target poll event id.
102    pub poll_id: EventId,
103    /// Response option ids (one per `response` tag).
104    pub response_ids: Vec<String>,
105    /// Forward-compatible passthrough for unknown tags.
106    pub extra_tags: Vec<Tag>,
107}
108
109/// Errors raised while parsing NIP-88 events.
110#[derive(Debug, Error)]
111#[non_exhaustive]
112pub enum PollError {
113    /// Event kind is not `1068` / `1018`.
114    #[error("unexpected kind for NIP-88 event: {}", .0.as_u16())]
115    WrongKind(Kind),
116    /// `option` tag is missing the id or label column.
117    #[error("`option` tag missing id or label")]
118    MalformedOption,
119    /// `e` tag missing on a poll response.
120    #[error("poll response missing `e` reference to poll event id")]
121    MissingPollReference,
122    /// `response` tag missing the option-id column.
123    #[error("`response` tag missing option id")]
124    MalformedResponse,
125    /// Wrapped relay-URL parser error.
126    #[error(transparent)]
127    InvalidRelayUrl(#[from] RelayUrlError),
128    /// Wrapped event-id parser error.
129    #[error(transparent)]
130    InvalidEventId(#[from] EventIdError),
131    /// Wrapped timestamp parser error.
132    #[error(transparent)]
133    InvalidTimestamp(#[from] TimestampError),
134}
135
136impl Poll {
137    /// Construct a poll with the label seeded.
138    #[must_use]
139    pub fn new(label: impl Into<String>, options: Vec<PollOption>) -> Self {
140        Self {
141            label: label.into(),
142            options,
143            ..Self::default()
144        }
145    }
146
147    /// Parse a `kind: 1068` poll event.
148    ///
149    /// # Errors
150    ///
151    /// See [`PollError`] for the failure modes.
152    pub fn from_event(event: &Event) -> Result<Self, PollError> {
153        if event.kind != KIND_POLL {
154            return Err(PollError::WrongKind(event.kind));
155        }
156        let mut out = Self::new(event.content.clone(), Vec::new());
157        for tag in &event.tags {
158            absorb_poll_tag(tag, &mut out)?;
159        }
160        Ok(out)
161    }
162
163    /// Effective poll type (defaults to [`PollType::SingleChoice`]
164    /// when unset per spec).
165    #[must_use]
166    pub fn effective_type(&self) -> PollType {
167        self.poll_type.clone().unwrap_or_default()
168    }
169}
170
171fn absorb_poll_tag(tag: &Tag, out: &mut Poll) -> Result<(), PollError> {
172    match tag.name() {
173        OPTION_TAG => {
174            let id = tag.get(1).ok_or(PollError::MalformedOption)?.to_owned();
175            let label = tag.get(2).ok_or(PollError::MalformedOption)?.to_owned();
176            out.options.push(PollOption { id, label });
177        }
178        RELAY_TAG => {
179            if let Some(raw) = tag.get(1) {
180                out.relays.push(RelayUrl::parse(raw)?);
181            }
182        }
183        POLLTYPE_TAG => {
184            out.poll_type = tag.get(1).map(PollType::parse);
185        }
186        ENDS_AT_TAG => {
187            if let Some(raw) = tag.get(1) {
188                out.ends_at = Some(raw.parse::<Timestamp>()?);
189            }
190        }
191        _ => out.extra_tags.push(tag.clone()),
192    }
193    Ok(())
194}
195
196impl PollResponse {
197    /// Construct a single-choice response.
198    #[must_use]
199    pub fn single(poll_id: EventId, option_id: impl Into<String>) -> Self {
200        Self {
201            poll_id,
202            response_ids: vec![option_id.into()],
203            extra_tags: Vec::new(),
204        }
205    }
206
207    /// Parse a `kind: 1018` poll-response event.
208    ///
209    /// # Errors
210    ///
211    /// See [`PollError`] for the failure modes.
212    pub fn from_event(event: &Event) -> Result<Self, PollError> {
213        if event.kind != KIND_POLL_RESPONSE {
214            return Err(PollError::WrongKind(event.kind));
215        }
216        let mut poll_id: Option<EventId> = None;
217        let mut response_ids: Vec<String> = Vec::new();
218        let mut extra_tags: Vec<Tag> = Vec::new();
219        for tag in &event.tags {
220            match tag.kind() {
221                TagKind::SingleLetter(s)
222                    if !s.uppercase && s.character == Alphabet::E && poll_id.is_none() =>
223                {
224                    let raw = tag.get(1).ok_or(PollError::MissingPollReference)?;
225                    poll_id = Some(EventId::parse(raw)?);
226                }
227                _ if tag.name() == RESPONSE_TAG => {
228                    let raw = tag.get(1).ok_or(PollError::MalformedResponse)?;
229                    response_ids.push(raw.to_owned());
230                }
231                _ => extra_tags.push(tag.clone()),
232            }
233        }
234        Ok(Self {
235            poll_id: poll_id.ok_or(PollError::MissingPollReference)?,
236            response_ids,
237            extra_tags,
238        })
239    }
240}
241
242impl EventBuilder {
243    /// Author a NIP-88 `kind: 1068` poll event.
244    #[must_use]
245    pub fn poll(poll: &Poll) -> Self {
246        let mut builder = Self::new(KIND_POLL, poll.label.clone());
247        for option in &poll.options {
248            builder = builder.tag(Tag::with(
249                &TagKind::from_wire(OPTION_TAG),
250                [option.id.clone(), option.label.clone()],
251            ));
252        }
253        for relay in &poll.relays {
254            builder = builder.tag(Tag::with(
255                &TagKind::from_wire(RELAY_TAG),
256                [relay.as_str().to_owned()],
257            ));
258        }
259        if let Some(pt) = &poll.poll_type {
260            builder = builder.tag(Tag::with(
261                &TagKind::from_wire(POLLTYPE_TAG),
262                [pt.as_str().to_owned()],
263            ));
264        }
265        if let Some(ts) = poll.ends_at {
266            builder = builder.tag(Tag::with(
267                &TagKind::from_wire(ENDS_AT_TAG),
268                [ts.as_secs().to_string()],
269            ));
270        }
271        for tag in &poll.extra_tags {
272            builder = builder.tag(tag.clone());
273        }
274        builder
275    }
276
277    /// Author a NIP-88 `kind: 1018` poll-response event.
278    #[must_use]
279    pub fn poll_response(response: &PollResponse) -> Self {
280        let head_e = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E));
281        let mut builder = Self::new(KIND_POLL_RESPONSE, "");
282        builder = builder.tag(Tag::with(&head_e, [response.poll_id.to_hex()]));
283        for option_id in &response.response_ids {
284            builder = builder.tag(Tag::with(
285                &TagKind::from_wire(RESPONSE_TAG),
286                [option_id.clone()],
287            ));
288        }
289        for tag in &response.extra_tags {
290            builder = builder.tag(tag.clone());
291        }
292        builder
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use crate::Keys;
300
301    fn keys() -> Keys {
302        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
303    }
304
305    #[test]
306    fn poll_round_trip() {
307        let poll = Poll {
308            label: "Pineapple on pizza".into(),
309            options: vec![
310                PollOption {
311                    id: "yay".into(),
312                    label: "Yay".into(),
313                },
314                PollOption {
315                    id: "nay".into(),
316                    label: "Nay".into(),
317                },
318            ],
319            relays: vec![RelayUrl::parse("wss://relay.example/").unwrap()],
320            poll_type: Some(PollType::SingleChoice),
321            ends_at: Some(Timestamp::from_secs(1_700_000_000)),
322            extra_tags: Vec::new(),
323        };
324        let event = EventBuilder::poll(&poll).sign_with_keys(&keys()).unwrap();
325        let parsed = Poll::from_event(&event).unwrap();
326        assert_eq!(parsed, poll);
327    }
328
329    #[test]
330    fn poll_response_round_trip() {
331        let response = PollResponse {
332            poll_id: EventId::from_byte_array([0x77; 32]),
333            response_ids: vec!["yay".into(), "nay".into()],
334            extra_tags: Vec::new(),
335        };
336        let event = EventBuilder::poll_response(&response)
337            .sign_with_keys(&keys())
338            .unwrap();
339        let parsed = PollResponse::from_event(&event).unwrap();
340        assert_eq!(parsed, response);
341    }
342
343    #[test]
344    fn missing_poll_kind_is_rejected() {
345        let event = EventBuilder::text_note("nope")
346            .sign_with_keys(&keys())
347            .unwrap();
348        assert!(matches!(
349            Poll::from_event(&event),
350            Err(PollError::WrongKind(_))
351        ));
352    }
353
354    #[test]
355    fn poll_default_type() {
356        let poll = Poll::new("q", Vec::new());
357        assert_eq!(poll.effective_type(), PollType::SingleChoice);
358    }
359}