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à [email protected] 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...!
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).