refactor(calendar): move the reminder source beside the feeds
CalendarWatch, the client resolution, and the slot policy were all feed domain living in the claw's config and source tree; only the extension adapter was the claw's. The reminder source now ships in the integration as the default answer to "what does a start time mean": one slot per lead time, calendar:<name> keys, fed to the consumer's dispatcher. The crate doc drops its no-policy claim to match, and the claw's resident prefixes take the calendar prefix from the integration so the spelling cannot drift.
This commit is contained in:
1 parent
fc0cbbcb6b
commit
b70a56c325
7 files changed
+251
-218
No files matched your search
@@ -15,6 +15,12 @@ use std::path::PathBuf;
|
|||||||
use rhema_forge_integration::RepoRef;
|
use rhema_forge_integration::RepoRef;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
// The calendar feed types are the integration's: the reminder source
|
||||||
|
// polls them there, and the claw's config only describes them.
|
||||||
|
pub use rhema_calendar_integration::reminder::{
|
||||||
|
CalendarKind, CalendarWatch, DEFAULT_HORIZON_HOURS, DEFAULT_LEAD_MINUTES,
|
||||||
|
};
|
||||||
|
|
||||||
/// How often the resident loop runs a detect pass, when
|
/// How often the resident loop runs a detect pass, when
|
||||||
/// [`ClawConfig::poll_interval_secs`] is unset.
|
/// [`ClawConfig::poll_interval_secs`] is unset.
|
||||||
pub const DEFAULT_POLL_INTERVAL_SECS: u64 = 60;
|
pub const DEFAULT_POLL_INTERVAL_SECS: u64 = 60;
|
||||||
@@ -29,16 +35,6 @@ pub const DEFAULT_MAX_TURN_STEPS: u32 = 2048;
|
|||||||
/// loopback only — the surface is for the machine's resident user.
|
/// loopback only — the surface is for the machine's resident user.
|
||||||
pub const DEFAULT_LISTEN: &str = "127.0.0.1:7799";
|
pub const DEFAULT_LISTEN: &str = "127.0.0.1:7799";
|
||||||
|
|
||||||
/// The one reminder lead, in minutes, a calendar carries when
|
|
||||||
/// [`CalendarWatch::lead_minutes`] is unset: warn shortly before, and
|
|
||||||
/// let a busier lead list be configured where wanted.
|
|
||||||
pub const DEFAULT_LEAD_MINUTES: u64 = 15;
|
|
||||||
|
|
||||||
/// How far ahead a calendar polls, in hours, when
|
|
||||||
/// [`CalendarWatch::horizon_hours`] is unset. Must exceed the longest
|
|
||||||
/// lead: a reminder cannot fire before the event is visible.
|
|
||||||
pub const DEFAULT_HORIZON_HOURS: u32 = 24;
|
|
||||||
|
|
||||||
/// Why a config operation failed.
|
/// Why a config operation failed.
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum ConfigError {
|
pub enum ConfigError {
|
||||||
@@ -221,165 +217,6 @@ pub fn resolve_token(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Which calendar feed a calendar watch polls. The wire spelling is
|
|
||||||
/// the source name the dispatcher registers and the reminder prompt
|
|
||||||
/// names.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "lowercase")]
|
|
||||||
pub enum CalendarKind {
|
|
||||||
/// An iCalendar feed over HTTP (`webcal://` counts): Google's
|
|
||||||
/// secret iCal address, a Nextcloud or Radicale share, Fastmail.
|
|
||||||
Ics,
|
|
||||||
/// A CalDAV collection, polled with a `calendar-query` REPORT:
|
|
||||||
/// Nextcloud, Radicale, Fastmail, a plain DAV server.
|
|
||||||
Caldav,
|
|
||||||
/// The Google Calendar API, against an OAuth access token or an
|
|
||||||
/// API key for public calendars.
|
|
||||||
Google,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CalendarKind {
|
|
||||||
/// The spelling the config, the reminder source, and the prompts
|
|
||||||
/// use.
|
|
||||||
pub fn name(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
CalendarKind::Ics => "ics",
|
|
||||||
CalendarKind::Caldav => "caldav",
|
|
||||||
CalendarKind::Google => "google",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parses a calendar kind as the config spells it. `None` for
|
|
||||||
/// anything else.
|
|
||||||
pub fn parse(name: &str) -> Option<Self> {
|
|
||||||
match name.trim() {
|
|
||||||
"ics" => Some(CalendarKind::Ics),
|
|
||||||
"caldav" => Some(CalendarKind::Caldav),
|
|
||||||
"google" => Some(CalendarKind::Google),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One watched calendar. Unknown keys are rejected, as on a watch.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case", deny_unknown_fields)]
|
|
||||||
pub struct CalendarWatch {
|
|
||||||
/// The calendar's name: unique across the list; the reminder key
|
|
||||||
/// is `calendar:<name>` and prompts name the calendar by it.
|
|
||||||
pub name: String,
|
|
||||||
/// Which feed kind this polls.
|
|
||||||
pub kind: CalendarKind,
|
|
||||||
/// The feed URL (`https://` or `webcal://`). Required for ics.
|
|
||||||
pub url: Option<String>,
|
|
||||||
/// The Google calendar id (`primary`, an address, a hex id).
|
|
||||||
/// Required for google.
|
|
||||||
pub calendar_id: Option<String>,
|
|
||||||
/// A bearer token (an OAuth access token for Google, auth for a
|
|
||||||
/// feed or collection that demands it). Falls back to
|
|
||||||
/// `CALENDAR_TOKEN`.
|
|
||||||
pub token: Option<String>,
|
|
||||||
/// The CalDAV user, for Basic auth. Set only with a password (or
|
|
||||||
/// `CALDAV_PASSWORD`); a calendar with a username authenticates
|
|
||||||
/// Basic, and `token` is the fallback only when there is none.
|
|
||||||
pub username: Option<String>,
|
|
||||||
/// The CalDAV password or app-specific password. Falls back to
|
|
||||||
/// `CALDAV_PASSWORD`.
|
|
||||||
pub password: Option<String>,
|
|
||||||
/// A Google API key for a public calendar; wins over `token`.
|
|
||||||
pub api_key: Option<String>,
|
|
||||||
/// Reminder lead times, minutes; each occurrence warns once per
|
|
||||||
/// lead. `[DEFAULT_LEAD_MINUTES]` when unset.
|
|
||||||
pub lead_minutes: Option<Vec<u64>>,
|
|
||||||
/// How far ahead the poll looks, hours. `[DEFAULT_HORIZON_HOURS]`
|
|
||||||
/// when unset; must exceed the longest lead.
|
|
||||||
pub horizon_hours: Option<u32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CalendarWatch {
|
|
||||||
/// The reminder leads as configured, blank and zero entries
|
|
||||||
/// dropped; [`DEFAULT_LEAD_MINUTES`] alone when nothing usable is
|
|
||||||
/// configured.
|
|
||||||
pub fn leads(&self) -> Vec<u64> {
|
|
||||||
match self.lead_minutes.as_ref() {
|
|
||||||
Some(leads) => {
|
|
||||||
let usable: Vec<u64> = leads.iter().copied().filter(|lead| *lead > 0).collect();
|
|
||||||
if usable.is_empty() {
|
|
||||||
vec![DEFAULT_LEAD_MINUTES]
|
|
||||||
} else {
|
|
||||||
usable
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => vec![DEFAULT_LEAD_MINUTES],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The poll horizon as configured, [`DEFAULT_HORIZON_HOURS`] when
|
|
||||||
/// unset. Zero reads as the default: a horizon of nothing polls
|
|
||||||
/// nothing, and the config validation refuses it when it would
|
|
||||||
/// matter.
|
|
||||||
pub fn horizon(&self) -> u32 {
|
|
||||||
self.horizon_hours
|
|
||||||
.filter(|hours| *hours > 0)
|
|
||||||
.unwrap_or(DEFAULT_HORIZON_HOURS)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A trimmed `token`/`api_key` value; blank counts as unset.
|
|
||||||
fn trimmed(value: Option<&str>) -> Option<String> {
|
|
||||||
value
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.map(str::to_owned)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The API key as configured, if any.
|
|
||||||
pub fn resolved_api_key(&self) -> Option<String> {
|
|
||||||
Self::trimmed(self.api_key.as_deref())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The CalDAV password to authenticate with: the config value,
|
|
||||||
/// then `CALDAV_PASSWORD`. Parameterized over the env lookup in
|
|
||||||
/// the pure [`CalendarWatch::resolve_password_with`].
|
|
||||||
pub fn resolved_password(&self) -> Option<String> {
|
|
||||||
Self::resolve_password_with(|name| std::env::var(name), self.password.as_deref())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pure password resolution: the config value first, then
|
|
||||||
/// `CALDAV_PASSWORD`. Parameterized over the env lookup so tests
|
|
||||||
/// can hold the environment still.
|
|
||||||
pub fn resolve_password_with(
|
|
||||||
get_env: impl Fn(&str) -> Result<String, std::env::VarError>,
|
|
||||||
stored: Option<&str>,
|
|
||||||
) -> Option<String> {
|
|
||||||
Self::trimmed(stored).or_else(|| {
|
|
||||||
get_env("CALDAV_PASSWORD")
|
|
||||||
.ok()
|
|
||||||
.and_then(|token| Self::trimmed(Some(&token)))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The token to poll with: the config value, then `CALENDAR_TOKEN`;
|
|
||||||
/// the first non-blank wins. Parameterized over the env lookup in
|
|
||||||
/// the pure [`CalendarWatch::resolve_token_with`].
|
|
||||||
pub fn resolved_token(&self) -> Option<String> {
|
|
||||||
Self::resolve_token_with(|name| std::env::var(name), self.token.as_deref())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pure token resolution: the config value first, then
|
|
||||||
/// `CALENDAR_TOKEN`. Parameterized over the env lookup so tests can
|
|
||||||
/// hold the environment still.
|
|
||||||
pub fn resolve_token_with(
|
|
||||||
get_env: impl Fn(&str) -> Result<String, std::env::VarError>,
|
|
||||||
stored: Option<&str>,
|
|
||||||
) -> Option<String> {
|
|
||||||
Self::trimmed(stored).or_else(|| {
|
|
||||||
get_env("CALENDAR_TOKEN")
|
|
||||||
.ok()
|
|
||||||
.and_then(|token| Self::trimmed(Some(&token)))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether a `host:port` listen address binds beyond loopback.
|
/// Whether a `host:port` listen address binds beyond loopback.
|
||||||
/// `localhost` (any case) and any loopback IP literal — `127.x.x.x`,
|
/// `localhost` (any case) and any loopback IP literal — `127.x.x.x`,
|
||||||
/// `::1` — are loopback; an empty host (`:7799`, the bind-everything
|
/// `::1` — are loopback; an empty host (`:7799`, the bind-everything
|
||||||
|
|||||||
@@ -36,12 +36,12 @@ use rhema_autonomous_agent::{Allowlist, Source, ToolRegistry};
|
|||||||
use rhema_core::ToolResolver;
|
use rhema_core::ToolResolver;
|
||||||
use rhema_rsd::{Dispatcher, ResourceKey, SystemClock};
|
use rhema_rsd::{Dispatcher, ResourceKey, SystemClock};
|
||||||
|
|
||||||
use crate::calendar::CalendarSource;
|
|
||||||
use crate::config::{CalendarWatch, ForgeKind};
|
use crate::config::{CalendarWatch, ForgeKind};
|
||||||
use crate::consents::WaitBridge;
|
use crate::consents::WaitBridge;
|
||||||
use crate::daemon::ClawError;
|
use crate::daemon::ClawError;
|
||||||
use crate::forge::ForgeSource;
|
use crate::forge::ForgeSource;
|
||||||
use crate::hub::EventHub;
|
use crate::hub::EventHub;
|
||||||
|
use rhema_calendar_integration::CalendarSource;
|
||||||
|
|
||||||
/// The store handles the standard set is assembled over. One struct,
|
/// The store handles the standard set is assembled over. One struct,
|
||||||
/// so a new extension is a field plus a constructor line, not another
|
/// so a new extension is a field plus a constructor line, not another
|
||||||
|
|||||||
@@ -73,7 +73,12 @@ use crate::notifications::NotificationStore;
|
|||||||
/// `interactive:`, `task:`, `phone:`, and `calendar:`. Forge keys —
|
/// `interactive:`, `task:`, `phone:`, and `calendar:`. Forge keys —
|
||||||
/// they carry `#` — belong to the forge handler; these four and
|
/// they carry `#` — belong to the forge handler; these four and
|
||||||
/// nothing else belong to this one.
|
/// nothing else belong to this one.
|
||||||
pub const RESIDENT_PREFIXES: [&str; 4] = ["interactive:", "task:", "phone:", "calendar:"];
|
pub const RESIDENT_PREFIXES: [&str; 4] = [
|
||||||
|
"interactive:",
|
||||||
|
"task:",
|
||||||
|
"phone:",
|
||||||
|
rhema_calendar_integration::reminder::KEY_PREFIX,
|
||||||
|
];
|
||||||
|
|
||||||
/// The prefix of the live chat surface's keys: `interactive:<name>` —
|
/// The prefix of the live chat surface's keys: `interactive:<name>` —
|
||||||
/// the name is the caller's label for the session (`web`, a chat
|
/// the name is the caller's label for the session (`web`, a chat
|
||||||
|
|||||||
@@ -49,8 +49,6 @@
|
|||||||
//! the standard set — the tool chain, the detect passes, and the
|
//! the standard set — the tool chain, the detect passes, and the
|
||||||
//! boot pass all iterate it
|
//! boot pass all iterate it
|
||||||
//! - [`forge`] — the sources: one detectable stream per forge kind
|
//! - [`forge`] — the sources: one detectable stream per forge kind
|
||||||
//! - [`calendar`] — the [`CalendarSource`]: due calendar reminders in,
|
|
||||||
//! events out
|
|
||||||
//! - [`task_source`] — the [`TaskScheduler`]: due tasks in, events out
|
//! - [`task_source`] — the [`TaskScheduler`]: due tasks in, events out
|
||||||
//! - [`handler`] — the forge dispatch: snapshot, inbox, one turn, one
|
//! - [`handler`] — the forge dispatch: snapshot, inbox, one turn, one
|
||||||
//! comment
|
//! comment
|
||||||
@@ -69,7 +67,6 @@ pub mod accounts_tools;
|
|||||||
pub mod attachments;
|
pub mod attachments;
|
||||||
pub mod backends;
|
pub mod backends;
|
||||||
pub mod bootstrap;
|
pub mod bootstrap;
|
||||||
pub mod calendar;
|
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod consents;
|
pub mod consents;
|
||||||
pub mod daemon;
|
pub mod daemon;
|
||||||
@@ -103,7 +100,6 @@ pub use bootstrap::{
|
|||||||
BOOTSTRAP_SAVE, Bootstrap, BootstrapError, BootstrapKind, BootstrapStatus, DEFAULT_SYSTEM_MD,
|
BOOTSTRAP_SAVE, Bootstrap, BootstrapError, BootstrapKind, BootstrapStatus, DEFAULT_SYSTEM_MD,
|
||||||
render_system,
|
render_system,
|
||||||
};
|
};
|
||||||
pub use calendar::CalendarSource;
|
|
||||||
pub use config::{ClawConfig, ConfigError, ForgeKind, Watch};
|
pub use config::{ClawConfig, ConfigError, ForgeKind, Watch};
|
||||||
pub use daemon::{ClawError, Daemon, TickReport, run_once};
|
pub use daemon::{ClawError, Daemon, TickReport, run_once};
|
||||||
pub use environment::write_report;
|
pub use environment::write_report;
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ authors.workspace = true
|
|||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
# The reminder source: one `Source` over the feeds, detect-pass shaped
|
||||||
|
# like the task scheduler's
|
||||||
|
rhema-autonomous-agent = { path = "../../crates/rhema-autonomous-agent", version = "0.1.0" }
|
||||||
|
# The reminder source ingests its slots into a `Dispatcher`; the event
|
||||||
|
# and key-function shapes are the dispatcher's
|
||||||
|
rhema-rsd = { path = "../../crates/rhema-rsd", version = "0.1.0" }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
|
|||||||
@@ -3,16 +3,19 @@
|
|||||||
//! calendar feeds a resident agent can remind about, with the adapters
|
//! calendar feeds a resident agent can remind about, with the adapters
|
||||||
//! that read them.
|
//! that read them.
|
||||||
//!
|
//!
|
||||||
//! An integration, not an agent: no driver, no loop, and no reminder
|
//! An integration, not an agent: no driver, no model, and no loop
|
||||||
//! policy lives here. A consumer composes the crate with its own agent
|
//! lives here. A consumer composes the crate with its own agent tier —
|
||||||
//! tier — it picks a [`crate::Window`], polls a [`crate::Calendar`]
|
//! it picks a [`crate::Window`], polls a [`crate::Calendar`] for the
|
||||||
//! for the occurrences starting inside it, and decides what a start
|
//! occurrences starting inside it, and decides what a start time
|
||||||
//! time means (the claw daemon today: one reminder slot per lead time,
|
//! means. The default answer ships in [`crate::reminder`]: one
|
||||||
//! dispatched into the resident agent). Every [`crate::CalendarEvent`]
|
//! reminder slot per lead time, keyed `calendar:<name>`, fed to the
|
||||||
//! carries an id that stays the same across polls of the same
|
//! consumer's dispatcher as a `Source` — every reminder-shaped
|
||||||
//! occurrence, so a consumer re-polling on its own cadence never
|
//! consumer starts there. A consumer with different semantics brings
|
||||||
//! re-fires a reminder it already fed; the repetition is the
|
//! its own; every [`crate::CalendarEvent`] carries an id that stays
|
||||||
//! consumer's dispatcher's dedup, not this crate's problem.
|
//! the same across polls of the same occurrence, so any
|
||||||
|
//! implementation re-polling on its own cadence never re-fires a
|
||||||
|
//! reminder it already fed. The repetition is the consumer's
|
||||||
|
//! dispatcher's dedup, not this crate's problem.
|
||||||
//!
|
//!
|
||||||
//! Recurrence is expanded before a poll answers, so a consumer never
|
//! Recurrence is expanded before a poll answers, so a consumer never
|
||||||
//! sees a rule, only starts it can compare against its clock:
|
//! sees a rule, only starts it can compare against its clock:
|
||||||
@@ -57,6 +60,8 @@
|
|||||||
//! - [`ics`] — the ICS/webcal adapter (one GET, expanded client-side)
|
//! - [`ics`] — the ICS/webcal adapter (one GET, expanded client-side)
|
||||||
//! - [`caldav`] — the CalDAV adapter (one REPORT, the same expansion)
|
//! - [`caldav`] — the CalDAV adapter (one REPORT, the same expansion)
|
||||||
//! - [`google`] — the Google Calendar API adapter (expanded server-side)
|
//! - [`google`] — the Google Calendar API adapter (expanded server-side)
|
||||||
|
//! - [`reminder`] — the default reminder source: due slots per lead
|
||||||
|
//! time, `calendar:<name>` keys, one `Source` over the configured feeds
|
||||||
|
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
#![deny(missing_docs)]
|
#![deny(missing_docs)]
|
||||||
@@ -67,6 +72,7 @@ pub mod error;
|
|||||||
pub mod event;
|
pub mod event;
|
||||||
pub mod google;
|
pub mod google;
|
||||||
pub mod ics;
|
pub mod ics;
|
||||||
|
pub mod reminder;
|
||||||
|
|
||||||
// Re-exported so implementors of the async seam can apply #[async_trait]
|
// Re-exported so implementors of the async seam can apply #[async_trait]
|
||||||
// without adding the crate themselves.
|
// without adding the crate themselves.
|
||||||
@@ -77,3 +83,4 @@ pub use error::CalendarError;
|
|||||||
pub use event::{CalendarEvent, Window};
|
pub use event::{CalendarEvent, Window};
|
||||||
pub use google::GoogleCalendar;
|
pub use google::GoogleCalendar;
|
||||||
pub use ics::IcsCalendar;
|
pub use ics::IcsCalendar;
|
||||||
|
pub use reminder::{CalendarKind, CalendarSource, CalendarWatch, calendar_key};
|
||||||
+215
-33
@@ -1,4 +1,4 @@
|
|||||||
//! The calendar source: deterministic detection of due reminders, the
|
//! The reminder source: deterministic detection of due reminders, the
|
||||||
//! task scheduler's shape over a calendar feed.
|
//! task scheduler's shape over a calendar feed.
|
||||||
//!
|
//!
|
||||||
//! The [`CalendarSource`] is a [`Source`] named `"calendars"` on the
|
//! The [`CalendarSource`] is a [`Source`] named `"calendars"` on the
|
||||||
@@ -13,33 +13,38 @@
|
|||||||
//! never fire: a lead in minutes against a midnight start is noise,
|
//! never fire: a lead in minutes against a midnight start is noise,
|
||||||
//! and a date-shaped nudge is what the task tools are for.
|
//! and a date-shaped nudge is what the task tools are for.
|
||||||
//!
|
//!
|
||||||
//! The model is never reached here; the turn happens because the
|
//! This is the default reminder policy, shipped beside the feeds it
|
||||||
//! dispatcher admitted the `calendar:<name>` key, exactly as for a
|
//! polls: one slot per lead time, `calendar:<name>` keys, all-day
|
||||||
//! task firing, and the resident handler's delivery rule auto-notifies
|
//! occurrences never fired. A consumer with different semantics brings
|
||||||
//! the final text to the user's devices. One session per calendar
|
//! its own source — the vocabulary's stable occurrence ids keep any
|
||||||
//! persists across its reminders, so the claw remembers what it said
|
//! implementation dedup-safe — but every reminder-shaped one starts
|
||||||
//! about the last one. A dead feed is logged and skipped, never fatal:
|
//! here. The model is never reached; the turn happens because the
|
||||||
//! an outage must not eat the other calendars' reminders on the same
|
//! consumer's dispatcher admitted the `calendar:<name>` key. One
|
||||||
//! pass. Cadence belongs to the daemon's loop.
|
//! session per calendar persists across its reminders, so the resident
|
||||||
|
//! remembers what it said about the last one. A dead feed is logged
|
||||||
|
//! and skipped, never fatal: an outage must not eat the other
|
||||||
|
//! calendars' reminders on the same pass. Cadence belongs to the
|
||||||
|
//! consumer's loop.
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use rhema_autonomous_agent::{Source, SourceError, async_trait};
|
use rhema_autonomous_agent::{Source, SourceError, async_trait};
|
||||||
use rhema_calendar_integration::{
|
use rhema_rsd::{Clock, Dispatcher, Event, KeyFn, ResourceKey};
|
||||||
CaldavCalendar, Calendar, CalendarEvent, GoogleCalendar, IcsCalendar, Window,
|
use serde::{Deserialize, Serialize};
|
||||||
};
|
use serde_json::{Value, json};
|
||||||
use rhema_rsd::{Clock, Dispatcher, Event, KeyFn};
|
|
||||||
use serde_json::json;
|
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
use time::format_description::well_known::Rfc3339;
|
use time::format_description::well_known::Rfc3339;
|
||||||
|
|
||||||
use crate::config::{CalendarKind, CalendarWatch};
|
use crate::{CaldavCalendar, Calendar, CalendarEvent, GoogleCalendar, IcsCalendar, Window};
|
||||||
use crate::interactive::passthrough_key_fn;
|
|
||||||
|
|
||||||
/// The source name the calendar reminders ingest under; registered on
|
/// The source name the calendar reminders ingest under; registered on
|
||||||
/// the resident dispatcher, so allowlists and `/v1/ingest` can name it.
|
/// the consumer's dispatcher, so allowlists and ingest doors can name
|
||||||
|
/// it.
|
||||||
pub const CALENDARS_SOURCE: &str = "calendars";
|
pub const CALENDARS_SOURCE: &str = "calendars";
|
||||||
|
|
||||||
|
/// The key prefix the reminder sessions live under: `calendar:<name>`.
|
||||||
|
pub const KEY_PREFIX: &str = "calendar:";
|
||||||
|
|
||||||
/// The env variable a calendar's token falls back to.
|
/// The env variable a calendar's token falls back to.
|
||||||
pub const TOKEN_ENV: &str = "CALENDAR_TOKEN";
|
pub const TOKEN_ENV: &str = "CALENDAR_TOKEN";
|
||||||
|
|
||||||
@@ -49,7 +54,22 @@ pub const CALDAV_PASSWORD_ENV: &str = "CALDAV_PASSWORD";
|
|||||||
/// One calendar's reminder key: `calendar:<name>` — the session the
|
/// One calendar's reminder key: `calendar:<name>` — the session the
|
||||||
/// reminder turns run in, persistent across the calendar's reminders.
|
/// reminder turns run in, persistent across the calendar's reminders.
|
||||||
pub fn calendar_key(name: &str) -> String {
|
pub fn calendar_key(name: &str) -> String {
|
||||||
format!("calendar:{name}")
|
format!("{KEY_PREFIX}{name}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The key function the source registers: the payload's `key`
|
||||||
|
/// verbatim — one key, one owned session — with the infallible
|
||||||
|
/// fallback, so a keyless payload can neither clobber a session nor
|
||||||
|
/// wedge the dispatcher; it lands in the unroutable slot.
|
||||||
|
fn reminder_key_fn() -> Arc<KeyFn> {
|
||||||
|
Arc::new(|event: &Event| {
|
||||||
|
event
|
||||||
|
.payload
|
||||||
|
.get("key")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(ResourceKey::from)
|
||||||
|
.unwrap_or_else(|| ResourceKey::from(format!("{KEY_PREFIX}unroutable#{}", event.id)))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One configured calendar, resolved to a client the source polls.
|
/// One configured calendar, resolved to a client the source polls.
|
||||||
@@ -238,7 +258,7 @@ impl CalendarSource {
|
|||||||
horizon_hours: calendar.horizon(),
|
horizon_hours: calendar.horizon(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
dispatcher.register_source(CALENDARS_SOURCE, passthrough_key_fn());
|
dispatcher.register_source(CALENDARS_SOURCE, reminder_key_fn());
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
dispatcher: dispatcher.clone(),
|
dispatcher: dispatcher.clone(),
|
||||||
clock,
|
clock,
|
||||||
@@ -254,7 +274,7 @@ impl CalendarSource {
|
|||||||
clock: Arc<dyn Clock>,
|
clock: Arc<dyn Clock>,
|
||||||
feeds: Vec<Feed>,
|
feeds: Vec<Feed>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
dispatcher.register_source(CALENDARS_SOURCE, passthrough_key_fn());
|
dispatcher.register_source(CALENDARS_SOURCE, reminder_key_fn());
|
||||||
Self {
|
Self {
|
||||||
dispatcher: dispatcher.clone(),
|
dispatcher: dispatcher.clone(),
|
||||||
clock,
|
clock,
|
||||||
@@ -270,7 +290,7 @@ impl Source for CalendarSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn key_fn(&self) -> Arc<KeyFn> {
|
fn key_fn(&self) -> Arc<KeyFn> {
|
||||||
passthrough_key_fn()
|
reminder_key_fn()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn detect(&self) -> Result<usize, SourceError> {
|
async fn detect(&self) -> Result<usize, SourceError> {
|
||||||
@@ -289,7 +309,7 @@ impl Source for CalendarSource {
|
|||||||
// the next pass polls it again, and the other
|
// the next pass polls it again, and the other
|
||||||
// calendars' reminders never wait behind it.
|
// calendars' reminders never wait behind it.
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
eprintln!("[claw] warning: calendar {} failed: {err}", feed.name);
|
eprintln!("[calendar] warning: {} feed failed: {err}", feed.name);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -311,6 +331,7 @@ impl Source for CalendarSource {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::CalendarError;
|
||||||
use rhema_rsd::{DispatcherConfig, FixedClock, ResourceKey, SystemClock, VolatileStore};
|
use rhema_rsd::{DispatcherConfig, FixedClock, ResourceKey, SystemClock, VolatileStore};
|
||||||
|
|
||||||
/// 2026-10-20T00:00:00Z, in milliseconds.
|
/// 2026-10-20T00:00:00Z, in milliseconds.
|
||||||
@@ -332,10 +353,7 @@ mod tests {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Calendar for Fixed {
|
impl Calendar for Fixed {
|
||||||
async fn events(
|
async fn events(&self, _window: &Window) -> Result<Vec<CalendarEvent>, CalendarError> {
|
||||||
&self,
|
|
||||||
_window: &Window,
|
|
||||||
) -> Result<Vec<CalendarEvent>, rhema_calendar_integration::CalendarError> {
|
|
||||||
Ok(self.0.clone())
|
Ok(self.0.clone())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -462,13 +480,8 @@ mod tests {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Calendar for Dead {
|
impl Calendar for Dead {
|
||||||
async fn events(
|
async fn events(&self, _window: &Window) -> Result<Vec<CalendarEvent>, CalendarError> {
|
||||||
&self,
|
Err(CalendarError::Network("down".to_owned()))
|
||||||
_window: &Window,
|
|
||||||
) -> Result<Vec<CalendarEvent>, rhema_calendar_integration::CalendarError> {
|
|
||||||
Err(rhema_calendar_integration::CalendarError::Network(
|
|
||||||
"down".to_owned(),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -544,3 +557,172 @@ mod tests {
|
|||||||
assert!(prompt.contains("in 3 minutes"), "got: {prompt}");
|
assert!(prompt.contains("in 3 minutes"), "got: {prompt}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The one reminder lead, in minutes, a calendar carries when
|
||||||
|
/// [`CalendarWatch::lead_minutes`] is unset: warn shortly before, and
|
||||||
|
/// let a busier lead list be configured where wanted.
|
||||||
|
pub const DEFAULT_LEAD_MINUTES: u64 = 15;
|
||||||
|
|
||||||
|
/// How far ahead a calendar polls, in hours, when
|
||||||
|
/// [`CalendarWatch::horizon_hours`] is unset. Must exceed the longest
|
||||||
|
/// lead: a reminder cannot fire before the event is visible.
|
||||||
|
pub const DEFAULT_HORIZON_HOURS: u32 = 24;
|
||||||
|
|
||||||
|
/// Which calendar feed a calendar watch polls. The wire spelling is
|
||||||
|
/// the source name the dispatcher registers and the reminder prompt
|
||||||
|
/// names.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum CalendarKind {
|
||||||
|
/// An iCalendar feed over HTTP (`webcal://` counts): Google's
|
||||||
|
/// secret iCal address, a Nextcloud or Radicale share, Fastmail.
|
||||||
|
Ics,
|
||||||
|
/// A CalDAV collection, polled with a `calendar-query` REPORT:
|
||||||
|
/// Nextcloud, Radicale, Fastmail, a plain DAV server.
|
||||||
|
Caldav,
|
||||||
|
/// The Google Calendar API, against an OAuth access token or an
|
||||||
|
/// API key for public calendars.
|
||||||
|
Google,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CalendarKind {
|
||||||
|
/// The spelling the config, the reminder source, and the prompts
|
||||||
|
/// use.
|
||||||
|
pub fn name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
CalendarKind::Ics => "ics",
|
||||||
|
CalendarKind::Caldav => "caldav",
|
||||||
|
CalendarKind::Google => "google",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a calendar kind as the config spells it. `None` for
|
||||||
|
/// anything else.
|
||||||
|
pub fn parse(name: &str) -> Option<Self> {
|
||||||
|
match name.trim() {
|
||||||
|
"ics" => Some(CalendarKind::Ics),
|
||||||
|
"caldav" => Some(CalendarKind::Caldav),
|
||||||
|
"google" => Some(CalendarKind::Google),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One watched calendar. Unknown keys are rejected, as on a watch.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case", deny_unknown_fields)]
|
||||||
|
pub struct CalendarWatch {
|
||||||
|
/// The calendar's name: unique across the list; the reminder key
|
||||||
|
/// is `calendar:<name>` and prompts name the calendar by it.
|
||||||
|
pub name: String,
|
||||||
|
/// Which feed kind this polls.
|
||||||
|
pub kind: CalendarKind,
|
||||||
|
/// The feed URL (`https://` or `webcal://`). Required for ics.
|
||||||
|
pub url: Option<String>,
|
||||||
|
/// The Google calendar id (`primary`, an address, a hex id).
|
||||||
|
/// Required for google.
|
||||||
|
pub calendar_id: Option<String>,
|
||||||
|
/// A bearer token (an OAuth access token for Google, auth for a
|
||||||
|
/// feed or collection that demands it). Falls back to
|
||||||
|
/// `CALENDAR_TOKEN`.
|
||||||
|
pub token: Option<String>,
|
||||||
|
/// The CalDAV user, for Basic auth. Set only with a password (or
|
||||||
|
/// `CALDAV_PASSWORD`); a calendar with a username authenticates
|
||||||
|
/// Basic, and `token` is the fallback only when there is none.
|
||||||
|
pub username: Option<String>,
|
||||||
|
/// The CalDAV password or app-specific password. Falls back to
|
||||||
|
/// `CALDAV_PASSWORD`.
|
||||||
|
pub password: Option<String>,
|
||||||
|
/// A Google API key for a public calendar; wins over `token`.
|
||||||
|
pub api_key: Option<String>,
|
||||||
|
/// Reminder lead times, minutes; each occurrence warns once per
|
||||||
|
/// lead. `[DEFAULT_LEAD_MINUTES]` when unset.
|
||||||
|
pub lead_minutes: Option<Vec<u64>>,
|
||||||
|
/// How far ahead the poll looks, hours. `[DEFAULT_HORIZON_HOURS]`
|
||||||
|
/// when unset; must exceed the longest lead.
|
||||||
|
pub horizon_hours: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CalendarWatch {
|
||||||
|
/// The reminder leads as configured, blank and zero entries
|
||||||
|
/// dropped; [`DEFAULT_LEAD_MINUTES`] alone when nothing usable is
|
||||||
|
/// configured.
|
||||||
|
pub fn leads(&self) -> Vec<u64> {
|
||||||
|
match self.lead_minutes.as_ref() {
|
||||||
|
Some(leads) => {
|
||||||
|
let usable: Vec<u64> = leads.iter().copied().filter(|lead| *lead > 0).collect();
|
||||||
|
if usable.is_empty() {
|
||||||
|
vec![DEFAULT_LEAD_MINUTES]
|
||||||
|
} else {
|
||||||
|
usable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => vec![DEFAULT_LEAD_MINUTES],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The poll horizon as configured, [`DEFAULT_HORIZON_HOURS`] when
|
||||||
|
/// unset. Zero reads as the default: a horizon of nothing polls
|
||||||
|
/// nothing, and the config validation refuses it when it would
|
||||||
|
/// matter.
|
||||||
|
pub fn horizon(&self) -> u32 {
|
||||||
|
self.horizon_hours
|
||||||
|
.filter(|hours| *hours > 0)
|
||||||
|
.unwrap_or(DEFAULT_HORIZON_HOURS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A trimmed `token`/`api_key` value; blank counts as unset.
|
||||||
|
fn trimmed(value: Option<&str>) -> Option<String> {
|
||||||
|
value
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(str::to_owned)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The API key as configured, if any.
|
||||||
|
pub fn resolved_api_key(&self) -> Option<String> {
|
||||||
|
Self::trimmed(self.api_key.as_deref())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The CalDAV password to authenticate with: the config value,
|
||||||
|
/// then `CALDAV_PASSWORD`. Parameterized over the env lookup in
|
||||||
|
/// the pure [`CalendarWatch::resolve_password_with`].
|
||||||
|
pub fn resolved_password(&self) -> Option<String> {
|
||||||
|
Self::resolve_password_with(|name| std::env::var(name), self.password.as_deref())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure password resolution: the config value first, then
|
||||||
|
/// `CALDAV_PASSWORD`. Parameterized over the env lookup so tests
|
||||||
|
/// can hold the environment still.
|
||||||
|
pub fn resolve_password_with(
|
||||||
|
get_env: impl Fn(&str) -> Result<String, std::env::VarError>,
|
||||||
|
stored: Option<&str>,
|
||||||
|
) -> Option<String> {
|
||||||
|
Self::trimmed(stored).or_else(|| {
|
||||||
|
get_env("CALDAV_PASSWORD")
|
||||||
|
.ok()
|
||||||
|
.and_then(|token| Self::trimmed(Some(&token)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The token to poll with: the config value, then `CALENDAR_TOKEN`;
|
||||||
|
/// the first non-blank wins. Parameterized over the env lookup in
|
||||||
|
/// the pure [`CalendarWatch::resolve_token_with`].
|
||||||
|
pub fn resolved_token(&self) -> Option<String> {
|
||||||
|
Self::resolve_token_with(|name| std::env::var(name), self.token.as_deref())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure token resolution: the config value first, then
|
||||||
|
/// `CALENDAR_TOKEN`. Parameterized over the env lookup so tests can
|
||||||
|
/// hold the environment still.
|
||||||
|
pub fn resolve_token_with(
|
||||||
|
get_env: impl Fn(&str) -> Result<String, std::env::VarError>,
|
||||||
|
stored: Option<&str>,
|
||||||
|
) -> Option<String> {
|
||||||
|
Self::trimmed(stored).or_else(|| {
|
||||||
|
get_env("CALENDAR_TOKEN")
|
||||||
|
.ok()
|
||||||
|
.and_then(|token| Self::trimmed(Some(&token)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user