use k256::schnorr::SigningKey;
use thiserror::Error;
use crate::event::{sign_event, NostrEvent, UnsignedEvent};
const KIND_CALENDAR_EVENT: u64 = 31923;
const KIND_CALENDAR_RSVP: u64 = 31925;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RsvpStatus {
Accept,
Decline,
Tentative,
}
impl RsvpStatus {
pub fn as_str(&self) -> &'static str {
match self {
RsvpStatus::Accept => "accepted",
RsvpStatus::Decline => "declined",
RsvpStatus::Tentative => "tentative",
}
}
}
#[derive(Debug, Error)]
pub enum CalendarError {
#[error("title must not be empty")]
EmptyTitle,
#[error("start_timestamp must be > 0")]
InvalidStartTime,
#[error("end_timestamp ({end}) must be >= start_timestamp ({start})")]
EndBeforeStart { start: u64, end: u64 },
#[error("invalid event ID: {0}")]
InvalidEventId(String),
#[error("invalid signing key: {0}")]
InvalidKey(String),
#[error("signing failed: {0}")]
SigningFailed(String),
}
fn now_secs() -> u64 {
#[cfg(target_arch = "wasm32")]
{
(js_sys::Date::now() / 1000.0) as u64
}
#[cfg(not(target_arch = "wasm32"))]
{
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock before UNIX epoch")
.as_secs()
}
}
fn random_d_tag() -> String {
let mut bytes = [0u8; 16];
getrandom::getrandom(&mut bytes).expect("getrandom for d-tag");
hex::encode(bytes)
}
pub fn create_calendar_event(
privkey: &[u8; 32],
title: &str,
start_timestamp: u64,
end_timestamp: Option<u64>,
location: Option<&str>,
description: Option<&str>,
max_attendees: Option<u32>,
) -> Result<NostrEvent, CalendarError> {
if title.is_empty() {
return Err(CalendarError::EmptyTitle);
}
if start_timestamp == 0 {
return Err(CalendarError::InvalidStartTime);
}
if let Some(end) = end_timestamp {
if end < start_timestamp {
return Err(CalendarError::EndBeforeStart {
start: start_timestamp,
end,
});
}
}
let signing_key =
SigningKey::from_bytes(privkey).map_err(|e| CalendarError::InvalidKey(e.to_string()))?;
let pubkey = hex::encode(signing_key.verifying_key().to_bytes());
let d_tag = random_d_tag();
let mut tags = vec![
vec!["d".to_string(), d_tag],
vec!["title".to_string(), title.to_string()],
vec!["start".to_string(), start_timestamp.to_string()],
];
if let Some(end) = end_timestamp {
tags.push(vec!["end".to_string(), end.to_string()]);
}
if let Some(loc) = location {
tags.push(vec!["location".to_string(), loc.to_string()]);
}
if let Some(max) = max_attendees {
tags.push(vec!["max_attendees".to_string(), max.to_string()]);
}
tags.push(vec!["t".to_string(), "calendar-event".to_string()]);
let unsigned = UnsignedEvent {
pubkey,
created_at: now_secs(),
kind: KIND_CALENDAR_EVENT,
tags,
content: description.unwrap_or("").to_string(),
};
sign_event(unsigned, &signing_key).map_err(|e| CalendarError::SigningFailed(e.to_string()))
}
pub fn create_rsvp(
privkey: &[u8; 32],
event_id: &str,
status: RsvpStatus,
) -> Result<NostrEvent, CalendarError> {
if event_id.len() != 64 || hex::decode(event_id).is_err() {
return Err(CalendarError::InvalidEventId(event_id.to_string()));
}
let signing_key =
SigningKey::from_bytes(privkey).map_err(|e| CalendarError::InvalidKey(e.to_string()))?;
let pubkey = hex::encode(signing_key.verifying_key().to_bytes());
let tags = vec![
vec!["d".to_string(), event_id.to_string()],
vec!["e".to_string(), event_id.to_string()],
vec!["status".to_string(), status.as_str().to_string()],
];
let unsigned = UnsignedEvent {
pubkey,
created_at: now_secs(),
kind: KIND_CALENDAR_RSVP,
tags,
content: String::new(),
};
sign_event(unsigned, &signing_key).map_err(|e| CalendarError::SigningFailed(e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::verify_event;
fn test_key() -> [u8; 32] {
[0x01u8; 32]
}
#[test]
fn calendar_event_basic() {
let event = create_calendar_event(
&test_key(),
"Rust Meetup",
1700000000,
None,
None,
None,
None,
)
.unwrap();
assert_eq!(event.kind, 31923);
let tag_names: Vec<&str> = event.tags.iter().map(|t| t[0].as_str()).collect();
assert!(tag_names.contains(&"d"));
assert!(tag_names.contains(&"title"));
assert!(tag_names.contains(&"start"));
assert!(tag_names.contains(&"t"));
let title_tag = event.tags.iter().find(|t| t[0] == "title").unwrap();
assert_eq!(title_tag[1], "Rust Meetup");
let start_tag = event.tags.iter().find(|t| t[0] == "start").unwrap();
assert_eq!(start_tag[1], "1700000000");
assert!(verify_event(&event));
}
#[test]
fn calendar_event_with_all_options() {
let event = create_calendar_event(
&test_key(),
"Workshop",
1700000000,
Some(1700003600),
Some("London"),
Some("A great workshop"),
Some(50),
)
.unwrap();
assert_eq!(event.kind, 31923);
assert_eq!(event.content, "A great workshop");
let end_tag = event.tags.iter().find(|t| t[0] == "end").unwrap();
assert_eq!(end_tag[1], "1700003600");
let loc_tag = event.tags.iter().find(|t| t[0] == "location").unwrap();
assert_eq!(loc_tag[1], "London");
let max_tag = event.tags.iter().find(|t| t[0] == "max_attendees").unwrap();
assert_eq!(max_tag[1], "50");
assert!(verify_event(&event));
}
#[test]
fn calendar_event_empty_title_rejected() {
let result = create_calendar_event(&test_key(), "", 1700000000, None, None, None, None);
assert!(matches!(result, Err(CalendarError::EmptyTitle)));
}
#[test]
fn calendar_event_zero_start_rejected() {
let result = create_calendar_event(&test_key(), "Title", 0, None, None, None, None);
assert!(matches!(result, Err(CalendarError::InvalidStartTime)));
}
#[test]
fn calendar_event_end_before_start_rejected() {
let result = create_calendar_event(
&test_key(),
"Title",
1700000000,
Some(1699999999),
None,
None,
None,
);
assert!(matches!(result, Err(CalendarError::EndBeforeStart { .. })));
}
#[test]
fn calendar_event_d_tag_is_unique() {
let e1 =
create_calendar_event(&test_key(), "A", 1700000000, None, None, None, None).unwrap();
let e2 =
create_calendar_event(&test_key(), "B", 1700000000, None, None, None, None).unwrap();
let d1 = &e1.tags.iter().find(|t| t[0] == "d").unwrap()[1];
let d2 = &e2.tags.iter().find(|t| t[0] == "d").unwrap()[1];
assert_ne!(d1, d2);
}
#[test]
fn rsvp_accept() {
let event_id = "aa".repeat(32);
let event = create_rsvp(&test_key(), &event_id, RsvpStatus::Accept).unwrap();
assert_eq!(event.kind, 31925);
assert_eq!(event.tags[0], vec!["d", &event_id]);
assert_eq!(event.tags[1], vec!["e", &event_id]);
assert_eq!(event.tags[2], vec!["status", "accepted"]);
assert_eq!(event.content, "");
assert!(verify_event(&event));
}
#[test]
fn rsvp_decline() {
let event_id = "bb".repeat(32);
let event = create_rsvp(&test_key(), &event_id, RsvpStatus::Decline).unwrap();
let status_tag = event.tags.iter().find(|t| t[0] == "status").unwrap();
assert_eq!(status_tag[1], "declined");
assert!(verify_event(&event));
}
#[test]
fn rsvp_tentative() {
let event_id = "cc".repeat(32);
let event = create_rsvp(&test_key(), &event_id, RsvpStatus::Tentative).unwrap();
let status_tag = event.tags.iter().find(|t| t[0] == "status").unwrap();
assert_eq!(status_tag[1], "tentative");
assert!(verify_event(&event));
}
#[test]
fn rsvp_invalid_event_id_rejected() {
let result = create_rsvp(&test_key(), "not-valid-hex", RsvpStatus::Accept);
assert!(matches!(result, Err(CalendarError::InvalidEventId(_))));
}
#[test]
fn rsvp_short_event_id_rejected() {
let result = create_rsvp(&test_key(), "aabb", RsvpStatus::Accept);
assert!(matches!(result, Err(CalendarError::InvalidEventId(_))));
}
#[test]
fn rsvp_d_tag_matches_event_id() {
let event_id = "dd".repeat(32);
let event = create_rsvp(&test_key(), &event_id, RsvpStatus::Accept).unwrap();
let d_tag = event.tags.iter().find(|t| t[0] == "d").unwrap();
assert_eq!(d_tag[1], event_id);
}
}