plenum-bot/src/main.rs
2024-12-10 21:38:35 +01:00

747 lines
29 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Dies ist der Plenumsbot vom Chaos Computer Club Berlin.
/*
Plenumsbot
Ankündigungsskript
• Schauen, wann ein Plenum stattfindet
• Wenn eins in 3 Tagen stattfindet, nächstes Pad kopieren und per Mail schicken
→ Pad als .md herunterladen
→ Text dranhängen und per mail an intern@ verschicken
• Wenn 1 Tag vor dem Plenum immer noch kein TOP im Pad ist
→ Mail mit Absage verschicken
Neues-Pad-Erstellen-Skript
• Schauen, wann Plenum stattfindet
• Wenn eins im nächsten Monat stattfindet, von dem noch kein Pad erstellt wurde, dann eins hinzufügen
Pad-ins-Wiki-und-versenden-Skript
• Skript wird manuell nach dem Plenum ausgelöst
• Plenumspad wird als .md heruntergeladen
• Text dranhängen und per Mail an intern@ verschicken
• Pad in MediaWiki-Format umwandeln
• Neue Wiki-Seite erstellen und dort das umgewandelte Pad hochladen
*/
/* *TO-DO LIST*
main.rs
- [ ] Add logic for top_anzahl in main.rs:172
MediaWiki
- [ ] Add "get_csrf_token-function" for getting a write token to allow write operations in the wiki (murmeldin)
- [ ] Add "create_page" function for creating new pages in the wiki that is called on every day after plenum (murmeldin)
- [ ] Add "modify_plenum_main_page" function for creating new Links on the Plenum main page whenever the create_page function is being called (murmeldin)
future improvements:
- search ADJ_TIMEYWIMEY to find places that need adjusting if the bot might run late
(that's an incomplete list, but tag things as you notice them…)
*/
use chrono::{Local, NaiveDate};
use clap::{Arg, Command};
use colored::Colorize;
use regex::Regex;
use std::env;
use std::error::Error;
use std::fmt::Display;
use std::io::IsTerminal;
use cccron_lib::config_spec::{self, CfgField, CfgGroup, CfgSpec};
use cccron_lib::key_value::KeyValueStore as KV;
use cccron_lib::date;
use cccron_lib::email::{self, Email, SimpleEmail};
use cccron_lib::hedgedoc::{self, HedgeDoc};
use cccron_lib::is_dry_run;
use cccron_lib::mediawiki::{self, Mediawiki};
use cccron_lib::NYI;
/* ***** Config Spec ***** */
const CONFIG_SPEC: CfgSpec<'static> = CfgSpec {
groups: &[
CfgGroup { name: "config",
description: "(internal values)",
fields: &[
CfgField::Silent { key: "version", default: "1" },
],
},
CfgGroup { name: "state",
description: "varying state info that needs to be kept across invocations",
fields: &[
CfgField::Default { key: "name",
default: "NORMAL",
description: "Named state of the program logic. (NORMAL, ANNOUNCED, REMINDED, LOGGED)",
},
CfgField::Optional { key: "toc", description: "Table of contents / pad summary." },
CfgField::Optional { key: "last-run",
description: "Last run of the program (used to figure out state of previous plenum mails.)",
},
]
},
CfgGroup { name: "date",
description: "specification of relevant dates",
fields: &[
CfgField::Default { key: "spec",
default: "(2,Tue),(4,Tue)",
description: "Days on which the plenum happens.",
},
],
},
hedgedoc::CONFIG,
mediawiki::CONFIG,
email::CONFIG,
// TODO: Matrix, …?
CfgGroup { name: "text",
description: "Various strings used.",
fields: &[
CfgField::Default { key: "email-greeting",
default: "Hallo liebe Mitreisende,",
description: "\"Hello\"-greeting added at the start of every email.",
},
CfgField::Default { key: "email-signature",
default: "\n\n[Diese Nachricht wurde automatisiert vom Plenumsbot erstellt und ist daher ohne Unterschrift gültig.]",
description: "Text added at the bottom of every email.",
},
CfgField::Default { key: "fallback-template",
default: "---\ndate: \"{{datum-iso}}\"\n...\n<!-- bitte oberhalb nichts verändern -->\n\n\
# Plenum vom {{datum}}\n**Beginn 20:XX**\n\n## Themen\n\n[toc]\n\n## Anwesend\nX\n\n\
<!--\nDamit Themen richtig erkannt und verarbeitet werden, bitte folgendes Format \
einhalten: 2+ Rauten, \"TOP \", Nummer, also z.B:\n\n\
## TOP 1: Der Vorstand berichtet\n\n### TOP 1.1: Auf der west.berlin nichts neues\n\n\
-->\n\n\
\n\n**Ende 2X:XX**\n\nNächstes Plenum: Am {{naechstes-plenum}} um 20 Uhr",
description: "Pad template used in case hedgedoc isn't reachable, so that we can write a new pad to the hedgedoc instance that we couldn't reach."
},
],
},
],
};
/* ***** Runtime Configuration (Arguments & Environment Variables) ***** */
/// Checks environment variable `VERBOSE` to see if status messages should be
/// printed.
///
/// Use `verboseln!` to print stuff only if `VERBOSE` (or `TRACE`) is set.
fn is_verbose() -> bool {
env::var("VERBOSE").map(|v| !v.is_empty()).unwrap_or(false)
}
/// Like `println!`, but only if `is_verbose` (or `is_trace`) is true (due to
/// the environment variable `VERBOSE` being set.)
macro_rules! verboseln {
($($arg:tt)*) => {
if is_verbose() || is_trace() {
println!($($arg)*);
}
};
}
/// (A. k. a. "*very* verbose".) Checks environment variable `TRACE` to see if
/// detailed small-step status messages should be printed.
///
/// Use `traceln!` to print stuff only if `TRACE` is set.
fn is_trace() -> bool {
env::var("TRACE").map(|v| !v.is_empty()).unwrap_or(false)
}
/// Like `println!`, but only if `is_trace` is true (due to the environment
/// variable `TRACE` being set.)
macro_rules! traceln {
($($arg:tt)*) => {
if is_trace() {
println!( "{}", format!($($arg)*).yellow() );
}
};
}
/// `trace_var!( [msg,] var )` prints either `varname = value` or `msg: value`
/// *if TRACE is set* (else is silent.)
///
/// There's an alternative form of `trace_var!( [msg,] var[, true] )` or the
/// preferred form of `trace_var_!( [msg,] var )` (i.e. just add an underscore
/// to the name), which will use the "pretty" form.
macro_rules! trace_var {
($var:expr, $pretty:expr) => {
if is_trace() {
if $pretty {
println!("{} = {}", stringify!($var).green(), format!("{:#?}", $var).cyan());
} else {
println!("{} = {}", stringify!($var).green(), format!("{:?}", $var).cyan());
}
}
};
($msg:expr, $var:expr, $pretty:expr) => {
if is_trace() {
if $pretty {
println!("{}: {}", $msg.green(), format!("{:#?}", $var).cyan());
} else {
println!("{}: {}", $msg.green(), format!("{:?}", $var).cyan());
}
}
};
($var:expr) => {
trace_var!($var, false);
};
($msg:expr, $var:expr) => {
trace_var!($msg, $var, false);
};
}
/// Pretty form of `trace_var!`
macro_rules! trace_var_ {
($var:expr) => {
trace_var!($var, true);
};
($msg:expr, $var:expr) => {
trace_var!($msg, $var, true);
};
}
/// Gets either today or the date from the environment variable `TODAY` (for
/// testing purposes.)
fn today() -> NaiveDate {
env::var("TODAY")
.map(|v| NaiveDate::parse_from_str(&v, "%F").expect("'TODAY' hat nicht format YYYY-MM-DD"))
.unwrap_or(Local::now().date_naive())
}
#[derive(Debug)]
struct Args {
check_mode: bool,
config_file: String,
}
fn parse_args() -> Args {
let matches = Command::new("Plenum-Bot")
.about("CCCB Plenumsbot")
.arg(
Arg::new("check")
.short('c')
.long("check")
.action(clap::ArgAction::SetTrue)
.help("interactively check the config for missing values"),
)
.arg(
Arg::new("config_file")
.short('f')
.long("config-file")
.value_name("FILE")
.action(clap::ArgAction::Set)
.default_value("config.sqlite")
.help("specifies an alternate config file"),
)
.get_matches();
Args {
check_mode: *matches.get_one::<bool>("check").unwrap(),
config_file: matches.get_one::<String>("config_file").unwrap().clone(),
}
}
/* ***** Main ***** */
fn main() -> Result<(), Box<dyn Error>> {
if std::io::stdout().is_terminal() {
println!(include_str!("chaosknoten.txt"), VERSION = env!("CARGO_PKG_VERSION"));
}
// set up config file access
let args = parse_args();
trace_var!(args);
let config_file = args.config_file.as_str();
verboseln!("Using config file {}.", config_file.cyan());
let config = KV::new(config_file).unwrap();
config_spec::populate_defaults(&CONFIG_SPEC, &config);
// select mode
if args.check_mode {
return config_spec::interactive_check(&CONFIG_SPEC, config);
}
// ensure existence of this is checked early
let _current_pad_id = config
.get("hedgedoc-last-id")
.expect("ID des aktuellen Pads undefiniert. Bitte in der DB eintragen oder generieren.");
trace_var!(_current_pad_id);
// get config
let hedgedoc = HedgeDoc::new(&config["hedgedoc-server-url"], is_dry_run());
trace_var!(hedgedoc);
let email_ = Email::new(
&config["email-server"],
&config["email-user"],
&config["email-password"],
is_dry_run(),
);
let mut email = SimpleEmail::new(
email_,
&config["email-from"],
&config["email-to"],
config.get("email-in-reply-to").ok(),
);
trace_var_!(email);
let wiki = Mediawiki::new(
&config["wiki-server-url"],
&config["wiki-http-user"],
&config["wiki-http-password"],
is_dry_run(),
);
trace_var_!(wiki);
// get next plenum days
let today = today();
verboseln!("Heute ist {}", today.to_string().cyan());
let plenum_spec = date::parse_spec(&config["date-spec"])?;
trace_var!(plenum_spec);
let nearest_plenum_days = date::get_matching_dates_around(today, plenum_spec);
trace_var!(nearest_plenum_days);
// figure out where we are
let mut last_state = ProgramState::parse(&config["state-name"]);
let last_run = config.get("state-last-run").unwrap_or_default();
let last_run = NaiveDate::parse_from_str(&last_run, "%Y-%m-%d").unwrap_or_default();
trace_var!(last_run);
// reset state if this hasn't been run for too long
if (today - last_run).num_days() > 10 {
if !matches!(last_state, ProgramState::Normal) {
eprintln!("WARNING: last run was a long time ago, resetting state.");
last_state = ProgramState::Normal;
do_cleanup(999, &today, &config, &hedgedoc, &email, &wiki)?;
// reset will have cleared in-reply-to if it existed
let (base, from, to, _) = email.into_parts();
email = SimpleEmail::from_parts(base, from, to, config.get("email-in-reply-to").ok());
}
}
let email = email;
let last_state = last_state;
// figure out where we should be
// deltas has either 2 or 3 days, if 3 then the middle one is == 0 (i.e. today)
let deltas: Vec<i64> = nearest_plenum_days.iter().map(|&d| (d - today).num_days()).collect();
trace_var!(deltas);
// find the relevant one:
let delta = *match last_state {
// In normal state, the past doesn't matter just pick the next occurrence.
ProgramState::Normal | ProgramState::Logged => deltas.last(),
// If announced or reminded, a *recent* past event may be relevant.
ProgramState::Announced | ProgramState::Reminded | ProgramState::Waiting => {
let candidate = deltas.get(deltas.len() - 2);
if candidate.is_some_and(|&i| i >= -7) {
candidate
} else {
deltas.last()
}
},
}
.unwrap(); // always has at least 2 elems
let plenum_day = today.checked_add_signed(chrono::TimeDelta::days(delta)).unwrap();
verboseln!(
"Relevantes Plenum ist am {} ({})",
plenum_day.to_string().cyan(),
relative_date(delta).cyan()
);
let intended_state = if delta > 3 {
ProgramState::Normal // nothing to do 3+ days in advance
} else if delta > 1 {
ProgramState::Announced // 2+ days in advance we want to have it announced
} else if delta >= 0 {
ProgramState::Reminded // up to the day of, we want to send a reminder (or cancel)
} else if delta >= -1 {
ProgramState::Waiting // we will wait a day for the protocol to be cleaned up
} else {
ProgramState::Logged // after that, we want to log it to the list & the wiki
};
verboseln!("Aktueller Zustand: {}", last_state.to_string().cyan());
verboseln!("Soll-Zustand: {}", intended_state.to_string().cyan());
let action: &ST = &TRANSITION_LUT[last_state as usize][intended_state as usize];
verboseln!("Notewendige Aktionen: {}", action.to_string().cyan());
// run action, which is responsible for updating the state as needed
action.get()(delta, &plenum_day, &config, &hedgedoc, &email, &wiki)?;
// shutdown
config.set("state-last-run", &today.to_string())?;
if config.has_errors() {
Err("There were errors while writing config values.".into())
} else {
Ok(())
}
}
fn generate_new_pad_for_following_date(
config: KV, hedgedoc: HedgeDoc, übernächster_plenumtermin: &str,
überübernächster_plenumtermin: &str, kv: &KV,
) -> Result<(), Box<dyn Error>> {
match hedgedoc.create_pad() {
Err(e) => println!("Failed to create pad: {}", e),
Ok(pad_id) => {
println!("Pad created successfully with ID: {}", pad_id);
// Get the most recent plenum template and replace the placeholders:
let template_content: String = match config.get("hedgedoc-template-name") {
Ok(content) => hedgedoc
.download(&content.clone())
.unwrap_or_else(|_| config.get("text-fallback-template").unwrap_or_default()),
Err(_) => config.get("text-fallback-template").unwrap_or_default(),
};
// XXX you don't just use the template as-is…
let template_modified: String = replace_placeholders(
&template_content,
übernächster_plenumtermin,
überübernächster_plenumtermin,
)
.unwrap_or(template_content); // Try regex, if not successful use without regex
match hedgedoc.import_note(Some(&pad_id), template_modified) {
Ok(_) => {
println!("Pad updated successfully with template content.");
rotate(&pad_id, kv);
},
Err(e) => println!("Failed to update pad: {}", e),
}
},
}
Ok(())
}
fn replace_placeholders(
template: &str, übernächster_plenumtermin: &str, überübernächster_plenumtermin: &str,
) -> Result<String, Box<dyn Error>> {
let re_datum = Regex::new(r"\{\{Datum\}\}")?;
let result = re_datum.replace_all(template, übernächster_plenumtermin);
let re_naechstes_plenum = Regex::new(r"\{\{naechstes-plenum\}\}")?;
let result = re_naechstes_plenum.replace_all(&result, überübernächster_plenumtermin);
Ok(result.to_string())
}
fn rotate(future_pad_id: &str, kv: &KV) {
let next_plenum_pad = kv.get("zukünftiges-plenumspad").ok();
if let Some(next_plenum_pad) = next_plenum_pad {
kv.set("aktuelles-plenumspad", &next_plenum_pad)
.expect("Fehler beim Beschreiben der Datenbank mit neuem Plenumslink!"); // Beispiel: aktuelles-plenumspad: Ok(Some("eCH24zXGS9S8Stg5xI3aRg"))
kv.set("zukünftiges-plenumspad", future_pad_id)
.expect("Fehler beim Beschreiben der Datenbank mit neuem Plenumslink!");
// Beispiel: aktuelles-plenumspad: Ok(Some("eCH24zXGS9S8Stg5xI3aRg"))
} else {
kv.set("zukünftiges-plenumspad", future_pad_id)
.expect("Fehler beim Beschreiben der Datenbank mit neuem Plenumslink!");
// Beispiel: aktuelles-plenumspad: Ok(Some("eCH24zXGS9S8Stg5xI3aRg"))
}
}
/* ***** formatting helpers ***** */
fn relative_date(ttp: i64) -> String {
if ttp.abs() > 2 {
if ttp.is_negative() {
format!("vor {} Tagen", -ttp)
} else {
format!("in {} Tagen", ttp)
}
} else {
match ttp {
2 => "übermorgen",
1 => "morgen",
0 => "heute",
-1 => "gestern",
-2 => "vorgestern",
_ => unreachable!(),
}
.to_string()
}
}
fn upper_first(s: &str) -> String {
let mut c = s.chars();
match c.next() {
Some(fst) => fst.to_uppercase().collect::<String>() + c.as_str(),
None => String::new(),
}
}
fn topic_count(n: usize, dative: bool) -> String {
match n {
0 => format!("noch keine{} Themen", if dative { "n" } else { "" }),
1 => format!("ein{} Thema", if dative { "em" } else { "" }),
_ => format!("{} Themen", n),
}
}
/* ***** repeating action parts ***** */
fn get_pad_info(config: &KV, hedgedoc: &HedgeDoc) -> (String, String, String, usize) {
let current_pad_id = &config["hedgedoc-last-id"];
let pad_content = hedgedoc.download(current_pad_id).expect("Hedgedoc: Download-Fehler");
let toc = hedgedoc::summarize(pad_content.clone());
verboseln!("Zusammenfassung des aktuellen Plenum-Pads:\n{}", toc.cyan());
let n_topics = toc.lines().count();
verboseln!("(Also {}.)", topic_count(n_topics, false).cyan());
(current_pad_id.to_string(), pad_content, toc, n_topics)
}
/* ***** transition actions ***** */
fn do_announcement(
ttp: i64, plenum_day: &NaiveDate, config: &KV, hedgedoc: &HedgeDoc, email: &SimpleEmail,
_wiki: &Mediawiki,
) -> Result<(), Box<dyn Error>> {
// fetch current pad contents & summarize
let (current_pad_id, _pad_content, toc, n_topics) = get_pad_info(config, hedgedoc);
// construct email
let subject = format!(
"Plenum {} (am {}): bisher {}",
relative_date(ttp),
plenum_day.format("%d.%m.%Y"),
topic_count(n_topics, false)
);
let line1 = if n_topics == 0 {
"Es sind bisher leider noch keine Themen zusammengekommen. Wenn am Montag immer noch nix ist, dann fällt das Plenum aus.".to_string()
} else {
format!("Die bisherigen Themen für das Plenum sind:\n\n{toc}")
};
let line2 = format!(
"Falls ihr noch Themen ergänzen wollt ist hier der Link zum Pad:\n {}",
hedgedoc.format_url(&current_pad_id)
);
let body = format!("{line1}\n\n{line2}");
// send it
let message_id = send_email(&subject, &body, email, config)?;
// on success, update state (ignore write errors, they'll be checked later)
config.set("email-message-id", &message_id).ok();
config.set("state-name", &ProgramState::Announced.to_string()).ok();
config.set("state-toc", &toc).ok();
Ok(())
}
fn do_reminder(
ttp: i64, plenum_day: &NaiveDate, config: &KV, hedgedoc: &HedgeDoc, email: &SimpleEmail,
_wiki: &Mediawiki,
) -> Result<(), Box<dyn Error>> {
// fetch current pad contents & summarize
let (current_pad_id, _pad_content, toc, n_topics) = get_pad_info(config, hedgedoc);
let old_toc = config.get("state-toc").unwrap_or_default();
// construct email
let human_date = plenum_day.format("%d.%m.%Y");
let subject = if n_topics == 0 {
format!("Plenum {} (am {}) fällt aus (keine Themen)", relative_date(ttp), human_date)
} else {
format!(
"{} ist Plenum mit {}!",
upper_first(&relative_date(ttp)),
topic_count(n_topics, true)
)
};
let body_prefix = if old_toc == toc {
format!("Die Themen sind gleich geblieben. ")
} else if old_toc.is_empty() {
format!("Es gibt Themen, die aktuelle Liste ist:\n\n{toc}\n\n")
} else {
format!("Es gab nochmal Änderungen, die aktualisierten Themen für das Plenum sind:\n\n{toc}\n\n")
};
let body = if n_topics > 0 {
format!(
"{body_prefix}Die vollen Details findet ihr im Pad:\n {}\n\nBis {} um 20:00!",
hedgedoc.format_url(&current_pad_id),
relative_date(ttp)
)
} else {
NYI!("generate link / pad for next plenum & include in this email");
"Da es immer noch keine Themen gibt fällt das Plenum aus.\n\n\
(Natürlich könnt ihr im Bedarfsfall immer noch kurzfristig ein Treffen einberufen, aber \
bitte kündigt das so früh wie möglich an, damit insbesondere auch Leute mit längeren \
Wegen sich noch darauf einstellen können.)"
.to_string()
};
// send it
let _message_id = send_email(&subject, &body, email, config)?;
// on success, update state (ignore write errors, they'll be checked later)
if n_topics == 0 {
NYI!(
"do we skip ahead to ProgramState::Logged here or do we later add a note to the wiki?"
);
}
config.set("state-name", &ProgramState::Reminded.to_string()).ok();
config.set("state-toc", &toc).ok();
Ok(())
}
#[allow(unused_variables)]
fn do_protocol(
ttp: i64, plenum_day: &NaiveDate, config: &KV, hedgedoc: &HedgeDoc, email: &SimpleEmail,
wiki: &Mediawiki,
) -> Result<(), Box<dyn Error>> {
let (current_pad_id, pad_content, toc, n_topics) = get_pad_info(config, hedgedoc);
if !toc.is_empty() {
let human_date = plenum_day.format("%d.%m.%Y");
let pad_content = hedgedoc::strip_metadata(pad_content);
let subject = format!("Protokoll vom Plenum am {human_date}");
NYI!("link for next plenum");
NYI!("replace [toc] with actual table of contents");
let body = format!(
"Anbei das Protokoll vom {human_date}, ab sofort auch im Wiki zu finden.\n\n\
Das Pad für das nächste Plenum ist zu finden unter {}.\n\n-----\n\n{pad_content}",
"<FIXME>"
);
let _message_id = send_email(&subject, &body, email, config)?;
NYI!("convert to mediawiki");
NYI!("add to wiki");
config.set("state-name", &ProgramState::Logged.to_string()).ok();
} else {
NYI!("What do we do in the no topics / no plenum case?");
}
Ok(())
}
/// General cleanup function. Call as `(999, today, …)` for a complete reset
/// based on today as the base date.
#[allow(unused_must_use)]
fn do_cleanup(
_ttp: i64, _plenum_day: &NaiveDate, config: &KV, _hedgedoc: &HedgeDoc, _email: &SimpleEmail,
_wiki: &Mediawiki,
) -> Result<(), Box<dyn Error>> {
config.delete("state-toc");
config.delete("email-last-message-id");
NYI!("rotate pad links");
NYI!("double-check state for leftovers");
config.set("state-name", &ProgramState::Normal.to_string());
Ok(())
}
/* ***** state machine ***** */
type TransitionFunction = fn(
ttp: i64,
plenum_day: &NaiveDate,
config: &KV,
hedgedoc: &HedgeDoc,
email: &SimpleEmail,
wiki: &Mediawiki,
) -> Result<(), Box<dyn Error>>;
#[rustfmt::skip]
const TRANSITION_LUT: [[ST; 5]; 5] = [
/* vvv IS vvv | >>> SHOULD >>> */
/* NORMAL ANNOUNCED REMINDED WAITING LOGGED */
/* NORMAL */ [ST::Nop, ST::DoAnnouncement, ST::DoReminder, ST::Nop, ST::Nop],
/* ANNOUNCED */ [ST::DoCleanup, ST::Nop, ST::DoReminder, ST::Nop, ST::DoProtocol],
/* REMINDED */ [ST::DoCleanup, ST::DoCleanupThenAnnouncement, ST::Nop, ST::Nop, ST::DoProtocol],
/* WAITING */ [ST::DoCleanup, ST::DoCleanupThenAnnouncement, ST::DoCleanupThenReminder, ST::Nop, ST::DoProtocol],
/* LOGGED */ [ST::DoCleanup, ST::DoCleanupThenAnnouncement, ST::DoCleanupThenReminder, ST::Nop, ST::DoCleanup],
];
#[derive(Debug, Default, Clone, Copy)]
enum ST {
#[default]
Nop,
DoAnnouncement,
DoReminder,
DoProtocol,
DoCleanup,
DoCleanupThenAnnouncement,
DoCleanupThenReminder,
}
impl ST {
fn get(&self) -> TransitionFunction {
match self {
ST::Nop => nop,
ST::DoAnnouncement => do_announcement,
ST::DoReminder => do_reminder,
ST::DoProtocol => do_protocol,
ST::DoCleanup => do_cleanup,
ST::DoCleanupThenAnnouncement => do_clean_announcement,
ST::DoCleanupThenReminder => do_clean_reminder,
}
}
}
impl Display for ST {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self)
}
}
fn nop(
_: i64, _: &NaiveDate, _: &KV, _: &HedgeDoc, _: &SimpleEmail, _: &Mediawiki,
) -> Result<(), Box<dyn Error>> {
Ok(())
}
fn do_clean_announcement(
ttp: i64, plenum_day: &NaiveDate, config: &KV, hedgedoc: &HedgeDoc, email: &SimpleEmail,
wiki: &Mediawiki,
) -> Result<(), Box<dyn Error>> {
do_cleanup(ttp, plenum_day, config, hedgedoc, email, wiki)?;
do_announcement(ttp, plenum_day, config, hedgedoc, email, wiki)
}
fn do_clean_reminder(
ttp: i64, plenum_day: &NaiveDate, config: &KV, hedgedoc: &HedgeDoc, email: &SimpleEmail,
wiki: &Mediawiki,
) -> Result<(), Box<dyn Error>> {
do_cleanup(ttp, plenum_day, config, hedgedoc, email, wiki)?;
do_reminder(ttp, plenum_day, config, hedgedoc, email, wiki)
}
/// State machine type for the announcement logic, ensuring we can deal with
/// inconsistent runs (e.g. multiple runs in a day, or skipping days). The
/// states are:
///
/// - Normal (default) in between dates, with nothing to do
/// - Announced the first announcement was sent
/// - Reminded the reminder was sent
/// - Waiting just a day of delay (after it makes sense to send a reminder)
/// - Logged protocol was written to wiki & mailing list
///
/// > The bot knows in which state it is at all times. It knows this because
/// > it knows in which state it isn't. By comparing where it is with where
/// > it isn't, it obtains a difference, or deviation. The program logic uses
/// > deviations to generate corrective commands to drive the bot from a state
/// > where it is to a state where it isn't, and arriving in a state where it
/// > wasn't, it now is. Consequently, the state where it is, is now the state
/// > that it wasn't, and it follows that the state that it was in, is now the
/// > state that it isn't in.
/// >
/// > In the event that the state that it is in is not the state that it wasn't,
/// > the system has acquired a variation, the variation being the difference
/// > between where the bot is, and where it wasn't. If variation is considered
/// > to be a significant factor, it too may be corrected by the program logic.
/// > However, the bot must also know where it was.
/// >
/// > The program logic works as follows. Because a delay has modified some
/// > of the information the bot has obtained, it is not sure just when it is.
/// > However, it is sure when it isn't, within reason, and it knows when it was.
/// > It now subtracts when it should be from when it wasn't, or vice-versa, and
/// > by differentiating this from the algebraic sum of when it shouldn't be,
/// > and when it was, it is able to obtain the deviation and its variation,
/// > which is called error.
#[derive(Default, Debug)]
enum ProgramState {
/// Normal is the default state, with no actions currently outstanding.
#[default]
Normal,
/// There is an upcoming event, and the first announcement has been sent.
Announced,
/// The reminder / second announcement was sent.
Reminded,
/// We're waiting for the protocol to surely be there & cleaned up.
Waiting,
/// (transient) The protocol was submitted.
Logged,
}
impl ProgramState {
/// Read state from a string. Always succeeds by just assuming the default state.
fn parse(name: &str) -> ProgramState {
match name.to_lowercase().as_str() {
"announced" => ProgramState::Announced,
"reminded" => ProgramState::Reminded,
"waiting" => ProgramState::Waiting,
"logged" => ProgramState::Logged,
_ => ProgramState::Normal,
}
}
}
impl std::fmt::Display for ProgramState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self)
}
}
/* ***** wrappers ***** */
fn send_email(
subject: &str, body: &str, email: &SimpleEmail, config: &KV,
) -> Result<String, Box<dyn Error>> {
let full_subject = format!("[PleB] {}", subject);
let full_body = format!(
"{}\n\n{}\n\n{}",
&config["text-email-greeting"], body, &config["text-email-signature"]
);
email.send_email(full_subject, full_body)
}