Ciao a tutti! La discussione dell’altro giorno riguardo a Bot per Lemmy mi ha invogliato a perder un po’ di tempo libero e buttare giù un semplicissimo script in Rust che crea un nuovo post quando viene invocato, seguendo i dati in un file di configurazione.

Anche se si tratta, fondamentalmente, di qualcosa d’incredibilmente semplice l’ho creato in modo da dar esempi pratici a molti degli argomenti che spesso, chi inizia Rust, ha difficoltà a trovare in un insieme coerente.

Per esser chiari questa non è - volutamente - la più efficiente o idiomatica versione del codice per eseguire il compito, ma ho cercato di mantenerla semplice da capire e tendenzialmente idiomatica, evitando tutte quelle forme che potrebbero intimidire o confondere, anche se migliori.

E, soprattutto, questo non è un tutorial. Vuole solo dare una prospettiva ampia.

Dentro ci sono esempi di:

  • Option
  • match
  • Del crate reqwest, usato per inviare una richiesta POST.
  • Come leggere un file di configurazione con toml e serde.
  • Il random in Rust, che non è integrato direttamente nella Standard Library ma per cui bisogna usare il crate rand (o similari).
  • Come ottenere e formattare una data con il crate time. Normalmente ho sempre usato chrono, quindi questa è stata un’esperienza nuova anche per me! haha
  • Diversi modi per interpolare le stringhe.
  • Come attivare per un crate delle feature non default nel proprio cargo.toml
  • panic! AKA Come far crashare il proprio programma! …volutamente!
  • expect non è il male! Ha il suo posto anche all’interno di programmi più solidi e non solo per veloce prototipi.

Ora non voglio venderlo per più di quello che è, ma credo sia una buona base di partenza per qualcuno che è interessato a Rust ma non lo ha mai visto con esempi reali, semplici e non mono argomento. Ho aggiunto anche diversi commenti che spero possano aiutare a comprenderne il flusso.

Quindi, per finire, funziona? Sì! È solido e resiliente? Nope. È una buona base per far qualcosa di meglio se volessi imparare e giocare un po’ con Rust? Credo di sì.

Se volete provarlo e fare dei test, il metodo migliore è usare le istanze di test ufficiali di Lemmy:

Oppure la palestra qui su feddit.it (credo?)

Una buona alternativa sono anche i servizi come https://httpbun.org/ o https://httpbin.org/ (down al momento dei miei test).

FAQ

Perché non su github?

Al momento non ho un account che posso usare per queste cose, mi dispiace!

Ma basta che copio i file?

Beh, sì, ma sarebbe il caso di creare un nuovo progetto con cargo new --bin simplecofeebot e sovrascrivere/modificare i file.

Cosa succede se faccio cargo run?

Che invii una richiesta errata a feddit.it, perché non c’è alcun token jwt nel file di configurazione. Nel caso in cui tu inserisca un token jwt allora - se non modifichi nulla - dovrebbe inviare una nuova discussione nella comunità del Caffé, chiamata “Caffè Italia DD/MM/AAAA” con la data del giorno d’invio, ed un messaggio fra quelli nella body_pool. Quindi meglio non farlo, o si finisce per spammare il caffé. :D

Perché nel Caffè invece di Informatica?!

Leggendo le regole della comunità !informatica@feddit.it mi sembra di capire sia solo incentrata sulle notizie!

Puoi compilarlo per me?

Nope. Non consiglio di usarlo seriamente così come è.

Questo codice è un macello!

Grazie, mi sono impegnato! Se hai notato bug, typo o cose insensate per favore commenta qui sotto!

Licenza???

MIT o Apache 2!


Cargo.toml
[package]
name = "simplecofeebot"
version = "0.1.0"
edition = "2021"
publish = false
license = "MIT OR Apache-2.0"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
toml = "0.7.6"
reqwest = { version = "0.11.18", features = ["blocking", "json"] }
time = { version = "0.3.23", features = ["std", "local-offset", "formatting"] }
serde = { version = "1.0.174", features = ["derive"] }
serde_json = "1.0.103"
rand = "0.8.5"
/simplecofeebot.toml
[instance]
url = "https://feddit.it"
# curl --get -d "name=caffeitalia" https://feddit.it/api/v3/community
community_id = 11869

[authentication]
# curl --header "Content-Type: application/json" --request POST --data '{"username_or_email":"UsernameFigo","password":"PasswordComplicata"}' https://feddit.it/api/v3/user/login
session_token = ""

