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>

//! Misc utils, not fitting anywhere else

use std::fs::File;
use std::io::Read;

use anyhow::anyhow;
use matrix_sdk::RoomState;
use matrix_sdk::ruma::{
    OwnedRoomId,
    OwnedRoomOrAliasId,
    UserId,
};
use qrcode::QrCode;

use crate::prelude::*;

/// Geographical coordinates of a&nbsp;point on Earth.
pub struct Location {
    /// Latitude in degrees, positive is North, negative is South.
    pub lat: f64,

    /// Longitude in degrees, positive is East, negative is West.
    pub lon: f64,
}

impl Location {
    /// Construct a&nbsp;new [`Location`]
    pub fn new(lat: f64, lon: f64) -> Self { Self { lat, lon } }

    /// Generate `"https://www.openstreetmap.org/..."` link to the [`Location`].
    ///
    /// See also: <https://wiki.openstreetmap.org/wiki/Permalink>
    ///
    /// Arguments:
    ///
    /// * `zoom` — optional value between `0` and `20`; see
    ///   <https://wiki.openstreetmap.org/wiki/Zoom_levels> for more info
    /// * `marker` — if `true`, will add a&nbsp;marker pointing to the exact spot
    ///
    /// Bugs:
    ///
    /// * layers (<https://wiki.openstreetmap.org/wiki/Layer_URL_parameter>) are currently
    ///   unsupported
    pub fn osm(&self, zoom: Option<u8>, marker: bool) -> String {
        osm(self.lat, self.lon, zoom, marker)
    }

    /// Generate `"https://osm.org/go/" shortlink` link to the [`Location`].
    ///
    /// See also: <https://wiki.openstreetmap.org/wiki/Shortlink>.
    ///
    /// Arguments:
    ///
    /// * `zoom` — optional value between `0` and `20`; see
    ///   <https://wiki.openstreetmap.org/wiki/Zoom_levels> for more info
    /// * `marker` — if `true`, will add a&nbsp;marker pointing to the exact spot
    ///
    /// Bugs:
    ///
    /// * layers (<https://wiki.openstreetmap.org/wiki/Layer_URL_parameter>) are currently
    ///   unsupported
    pub fn osm_shortlink(&self, zoom: Option<u8>, marker: bool) -> String {
        osm_shortlink(self.lat, self.lon, zoom, marker)
    }
}

impl std::convert::TryFrom<&serde_json::Value> for Location {
    type Error = anyhow::Error;
    fn try_from(item: &serde_json::Value) -> Result<Self, Self::Error> {
        Ok(Self {
            lat: item["latitude"].as_f64().ok_or(anyhow!("schema error ({item})"))?,
            lon: item["longitude"].as_f64().ok_or(anyhow!("schema error ({item})"))?,
        })
    }
}

fn osm(lat: f64, lon: f64, zoom: Option<u8>, marker: bool) -> String {
    format!(
        "https://www.openstreetmap.org/{}#map={}/{}/{}",
        if marker {
            format!("?mlat={}&mlon={}", lat, lon)
        } else {
            "".to_string()
        },
        zoom.unwrap_or(17),
        lat,
        lon,
    )
}

// https://wiki.openstreetmap.org/wiki/Shortlink
//  - https://gist.github.com/mdornseif/5652824
//  - https://github.com/openstreetmap-ng/openstreetmap-ng/blob/1d641b53cc6cf2cfe35f4b34c7a04d7fb386a745/app/lib/shortlink.py
//  - https://github.com/Zaczero/osm-shortlink
const SHORTLINK_CHARSET: &[u8] =
    b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_~";
fn osm_shortlink(lat: f64, lon: f64, zoom: Option<u8>, marker: bool) -> String {
    let zoom = zoom.unwrap_or(17);

    let y = (((lat + 90.0) % 180.0) * 2.0_f64.powi(32) / 180.0) as u64;
    let x = (((lon + 180.0) % 360.0) * 2.0_f64.powi(32) / 360.0) as u64;

    let mut c = 0u64;

    for i in (0u8..32).rev() {
        c = (c << 1) | ((x >> i) & 1);
        c = (c << 1) | ((y >> i) & 1);
    }

    let (d, r) = ((zoom + 8).div_ceil(3), (zoom + 8) % 3);

    let mut ret = String::with_capacity((19 + d + r + if marker { 3 } else { 0 }) as usize);
    ret.push_str("https://osm.org/go/");

    for i in 0..d {
        ret.push(SHORTLINK_CHARSET[((c >> (58 - 6 * i)) & 0x3f) as usize].into());
    }

    for _ in 0..r {
        ret.push('-');
    }

    if marker {
        ret.push_str("?m=")
    }

    ret
}


/// Create PNG qrcode suitable for sending as an image to Matrix room.
///
/// ```
/// # use matrix_sdk::attachment::AttachmentConfig;
/// # use rdzobot::utils;
/// # async fn f(room: matrix_sdk::Room) -> anyhow::Result<()> {
/// let image = utils::create_qrcode_png("https://hackerspace.pl/")?;
///
/// room.send_attachment(
///     "hackerspace.png",
///     &mime::IMAGE_PNG,
///     image,
///     AttachmentConfig::new().caption(Some("hackerspace.pl".to_string())),
/// )
/// .await?;
/// # Ok(())
/// # }
/// ```
pub fn create_qrcode_png(data: &str) -> anyhow::Result<Vec<u8>> {
    let qrcode = QrCode::new(data.as_bytes()).unwrap();
    let image = qrcode.render::<image::Luma<u8>>().build();
    let mut png: Vec<u8> = Vec::new();
    image.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)?;

    Ok(png)
}


