rdzobot 0.1.0

Modular, but monolithic Matrix bot
Documentation
// SPDX-License-Identifier: AGPL-3.0-or-later
// SPDX-FileCopyrightText: 2025 Wojtek Porczyk <woju@hackerspace.pl>

//! Module `hswaw`: assorted tooling for Warsaw Hackerspace

const FAKTURA: &str = "\
Stowarzyszenie „Warszawski Hackerspace”
NIP: 5252540655
ul. Wolność 2A
01-018 Warszawa";

const SRU_DIE: u8 = 6; // 1d6

use std::sync::atomic::{
    AtomicU8,
    Ordering,
};

use crate::prelude::*;
use crate::utils;


#[derive(Debug, Deserialize)]
#[serde(default)]
#[doc(hidden)]
pub struct Config {
    enabled: bool,

    /// Privileged rooms
    priv_rooms: Vec<OwnedRoomOrAliasId>,
}

impl Default for Config {
    fn default() -> Self {
        Self {
            enabled: true,
            priv_rooms: Vec::default(),
        }
    }
}

#[doc(hidden)]
pub fn load(bot: Rdzobot) {
    if !bot.config().module.hswaw.enabled {
        return;
    }

    bot.add_command(
        clap::Command::new("!faktura")
            .visible_alias("!faktury")
            .about("Pokaże aktualne dane do faktury"),
        on_cmd_faktura,
    );

    bot.add_command(LabelArgs::command(), on_cmd_label);
    bot.add_command(clap::Command::new("!sru").about("Hackerspejsowa ruletka"), on_cmd_sru);
}


async fn on_cmd_faktura(
    _arg_matches: clap::ArgMatches,
    _event: OriginalSyncRoomMessageEvent,
    _client: Client,
    room: Room,
    _bot: Rdzobot,
) -> anyhow::Result<()> {
    room.send(RoomMessageEventContent::notice_plain(FAKTURA)).await?;
    Ok(())
}

async fn on_cmd_sru(
    _arg_matches: clap::ArgMatches,
    event: OriginalSyncRoomMessageEvent,
    _client: Client,
    room: Room,
    _bot: Rdzobot,
) -> anyhow::Result<()> {
    // Dlaczego wszystkie kanały mają wspólny stan? Bo chciałem się nauczyć atomiców.
    static COUNTER: AtomicU8 = AtomicU8::new(SRU_DIE - 1);

    let mut prev = COUNTER.load(Ordering::Relaxed);
    let (mut lucky, mut next) = sru_roll(prev);
    loop {
        match COUNTER.compare_exchange_weak(prev, next, Ordering::Relaxed, Ordering::Relaxed) {
            Ok(_) => break,
            Err(next_prev) => {
                prev = next_prev;
                (lucky, next) = sru_roll(prev);
            }
        }
    }

    if lucky {
        room.send(RoomMessageEventContent::notice_plain(format!(
            "You were lucky this time (chance 1/{})",
            prev + 1,
        )))
        .await?;
    } else if room
        .kick_user(
            &event.sender,
            Some(format!("You were unlucky this time (chance 1/{})", prev + 1).as_str()),
        )
        .await
        .is_err()
    {
        room.send(RoomMessageEventContent::notice_plain(format!(
            "SRU! (chance 1/{}; can't kick)",
            prev + 1,
        )))
        .await?;
    }
    Ok(())
}

fn sru_roll(prev: u8) -> (bool, u8) {
    let n = (prev + 1) as u16;
    let max: u8 = ((256u16 / n) * n - 1) as u8;
    let mut random = utils::random();
    let lucky = loop {
        let b = random.next().unwrap();
        if b <= max {
            break b % (n as u8) != 0;
        }
    };

    (lucky, if lucky { prev - 1 } else { SRU_DIE - 1 })
}


#[derive(clap::Parser)]
#[command(name = "!label")]
#[command(about = "Prints a sticker for labeling materials stored in heavy workshop")]
struct LabelArgs {
    /// actually print, don't preview; THIS COSTS MONEY, and is limited to HS members
    #[arg(long)]
    print: bool,

