747 lines
29 KiB
Rust
747 lines
29 KiB
Rust
// 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(¤t_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(¤t_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)
|
||
}
|