plenum-bot/src/main.rs
nobody 12501c8c73 improve debuggability
more Debug-derives, trace/verbose, …
2024-12-10 21:38:35 +01:00

840 lines
32 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::{Datelike, Local, NaiveDate, Weekday};
use clap::{Arg, Command};
use colored::Colorize;
use regex::Regex;
use std::borrow::Cow;
use std::env;
use std::error::Error;
mod key_value;
use key_value::KeyValueStore as KV;
mod config_spec;
use config_spec::{CfgField, CfgGroup, CfgSpec};
mod template;
pub mod variables_and_settings;
mod email;
use email::{Email, SimpleEmail};
mod hedgedoc;
use hedgedoc::HedgeDoc;
mod mediawiki;
use mediawiki::Mediawiki;
mod date;
const FALLBACK_TEMPLATE: &str = variables_and_settings::FALLBACK_TEMPLATE;
/* ***** 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: "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.",
},
],
},
],
};
/* ***** Runtime Configuration (Arguments & Environment Variables) ***** */
/// Checks environment variable `DRY_RUN` to see if any external operations
/// should *actually* be done.
///
/// If `is_dry_run` returns `true`, just report what you *would* do, and
/// don't actually e.g. send emails.
fn is_dry_run() -> bool {
env::var("DRY_RUN").map(|v| !v.is_empty()).unwrap_or(false)
}
/// 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>> {
// 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);
if args.check_mode {
println!(include_str!("chaosknoten.txt"), VERSION = env!("CARGO_PKG_VERSION"));
return config_spec::interactive_check(&CONFIG_SPEC, config);
}
// 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 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();
// figure out where we should be
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)?;
}
}
let last_state = last_state;
// 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();
// 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];
trace_var!(action);
action.get()(delta, &plenum_day, &config, &hedgedoc, &email, &wiki)?;
// TODO: cleanup / write new state
if config.has_errors() {
return Err("There were errors while writing config values.".into());
} else {
return Ok(());
}
// Dienstage diesen Monat
let all_tuesdays: Vec<NaiveDate> = get_all_weekdays(0, Weekday::Tue);
let zweiter_dienstag: String = all_tuesdays[1].to_string(); // z.B. 2024-07-09
let vierter_dienstag: String = all_tuesdays[3].to_string(); // z.B. 2024-07-23
//Dienstage des nächsten Monats definieren
let all_tuesdays_next_month: Vec<NaiveDate> = get_all_weekdays(1, Weekday::Tue);
let zweiter_dienstag_nächster_monat: String = all_tuesdays_next_month[1].to_string();
let vierter_dienstag_nächster_monat: String = all_tuesdays_next_month[3].to_string();
// Daten, die später benutzt werden, definieren
let today = Local::now();
let today_simple = today.date_naive();
let yesterday = today_simple.pred_opt().unwrap();
let in_1_day = today_simple.succ_opt().unwrap();
let in_2_days = in_1_day.succ_opt().unwrap();
let in_3_days = in_2_days.succ_opt().unwrap();
// Nächste Plena nachschauen:
let nächster_plenumtermin: &String = if all_tuesdays[1] >= yesterday {
// Für das Pad rumschicken am nächsten Tag wird das Datum einen Tag nach Hinten gesetzt,
&zweiter_dienstag
} else {
&vierter_dienstag
};
let übernächster_plenumtermin = if all_tuesdays[1] >= yesterday {
// hier das Gleiche.
&vierter_dienstag
} else {
&zweiter_dienstag_nächster_monat
};
let überübernächster_plenumtermin = if all_tuesdays[1] >= yesterday {
// hier das Gleiche.
&zweiter_dienstag_nächster_monat
} else {
&vierter_dienstag_nächster_monat
};
// Der Code muss nur für vor dem 2. und vor dem 4. Dienstag gebaut werden, weil im nächsten Monat der Code frühestens 7 Tage vor dem Plenum wieder passt.
let in_1_day_is_plenum: bool = check_if_plenum(nächster_plenumtermin.clone(), in_1_day);
let in_3_days_is_plenum: bool = check_if_plenum(nächster_plenumtermin.clone(), in_3_days);
let yesterday_was_plenum: bool = check_if_plenum(nächster_plenumtermin.clone(), yesterday);
// Pad-Links aus der Datenbank laden:
let current_pad_id = config
.get("hedgedoc-last-id")
.expect("ID des aktuellen Pads undefiniert. Bitte in der DB eintragen oder generieren.");
let future_pad_id = config
.get("hedgedoc-next-id")
.expect("ID des nächsten Pads undefiniert. Bitte in der DB eintragen oder generieren.");
let mut message_id: Option<String> = None;
// let in_3_days_is_plenum = true;
//TEMPORÄR ANFANG: BEI PRODUCTION MUSS DAS HIER RAUS
let top_anzahl: i32 = 0; // Muss noch gecodet werden
let yesterday_was_plenum = true; // Das ist nur zu Testzwecken, kommt noch weg
let auth_result = wiki.get_login_token()?;
println!("---AUTH RESULT:---\n{}\n-----------", auth_result);
// TEMPORÄR ENDE
// AAAAAAAAAA
let pad_content = hedgedoc.download(&current_pad_id).expect("Fehler beim Download des Pads!");
let pad_content_without_top_instructions = try_to_remove_top_instructions(pad_content);
println!("Pad-content geladen!");
let current_pad_link = hedgedoc.format_url(&current_pad_id);
let future_pad_link = hedgedoc.format_url(&future_pad_id);
if in_1_day_is_plenum {
println!("In 1 Tag ist Plenum, deshalb wird eine Erinnerung raus geschickt!");
let tldr_vec = create_tldr(&pad_content_without_top_instructions);
let mut tldr = String::new();
for element in tldr_vec {
tldr.push_str("\n");
tldr.push_str(&element)
}
if number_of_tops(&pad_content_without_top_instructions) != 0 {
// Mail an alle senden, findet statt
let betreff = "Morgen ist Plenum!".to_string(); // ADJ_TIMEYWIMEY
let message: String = format!(
r#"Es gibt Themen, deshalb wird das morgige Plenum statt finden. Anbei das Plenumspad:
{current_pad_link}
Und hier ein TL;DR von den aktuellen Themen:
{tldr}
Bis morgen, 20 Uhr!"#
); // ADJ_TIMEYWIMEY
message_id = send_email(&betreff, &message, &email, &config);
// .expect("Plenum findet statt. Mail wurde versucht zu senden, konnte aber nicht gesendet werden!")
} else {
// Mail an alle senden und absagen
let betreff = format!("Plenum am {} fällt mangels Themen aus", nächster_plenumtermin);
let message: String = format!(
r#"Es gibt keine Themen, deshalb wird das morgige Plenum leider nicht statt finden.
Hier ist der Link zum Pad vom nächsten Plenum, das am {} statt finden wird:
{future_pad_link}
Bis zum nächsten Plenum."#,
nächster_plenumtermin
);
message_id = send_email(&betreff, &message, &email, &config);
// .expect("Plenum wird abgesagt. Mail wurde versucht zu senden, konnte aber nicht gesendet werden!"))
}
} else if in_3_days_is_plenum {
println!("In 3 Tagen ist Plenum, deshalb wird eine Erinnerung raus geschickt!");
if number_of_tops(&pad_content_without_top_instructions) == 0 {
// Mail an alle senden und sagen, dass es noch keine Themen gibt
let betreff = format!("Plenum vom {}: Bisher noch keine Themen", nächster_plenumtermin);
let message: String = format!(
r#"Es sind bisher leider keine Themen zusammengekommen. Wenn es bis Sonntag Abend keine Themen gibt, wird das Plenum voraussichtlich nicht statt finden.
Hier ist der Link zum Pad, wo ihr noch Themen eintragen könnt:
{current_pad_link}"#
);
message_id = send_email(&betreff, &message, &email, &config);
// .expect("Noch nicht genug Themen. Mail wurde versucht zu senden, konnte aber nicht gesendet werden!"))
}
} else if yesterday_was_plenum {
// This logic breaks on 02/2034, but on every other month it works
let old_pad_content =
hedgedoc.download(&current_pad_id).expect("Fehler beim Hedgedoc-Pad-Download!");
// MUSS WIEDER REIN NACH DEM TESTEN: generate_new_pad_for_following_date(übernächster_plenumtermin, überübernächster_plenumtermin, &config).await.expect("Fehler! Plenumspad konnte nicht generiert werden!");
println!(
"DATENBANK: aktuelles-plenumspad: {:?} und zukünftiges plenumspad: {:?}",
&config.get("hedgedoc-last-id"),
&config.get("hedgedoc-next-id")
);
let old_pad_content_without_top_instructions =
try_to_remove_top_instructions(old_pad_content);
let tldr_vec = create_tldr(&old_pad_content_without_top_instructions);
let tldr = tldr_vec.join("\n");
// XXX nächstes/übernächstes?
let message: String = format!(
r#"Anbei das gestrige Plenumspad. Hier sind die Links zum nächsten:
{current_pad_link}
und zum übernächsten Plenum:
{future_pad_link}
TL;DR:
{tldr}
Und hier ist das Protokoll des letzten Plenums:
{old_pad_content_without_top_instructions}"#
);
let betreff: String =
format!("Plenumsprotokoll vom {}: Es gab {} TOPs", nächster_plenumtermin, top_anzahl);
// XXX option x expect
message_id = send_email(&betreff, &message, &email, &config);
// .expect("Mail mit Plenumsprotokoll wurde versucht zu senden, konnte aber nicht gesendet werden!"));
mediawiki::pad_ins_wiki(old_pad_content_without_top_instructions);
}
println!("message id: {:?}", message_id);
println!("[ENDE]\n{}", "Aktueller Zustand der DB:".bold());
config
.dump_redacting(&[
"email-password",
"wiki-http-password",
"wiki-api-secret",
"matrix-password",
])
.ok();
}
fn get_all_weekdays(month_offset: i32, week_day: Weekday) -> Vec<NaiveDate> {
let date = Local::now().date_naive();
let date = match month_offset.signum() {
0 => Some(date),
1 => date.checked_add_months(chrono::Months::new(month_offset as u32)),
-1 => date.checked_sub_months(chrono::Months::new((-month_offset) as u32)),
_ => unreachable!(),
}
.expect("(very) invalid month offset");
let month = date.month();
let mut current_date = NaiveDate::from_ymd_opt(date.year(), date.month(), 1);
let mut dates = Vec::new();
while let Some(date) = current_date {
if date.month() == month && date.weekday() == week_day {
dates.push(date);
}
current_date = date.succ_opt();
}
dates
}
fn check_if_plenum(infrage_kommendes_plenum: String, date_to_check: NaiveDate) -> bool {
// Überprüfen, ob an dem Datum Plenum ist
let date_to_check = date_to_check.to_string();
return if infrage_kommendes_plenum == date_to_check { true } else { false };
}
fn number_of_tops(pad_content: &String) -> i32 {
// Logik: Wenn irgendwo TOP 2, top2, Top2 oder TOP2 steht, dann gibt es Themen
let re = Regex::new(r"^##+ *([Tt][Oo][Pp] +\d[.\d]*.*)$").unwrap();
let m = re.find(&pad_content);
m.iter().len() as i32
}
fn create_tldr(pad_content: &String) -> Vec<&str> {
// Logik: Wenn irgendwo TOP 2, top2, Top2 oder TOP2 steht, dann gibt es Themen
let re_top = Regex::new(r"^##+ *([Tt][Oo][Pp] +\d[.\d]*.*)$").unwrap();
let tldr: Vec<&str> = re_top.find_iter(&pad_content).map(|m| m.as_str()).collect();
//println!("{:?}", m);
tldr
//tldr_vec.append() = m.iter()
}
fn generate_new_pad_for_following_date(
config: KV, hedgedoc: HedgeDoc, übernächster_plenumtermin: &String,
überübernächster_plenumtermin: &String, 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(|_| FALLBACK_TEMPLATE.to_string()),
Err(_) => FALLBACK_TEMPLATE.to_string(),
};
// 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_else(|error| 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 next_plenum_pad == None {
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("aktuelles-plenumspad", &next_plenum_pad.unwrap())
.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"))
}
}
fn try_to_remove_top_instructions(pad_content: String) -> String {
let re_top_instructions: Regex = Regex::new(r"(<!--(?:.||\n)*-->)").unwrap();
let result: Cow<str> = re_top_instructions.replace_all(&pad_content, "---");
result.to_string() // Wenn es nicht geklappt hat, wird einfach das Pad mit dem Kommentar zurückgegeben
}
/* ***** 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(),
}
}
/* ***** transition actions ***** */
// BBBBBBBBBB
fn do_announcement(
ttp: i64, plenum_day: &NaiveDate, config: &KV, hedgedoc: &HedgeDoc, email: &SimpleEmail,
wiki: &Mediawiki,
) -> Result<(), Box<dyn Error>> {
// use TTP to adjust text if needed (in {ttp} days)
todo!()
}
fn do_reminder(
ttp: i64, plenum_day: &NaiveDate, config: &KV, hedgedoc: &HedgeDoc, email: &SimpleEmail,
wiki: &Mediawiki,
) -> Result<(), Box<dyn Error>> {
// use TTP to adjust text if needed (tomorrow or today / in {ttp} days)
todo!()
}
fn do_protocol(
ttp: i64, plenum_day: &NaiveDate, config: &KV, hedgedoc: &HedgeDoc, email: &SimpleEmail,
wiki: &Mediawiki,
) -> Result<(), Box<dyn Error>> {
// use TTP to adjust text if needed ({-ttp} days ago)
todo!()
}
/// General cleanup function. Call as `(999, today, …)` for a complete reset
/// based on today as the base date.
fn do_cleanup(
ttp: i64, plenum_day: &NaiveDate, config: &KV, hedgedoc: &HedgeDoc, email: &SimpleEmail,
wiki: &Mediawiki,
) -> Result<(), Box<dyn Error>> {
todo!()
}
/* ***** 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] = [
/* 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,
}
}
}
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 {
let str = match self {
ProgramState::Normal => "Normal",
ProgramState::Announced => "Announced",
ProgramState::Reminded => "Reminded",
ProgramState::Waiting => "Waiting",
ProgramState::Logged => "Logged",
};
write!(f, "{}", str)
}
}
/* ***** wrappers ***** */
fn send_email(subject: &str, body: &str, email: &SimpleEmail, config: &KV) -> Option<String> {
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).ok()
}