// Dies ist der Plenumsbot vom Chaos Computer Club Berlin. Mehr Infos zum aktuellen Stand des Projektss in der ReadMe. use std::env; use std::error::Error; use std::time::Instant; use chrono::{Local, NaiveDate}; use clap::{Arg, Command}; use colored::Colorize; use regex::Regex; 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::{verboseln,trace_var,trace_var_}; 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\n\n\ # Plenum vom {{datum}}\n**Beginn 20:XX**\n\n## Themen\n\n[toc]\n\n## Anwesend\nX\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) ***** */ /// 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::("check").unwrap(), config_file: matches.get_one::("config_file").unwrap().clone(), } } /* ***** Main ***** */ fn main() -> Result<(), Box> { let start = Instant::now(); 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 mut wiki = Mediawiki::new( &config["wiki-server-url"], &config["wiki-http-user"], &config["wiki-http-password"], &config["wiki-api-user"], &config["wiki-api-secret"], is_dry_run(), &config["wiki-plenum-page"], ); 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, &mut 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 = 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, &mut wiki)?; // shutdown config.set("state-last-run", &today.to_string())?; let duration = format!("{:?}", start.elapsed()); config.set("state-eta", &duration).ok(); verboseln!("Der Bot ist fertig und hat für die Ausführung {} gebraucht.", duration); 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> { 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> { 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::() + 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: &mut Mediawiki, ) -> Result<(), Box> { NYI!("trace/verbose annotations"); // 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: &mut Mediawiki, ) -> Result<(), Box> { NYI!("trace/verbose annotations"); // 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: &mut Mediawiki, ) -> Result<(), Box> { NYI!("trace/verbose annotations"); 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}", "" ); let _message_id = send_email(&subject, &body, email, config)?; NYI!("convert to mediawiki"); mediawiki::pad_ins_wiki(pad_content, wiki)?; 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: &mut Mediawiki, ) -> Result<(), Box> { NYI!("trace/verbose annotations"); 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: &mut Mediawiki, ) -> Result<(), Box>; #[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, _: &mut Mediawiki, ) -> Result<(), Box> { Ok(()) } fn do_clean_announcement( ttp: i64, plenum_day: &NaiveDate, config: &KV, hedgedoc: &HedgeDoc, email: &SimpleEmail, wiki: &mut Mediawiki, ) -> Result<(), Box> { 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: &mut Mediawiki, ) -> Result<(), Box> { 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> { 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) } fn display_logo(eta: &str) { let ansi_art_pt1 = r#"     ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄  Plenum! Ple-e-e-num!  █ ▂▂▂▂▂▂▂▂▂ █  Plenum ist wichtig für die Revolution!  █ ▞ ▚ ▌ ▚ █   █ ▌ ▚🬯🮗🮗🮗🮗🮗🮗▚▚▚ █  Dies ist der CCCB Plenumsbot  █ ▌ ▞🬥🮗🮗🮗🮗🮗🮗▜▞▞▖ █   █ ▚ ▞ ▌ ▞ 🬂🬤▞▚🬭 █  Version {VERSION}  █ 🬂🬂🬂🬂🬂🬂🬂🬂🬂 ▞▐▐ █   █ 🬔🬈 🬔🬈 🬔🬈 🬴🬗 🬭🬫🬀 █  ETA.:"#; let ansi_art_pt2 = r#"  █ 🬣🬖 🬣🬖 🬣🬖 🬲🬘 ▚ █   █▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄█    ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ "#; let ansi_art = format!("{ansi_art_pt1}{eta}{ansi_art_pt2}"); println!("{}", ansi_art ); }