    /// use another nickname
    nick: Vec<String>,
}

async fn on_cmd_label(
    mut arg_matches: clap::ArgMatches,
    event: OriginalSyncRoomMessageEvent,
    client: Client,
    room: Room,
    bot: Rdzobot,
) -> anyhow::Result<()> {
    let args = LabelArgs::from_arg_matches_mut(&mut arg_matches).unwrap();
    let sender = utils::localpart_without_bridge_prefix(&event.sender).to_string();

    let url = reqwest::Url::parse_with_params(
        format!(
            "https://label.hackerspace.pl/api/{}/100/",
            if args.print { "print" } else { "preview" }
        )
        .as_str(),
        &[
            ("usemarkup", "1"),
            (
                "text",
                format!(
                    "{}\n<span size=\"60pt\" color=\"white\" bgcolor=\"black\"> {} </span>",
                    if args.nick.is_empty() {
                        sender.clone()
                    } else {
                        args.nick.join(" ")
                    },
                    chrono::Utc::now()
                        .with_timezone(&bot.config().timezone)
                        .format("%e.%m.%Y")
                        .to_string()
                        .trim(),
                )
                .as_str(),
            ),
        ],
    )
    .unwrap();

    tracing::debug!("!print url={}", url.as_str());

    if args.print {
        if !utils::check_acl_room(client.clone(), &bot.config().module.hswaw.priv_rooms, &room)
            .await?
        {
            room.send(RoomMessageEventContent::notice_plain("not in this channel").make_reply_to(
                &event.clone().into_full_event(room.room_id().into()),
                ForwardThread::No,
                AddMentions::Yes,
            ))
            .await?;
            return Ok(());
        }

        if !kasownik_judgement(client.http_client().clone(), &sender).await? {
            room.send(
                RoomMessageEventContent::notice_plain("żeby drukować, trzeba płacić składki")
                    .make_reply_to(
                        &event.clone().into_full_event(room.room_id().into()),
                        ForwardThread::No,
                        AddMentions::Yes,
                    ),
            )
            .await?;
            return Ok(());
        }

        client
            .http_client()
            .post(url)
            .basic_auth("rdzobot", bot.get_secret("http-basic", "rdzobot@label.hackerspace.pl"))
            .send()
            .await?
            .bytes()
            .await?;
    } else {
        let image = client
            .http_client()
            .get(url)
            .basic_auth("rdzobot", bot.get_secret("http-basic", "rdzobot@label.hackerspace.pl"))
            .send()
            .await?
            .bytes()
            .await?;

        room.send_attachment(
            "label.png",
            &mime::IMAGE_PNG,
            image.into(),
            matrix_sdk::attachment::AttachmentConfig::new(),
        )
        .await?;
    }

    Ok(())
}

// see hswaw/kasownik/web/webapp/api.py _public_api_method
#[derive(Debug, serde::Deserialize)]
#[serde(tag = "status")]
#[allow(dead_code)]
enum KasownikJudgement {
    #[serde(rename = "ok")]
    Ok { content: bool, modified: String },

    #[serde(rename = "error")]
    Error { content: String, modified: String },
}

/// Check if the member has paid all his/her membership dues.
pub async fn kasownik_judgement(http: reqwest::Client, member: &str) -> anyhow::Result<bool> {
    let url = reqwest::Url::parse(
        format!("https://kasownik.hackerspace.pl/api/judgement/{member}.json").as_str(),
    )?;
    match http.get(url).send().await?.json::<KasownikJudgement>().await? {
        KasownikJudgement::Ok { content, .. } => Ok(content),
        KasownikJudgement::Error { .. } => Ok(false),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn test_kasownik_judgement() -> anyhow::Result<()> {
        let http = reqwest::Client::new();
        assert!(kasownik_judgement(http.clone(), "woju").await?); // hehehe
        assert!(!kasownik_judgement(http.clone(), "root").await?);
        Ok(())
    }
}