/// Loudly proclaim that the sender is unauthorized to issue a&nbsp;command.
pub async fn nie_zesraj_się(
    event: &OriginalSyncRoomMessageEvent,
    room: &Room,
) -> anyhow::Result<()> {
    let content = RoomMessageEventContent::notice_plain("Nie zesraj się.").make_reply_to(
        &event.clone().into_full_event(room.room_id().into()),
        ForwardThread::No,
        AddMentions::Yes,
    );
    room.send(content).await?;
    Ok(())
}

/// Control if the message is suitable to reply to, or react by executing a&nbsp;command.
///
/// If yes, `Some(message_body)` is returned. If not, `None`.
///
/// Currently, the requirements are:
///
/// * The [`Room`] is currently joined by the bot.
/// * The message originates from someone else, i.e. not by the bot itself.
/// * The message is a&nbsp;text message, not a&nbsp;notice (probably sent by another bot), an
///   image or another non-text thingy.
///
/// In the future it's possible that particular checks will be altered to include other types of
/// typical messages (to adapt to new Matrix specs), but the general intent of selecting messages
/// to be processed by the bot will remain.
///
/// Arguments should be passed directly from event handler.
pub fn text_message_gate(
    event: &OriginalSyncRoomMessageEvent,
    client: &Client,
    room: &Room,
) -> Option<String> {
    /* don't interact with foreign rooms and do not react to our own events */
    if room.state() != RoomState::Joined || event.sender == client.user_id().unwrap() {
        return None;
    }

    /* do not react to non-text messages (e.g. notices) */
    let MessageType::Text(text_content) = &event.content.msgtype else {
        return None;
    };
    Some(text_content.body.clone())
}

/// Resolve room alias.
///
/// This function is handy for parsing room identifiers (e.g. to instruct the bot to join or leave
/// a&nbsp;particular channel), to allow a&nbsp;command to optionally specify the channel either by
/// `!`- or `#`-identifier, and if not given, will default to current room (or any other given by
/// `default`).
///
/// See `autojoin.rs`, `on_cmd_leave()` for an example how to use it.
pub async fn resolve_room_alias_with_default(
    client: Client,
    room_or_alias_id: Option<OwnedRoomOrAliasId>,
    default: Option<&Room>,
) -> anyhow::Result<Option<Room>> {
    if let Some(room_or_alias_id) = room_or_alias_id {
        let room_id = match OwnedRoomId::try_from(room_or_alias_id) {
            /*
             * TryFrom for OwnedRoomOrAliasId is smart, it has
             * try_from(...) -> Result<OwnedRoomId, OwnedAliasId>
             */
            Ok(room_id) => room_id,
            Err(room_alias_id) => client.resolve_room_alias(&room_alias_id).await?.room_id,
        };
        Ok(client.get_room(&room_id))
    } else if let Some(default) = default {
        Ok(Some(default.clone()))
    } else {
        Ok(None)
    }
}

/// Random bytes generator
pub fn random() -> impl Iterator<Item = u8> {
    File::open("/dev/urandom")
        .expect("can't open /dev/urandom")
        .bytes()
        .map(|r| r.expect("failed to read from /dev/urandom"))
}


/// Get human's nickname from Matrix `@`-id.
///
/// This extracts localpart from given `UserId` and strips known prefix. Currently known prefixes
/// are:
/// - `_discord_`
/// - `libera_`
/// - `slack_`
/// - `telegram_`
#[rustfmt::skip]
pub fn localpart_without_bridge_prefix(user_id: &UserId) -> &str {
    let localpart = user_id.localpart();
    [
        "_discord_",
        "libera_",
        "slack_",
        "telegram_",
    ].iter().filter_map(|prefix| localpart.strip_prefix(prefix)).next().unwrap_or(localpart)
}


/// Check if the room is on a&nbsp;list of rooms.
///
/// The list is a&nbsp;vector or [`OwnedRoomOrAliasId`], so the owner may give either `!`-ids or
/// `#`-ids.
pub async fn check_acl_room(
    client: Client,
    acl: &Vec<OwnedRoomOrAliasId>,
    room: &Room,
) -> anyhow::Result<bool> {
    tracing::debug!("check_acl_room(acl={:?}, room.room_id={:?}", &acl, room.room_id());

    for room_or_alias_id in acl.iter() {
        tracing::debug!("  checking {:?}", &room_or_alias_id);
        if let Some(r) =
            resolve_room_alias_with_default(client.clone(), Some(room_or_alias_id.clone()), None)
                .await?
        {
            tracing::debug!("    resolved to {:?}", r.room_id());
            if r.room_id() == room.room_id() {
                tracing::debug!("      found");
                return Ok(true);
            }
        }
    }

    tracing::debug!("  not found");
    Ok(false)
}


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

    #[test]
    #[rustfmt::skip]
    fn test_localpart_without_bridge_prefix() {
        assert_eq!(localpart_without_bridge_prefix(
            user_id!("@woju:hackerspace.pl")), "woju");
        assert_eq!(localpart_without_bridge_prefix(
            user_id!("@libera_woju:hackerspace.pl")), "woju");
        assert_eq!(localpart_without_bridge_prefix(
            user_id!("@libera_libera_woju:hackerspace.pl")), "libera_woju");
        assert_eq!(localpart_without_bridge_prefix(
            user_id!("@telegram_libera_woju:hackerspace.pl")), "libera_woju");
        assert_eq!(localpart_without_bridge_prefix(
            user_id!("@telegram_123456:hackerspace.pl")), "123456");
        assert_eq!(localpart_without_bridge_prefix(
            user_id!("@_discord_123456:hackerspace.pl")), "123456");
    }
}