[text]
body_pool = [
    "Salve!",
    "Che piacere vederla, commendatore, il solito?",
    "Un bel caffettino?",
]
# title_pool = ["hmhm", "oh no"]
src/main.rs
// In poche parole questi sono gli import.
use std::{fs::File, io::Read, path::Path};

use rand::{rngs::ThreadRng, Rng};
use serde::Deserialize;
use serde_json::json;
use time::{format_description, OffsetDateTime};

// Creiamo una serie di Struct per contenere i vari
// dati presenti nel file di configurazione.
// derive è una macro procedurale che
// genererà per noi determinate parti di codice.
// Deserialize è fondamentale e proviene dal crate
// serde, su cui toml fa affidamento.
#[derive(Debug, Deserialize)]
struct Config {
    instance: InstanceConfig,
    authentication: AuthenticationConfig,
    text: TextConfig,
}

#[derive(Debug, Deserialize)]
struct InstanceConfig {
    url: String,
    community_id: i32,
}

#[derive(Debug, Deserialize)]
struct AuthenticationConfig {
    session_token: String,
}

#[derive(Debug, Deserialize)]
struct TextConfig {
    // Perché un Option? Perché la pool di titoli nel file
    // di configurazione può esserci o non esserci, è, per
    // l'appunto, opzionale.
    // Potremo facilmente controllare che il valore sia presente
    // in base al suo tipo:
    //   Some(Vec<String>) - un vettore contenente stringhe
    //   None - un'Option vuota
    title_pool: Option<Vec<String>>,
    body_pool: Option<Vec<String>>,
}

fn main() {
    // Specifica la posizione ed il nome del file di configurazione.
    let path = Path::new("./simplecofeebot.toml");
    // Apri il file di configurazione e controlla che l'operazione non dia errore.
    let (config, title, body): (Config, String, String) = match File::open(path) {
        // Il file è stato aperto con successo, quindi usiamo un pattern
        // per estrarre il contenuto del nostro tipo Result<File, Error>.
        Ok(mut file) => {
            // Creiamo una nuova Stringa che verrà utilizzata come buffer per il contenuto
            // del nostro file di configurazione.
            // Deve esser mut perché dobbiamo modificarla.
            let mut doc = String::new();
            // Ed ora tentiamo di trasportare il contenuto del file dentro la stringa di buffer!
            match file.read_to_string(&mut doc) {
                // Non siamo interessati a quello che read_to_string ritorna dentro l'Ok()
                // quindi usiamo _ per indicare che non serve una variabile dove inserirlo.
                Ok(_) => {
                    // Usiamo il crate Rand per creare un generatore di numeri casuali locale.
                    let mut rng = rand::thread_rng();
                    // Finalmente usiamo il crate toml per analizzare il file di configurazione.
                    let config: Config = match toml::from_str(doc.as_str()) {
                        // Il match può ritornare un valore al suo completamento, quindi,
                        // nel caso sia andato tutto bene, ritorniamo il valore contenuto
                        // all'interno del nostro Ok.
                        Ok(s) => s,
                        Err(err) => {
                            // Oh no!
                            // Qualcosa è andato storto e non ci è possibile recuperare
                            // da questo errore. Ci tocca chiudere il programma con un bel
                            // e salutare crash. Cerchiamo almeno di spiegare il perché...
                            panic!(
                                "Errore! Impossibile analizzare il file di configurazione!\n{}",
                                err
                            );
                        }
                    };
                    // Per rendere il titolo un po' meno monotono aggiungiamo un suffisso
                    // estratto a caso fra le stringhe date nell'apposito spazio nel file di
                    // configurazione.
                    // Useremo una funzione chiamata pick_random, definita poco sotto!
                    let title_suffix = config
                        .text
                        .title_pool
                        .as_ref()
                        .map_or(String::new(), |v| pick_random(&mut rng, v));

                    let body_default = "Salve!".to_owned();

                    let mut body = config
                        .text
                        .body_pool
                        .as_ref()
                        .map_or(body_default.clone(), |v| pick_random(&mut rng, v));

                    // Abbiamo fatto in modo che, in caso il nostro utente non
                    // abbia definito body_pool nella configurazione, allora vi sia
                    // la String "Salve!" come body, ma cosa succede nel caso in cui
                    // il nostro utente abbia passato [""] o []?
                    // Dato che abbiamo preso una decisione esecutiva d'avere come
                    // default "Salve!", allora è il caso d'aggiungere
                    // un altro controllo...
                    if body.is_empty() {
                        body = body_default;
                    }

                    // Rimandiamo indietro una bella Tupla d'elementi.. questo
                    // puzza solo a me?
                    // Se si fa affidamento su un dato strutturato, per muoverlo
                    // in giro, forse sarebbe il caso di creare direttamente una
                    // nuova Struct ed esser sicuri di quel che passiamo!
                    (config, generate_title(title_suffix), body)
                }
                Err(err) => {
                    // Hmm.. probabilmente il file non era codificato in UTF-8! Oh no! Anche qui...
                    // niente configurazione, niente funzionalità!
                    panic!(
                        "Errore! Sei sicuro che il file di configurazione sia in UTF-8 valido?\n{}",
                        err
                    );
                }
            }
        }
        // C'è stato un errore durante l'apertura del file!
        Err(err) => {
            // Niente file di configurazione significa che non abbiamo accesso al token JWT..
            // Anche se avessimo dei default per gli altri elementi non è possibile andare oltre,
            // l'unica via è un bel crash!
            panic!(
                "Errore! Impossibile aprire il file in \"{}\":\n{}",
                path.display(),
                err
            )
        }
    };

    // Ora che abbiamo il nostro titolo ed il nostro corpo del messaggio
    // è arrivato il momento di postarli! Ma come fare?
    // A piccoli passi!
    // Sarebbe opportuno controllare che l'url dell'istanza sia
    // almeno formalmente corretto.
    // Perché non fare tutti i controlli subito appena analizzato
    // il file di configurazione, così da non sprecare
    // tempo e computazioni? È un ottima domanda!
    // E la strategia giusta sarebbe di spostare tutto ciò che
    // riguarda la configurazione in un suo modulo a parte,
    // far tutte le validazioni del caso, e solo dopo procedere!
    // ...ma qui siamo bonari, ci fidiamo del nostro utente!
    // Saltiamo direttamente ad usare un nuovo crate per
    // inviare richieste: reqwest!
    let client = reqwest::blocking::Client::new();
    // Cosa succede se, per caso, l'url dell'istanza è malformato, pieno
    // di spazi bianchi o con un bel / alla fine..? E se non inizia con
    // il protocollo?
    let api_endpoint = format!("{}/api/v3/post", config.instance.url);
    // Questi son un sacco di dati da prendere e di cui fidarsi così, senza
    // pensarci..
    // Però siamo avventurosi! Creiamo direttamente il json sulla fiducia.
    // Per farlo utilizziamo una macro che proviene da serde_json.
    // Esistono molte altre vie, ma questa è quella più semplice da
    // gestire in modo sano, se dovessimo mai decidere d'implementare
    // altre casistiche.
    let req = json!({
        "auth": config.authentication.session_token,
        "name": title,
        "community_id": config.instance.community_id,
        "body": body,
    });
    // Ed ora proviamo ad inviare la richiesta!
    match client.post(api_endpoint).json(&req).send() {
        Ok(response) => {
            // Sembra esser tutto riuscito! Mettiamo un bel messaggino con
            // un sacco d'informazioni inutili nella console, giusto per
            // dar un po' di fastidio al nostro amato utente!
            // In più con un unwrap messo così c'è sempre la possibilità
            // d'avere un divertente crash al momento meno opportuno!
            println!("La discussione con il titolo \"{title}\" è stata inviata correttamente.\nRisposta: {}", response.text().unwrap())
            // Ma un momento... Se l'url era sbagliato e abbiam ricevuto
            // indietro un 404, 503 o qualche altro problema che ha impedito al
            // post d'esser ricevuto? Al momento il nostro script direbbe
            // sempre che tutto è andato bene! Ooooops!
            // Forse potremmo controllare response.status() e fare un bel match...
        }
        Err(err) => eprint!(
            "Errore! Non è stato possibile inviare la discussione!\n{}\n",
            err
        ),
    }
}

fn pick_random(trng: &mut ThreadRng, trg: &Vec<String>) -> String {
    // hmm.. cosa succede se viene passato un vettore vuoto...?
    // Meglio controllare! E in Rust anche l'if può direttamente
    // ritornare un valore.
    if trg.is_empty() {
        // È un po' triste e non esattamente il massimo.. Ma una stringa
        // vuota è pur sempre un'opzione!
        // Notare come trasformiamo "" da una &'static str in una String usando
        // to_owned().
        "".to_owned()
    } else {
        // Invece se c'è almeno un elemento allora procediamo all'estrazione!
        // Notare come len() ritorni la lunghezza del vettore, quindi
        // [0, 1, 2] ha una len() di 3, però l'ultimo elemento ha un'id
        // di 2!
        // Un'altra ottimizzazione possibile sarebbe nel caso avessimo
        // un solo elemento... ma.. Ne vale la pena?
        trg.get(trng.gen_range(0..=(trg.len() - 1)))
            .unwrap()
            .to_owned()
    }
}

fn generate_title(suffix: String) -> String {
    // Creiamo un descrittore per il formato della data da inserire nel titolo.
    // Nel caso ci sia qualche errore crashamo direttamente usando expect,
    // perché siamo bravi mettiamo anche un messaggio che cerca di spiegare il
    // motivo più probabile per il nostro crash.. Potrebbe esser utile in futuro, se
    // qualcuno decidesse di render il descrittore configurabile!
    let date_format = format_description::parse("[day]/[month]/[year]").expect(
        "La stringa che stai cercando di usare per formattare la data non sembra corretta!",
    );
    // Cerchiamo d'ottenere la datetime della macchina locale...
    let date = match OffsetDateTime::now_local() {
        Ok(datetime) => datetime.date(),
        Err(_) => {
            // Impossibile ottenere la data locale, con tristezza usiamo quella UTC.
            // Potrebbe capitare che il giorno sia diverso, per colpa dei fusi orari..
            eprintln!("Errore! Impossibile ottenere la data locale: verrà usata quella UTC.");
            OffsetDateTime::now_utc().date()
        }
    }
    .format(&date_format)
    .expect("Non siamo riusciti a formattare la data..?");

    format!("Caffè Italia {} {}", date, suffix)
        .trim()
        .to_owned()
}

Alcune possibili migliorie semplici ed interessanti potrebbero esser:

  • Una gestione migliore degli errori, usando l’operatore ‘?’, ed il crate anyhow.
  • Potremmo anche creare degli errori migliori con thiserror!
  • Abbiamo già il crate time… Perché non aggiungere dei timestamp ai messaggi nella console?
  • Creare diversi moduli per gestire in modo più generale e specifico (Ah! Sembra un ossimoro ma non lo è!) le varie funzioni!
  • Dopo aver creato la discussione, se ha accesso ai poteri da mod, pinnarla!
  • Magari sanitizzare un po’ le stringe provenienti dal file config? Anche solo un trim sarebbe un bel passo avanti! Anche quando l’utente è fidato cercare di coprire gli errori più comuni è sempre una buona idea!
  • Cosa succede se il session_token è scaduto…? Magari si potrebbe direttamente fare il login?
  • Magari trasformarlo per, oltre al file di configurazione, accettare anche dei parametri da linea di comando, usando un crate come Clap!
  • Attualmente lo script posta quando è invocato… Magari potremmo creare un loop e una configurazione per postare a determinate date/orari? Certo, un cron job quando disponibile è sempre la scelta migliore… Ma se proprio ci si sta annoiando…!
  • DrViente
    link
    fedilink
    Italiano
    arrow-up
    2
    ·
    edit-2
    1 year ago

    Questo codice è un macello! /s

    Lascerò a Clippy il compito di indicare tutti i problemi. Aggiungi #![deny(clippy::pedantic)] in cima ed esegui cargo clippy.

    Altri commenti in ordine sparso:

    È decisamente poco idiomatico, anche se immagino sia stato scritto così apposta, dato che è indirizzato a chi il linguaggio non lo conosce.

    Usare match per estrarre esplicitamente gli errori è il modo sbagliato di usare Rust. Quello giusto è usare l’operatore ?, che lo fa in modo implicito (o al massimo if let o let else). Non servono nemmeno anyhow o simili, le funzioni possono restituire Result<_, Box<dyn std::error::Error>>.

    Senza contare che match crea un nuovo scope e un nuovo livello di indentazione, il codice sembra la bandiera del Nepal.

    Per lo stesso motivo, usa std::fs::read_from_string invece di creare un file esplicitamente.

    Gli argomenti trng e trg della funzione pick_random suonano identici, ma a giudicare dal tipo sono ben diversi.

    Forse potremmo controllare response.status() e fare un bel match…

    Meglio usare Response::error_for_status. C’è quasi sempre un modo migliore di un match esplicito per gestire gli errori.

    Creare diversi moduli

    Prima di creare nuovi moduli penserei a creare nuove funzioni. main() fa decisamente troppe cose.

    Infine:

    Perché non su github?

    Al momento non ho un account che posso usare per queste cose, mi dispiace!

    Questa non è una buona scusa. Gli account su GitHub / Gitlab / SourceHut / BitBucket… non costano niente.

    Però tutto sommato è stata una buona idea, e il codice in sé non è poi così malvagio per un principiante (o per qualcuno che scrive per principianti). Magari riesci a fare incuriosire qualcuno.

    • MechanizeOP
      link
      fedilink
      Italiano
      arrow-up
      3
      ·
      1 year ago

      Grazie per il commento @DrViente@feddit.it !

      Devo esser sincero, inizialmente, leggendo la tua risposta, l’avevo intesa come un classico “io son meglio”, non volta ad apprendere, ragionare o migliorare la conoscenza di un neofita in Rust - modo in cui sembra trasparire tu mi consideri.

      Avevo scritto una risposta difensiva e probabilmente anche piccata, dove snocciolavo le mie scelte nell’ottica di una discussione educativa per principianti.
      L’ho cancellata, ma voglio comunque trattare due punti che hai sollevato, facendolo spero in modo neutro.

      Il primo riguarda in generale i contenuti: considero i tuoi appunti - ad eccezione dell’uso di pedantic che specialmente un neofita non dovrebbe toccare - sostanzialmente corretti per un programma di queste dimensioni, anche se a tratti pure a questo livello opinabili (non separare l’architettura in moduli, non usare anyhow e thiserror), specialmente se si vuole espanderlo in qualcosa di più utile. Una buona base aiuta una buona crescita.

      Il secondo invece riguarda questa frase, che reputo incredibilmente conflittuale con il solo scopo d’esserlo:

      Questa non è una buona scusa. Gli account su GitHub / Gitlab / SourceHut / BitBucket… non costano niente.

      L’unica risposta che posso darti è che hai ragione! Ma il costo del servizio non è fra le motivazioni per cui io al momento non ho un account che posso usare per queste cose.
      Nota, anche, che non era mia intenzione far intendere di non avere un account, anche se capisco come possa esser interpretato in quel modo. Ma questo è secondario: non ho parlato di motivazioni (o scritto scuse), quindi non capisco come tu possa trarre conclusioni al riguardo.

      Detto questo spero di non averti infastidito con questa replica, perché non era assolutamente mia intenzione.

      Però tutto sommato è stata una buona idea, e il codice in sé non è poi così malvagio per un principiante (o per qualcuno che scrive per principianti). Magari riesci a fare incuriosire qualcuno.

      Inizialmente volevo fare post semi-settimanali (tempo libero permettendo), dove man mano introducevo nuove funzionalità, concetti e trasformavo il codice in modo d’esser sempre più idiomatico e formalmente corretto - lentamente bollendo il rospo, per così dire - ma visto lo scarso interesse credo aspetterò qualche tempo prima di tentare nuovamente.

      • DrViente
        link
        fedilink
        Italiano
        arrow-up
        1
        arrow-down
        1
        ·
        1 year ago

        Devo esser sincero, inizialmente, leggendo la tua risposta, l’avevo intesa come un classico “io son meglio”, non volta ad apprendere, ragionare o migliorare la conoscenza di un neofita in Rust - modo in cui sembra trasparire tu mi consideri.

        Allora vuol dire che non sono riuscito a trasmettere il tono giocoso e spensierato che avrei voluto. Mi spiace.

        E forse ho frainteso il tuo messaggio originale in questo punto:

        Questo codice è un macello!

        Grazie, mi sono impegnato! Se hai notato bug, typo o cose insensate per favore commenta qui sotto!

        In questa tua replica sembri molto meno propenso a ricevere commenti di quanto avessi letto nel messaggio originale.

    • damtux
      link
      fedilink
      Italiano
      arrow-up
      2
      ·
      1 year ago

      l’account meglio su Codeberg / Gitea / Forgejo che sono 100% open rispetto a Gitlab o Github 😉

      • DrViente
        link
        fedilink
        Italiano
        arrow-up
        2
        ·
        1 year ago

        l’account meglio su Codeberg / Gitea / Forgejo che sono 100% open rispetto a Gitlab o Github 😉

        Gitea e Forgejo non offrono un servizio però, solo un’applicazione (se sbaglio linka pure, sono interessato).

        Chiedere di mettere su e gestire un’infrastruttura del genere aperta al pubblico per condividere un programma mi sembra un po’ esagerato. Per quanto sia divertente / soddisfacente / facile (opinione personale da uno che ha il suo server Gitea personale).

        • damtux
          link
          fedilink
          Italiano
          arrow-up
          1
          ·
          1 year ago

          Si, è vero sono applicazioni :) infatti la risposta di per sè è Codeberg, che in passato girava su Gitea e ora su Forgejo…era solo per specificare alternative in senso ampio.