use crate::prelude::*;
use js_int::uint;
use matrix_sdk::attachment::{AttachmentConfig, AttachmentInfo, BaseImageInfo};
use reqwest::{ClientBuilder, redirect};
use tempfile::Builder;
use tokio::time::{Duration, interval};
use tokio_postgres::types::Type as dbtype;
#[derive(Clone, Deserialize)]
pub struct ModuleConfig {
pub room_map: HashMap<String, String>,
pub presence_map: HashMap<String, Vec<String>>,
#[serde(default = "presence_interval")]
pub presence_interval: u64,
#[serde(default = "empty_response")]
pub empty_response: String,
#[serde(default = "keywords")]
pub keywords: Vec<String>,
#[serde(default = "presence_db")]
pub presence_db: String,
#[serde(default = "keywords_heatmap")]
pub keywords_heatmap: Vec<String>,
pub heatmap_map: HashMap<String, String>,
#[serde(default = "heatmap_tmp_dir")]
pub heatmap_tmp_dir: String,
}
const fn presence_interval() -> u64 {
30
}
fn empty_response() -> String {
"Nikdo není doma...".s()
}
fn keywords() -> Vec<String> {
vec!["at".s()]
}
fn presence_db() -> String {
"notbot".s()
}
fn keywords_heatmap() -> Vec<String> {
vec!["heatmap".s(), "hm".s()]
}
fn heatmap_tmp_dir() -> String {
"./".s()
}
pub(crate) fn starter(_: &Client, config: &Config) -> anyhow::Result<Vec<ModuleInfo>> {
info!("registering modules");
let module_config: ModuleConfig = config.typed_module_config(module_path!())?;
let (tx, rx) = mpsc::channel::<ConsumerEvent>(1);
let at = ModuleInfo {
name: "at".s(),
help: "see who is present at the hackerspace".s(),
acl: vec![],
trigger: TriggerType::Keyword(module_config.keywords.clone()),
channel: tx,
error_prefix: Some("error getting presence status".s()),
};
at.spawn(rx, module_config.clone(), at_processor);
let heatmap = ModuleInfo::new(
"heatmap",
"show activity at the hackerspace for the last week",
vec![],
TriggerType::Keyword(module_config.keywords_heatmap.clone()),
Some("error generating heatmap"),
module_config,
heatmap,
);
Ok(vec![at, heatmap])
}
pub async fn at_processor(event: ConsumerEvent, config: ModuleConfig) -> anyhow::Result<()> {
let name = room_name(&event.room);
let url = if let Some(url) = config.room_map.get(&name) {
url
} else {
debug!("no spaceapi url found, using default");
match config.room_map.get("default") {
None => bail!("no spaceapi url found"),
Some(u) => u,
}
};
let data = fetch_and_decode_json::<space_api::SpaceAPI>(url.to_owned()).await?;
let present: Vec<String> = names_dehighlighted(data.sensors.people_now_present);
let response = if present.is_empty() {
config.empty_response.clone()
} else {
present.join(", ")
};
event
.room
.send(RoomMessageEventContent::text_plain(response))
.await?;
Ok(())
}
pub async fn heatmap(event: ConsumerEvent, config: ModuleConfig) -> anyhow::Result<()> {
let name = room_name(&event.room);
let period: HeatmapPeriod = event.args.try_into()?;
let Some(base_url) = config.heatmap_map.get(&name) else {
bail!("no heatmap source defined for this channel")
};
let url = format!("{base_url}&period={}", Into::<String>::into(period.clone()));
let client = ClientBuilder::new()
.redirect(redirect::Policy::none())
.build()?;
let response = client.get(url).send().await?;
if !response.status().is_success() {
bail!("wrong api response: {:?}", response.status());
};
let data: heatmap_api::Root = response.json().await?;
let named_tempfile = Builder::new()
.prefix("notbot-heatmap-")
.suffix(".png")
.rand_bytes(5)
.tempfile_in(config.heatmap_tmp_dir)?;
let name = named_tempfile
.path()
.to_str()
.ok_or_else(|| anyhow::anyhow!("tempfile error"))?;
trace!("heatmap tmpfile: {name}");
heatmap_api::draw(name, &data.data)?;
let image = fs::read(name)?;
let caption = match period {
HeatmapPeriod::All => Some("All acquired datapoints".s()),
_ => Some(format!("Activity in the last {period:?}")),
};
let attachment_config = AttachmentConfig::new()
.caption(caption)
.info(AttachmentInfo::Image(BaseImageInfo {
height: Some(uint!(330)),
width: Some(uint!(1010)),
..Default::default()
}));
event
.room
.send_attachment("heatmap.png", &mime::IMAGE_PNG, image, attachment_config)
.await?;
std::fs::remove_file(name)?;
Ok(())
}
#[derive(Debug, Clone)]
enum HeatmapPeriod {
Week,
Month,
Year,
All,
}
impl TryFrom<Option<String>> for HeatmapPeriod {
type Error = anyhow::Error;
fn try_from(s: Option<String>) -> anyhow::Result<Self> {
use HeatmapPeriod::{All, Month, Week, Year};
match s {
None => Ok(Week),
Some(maybe) => match maybe.to_lowercase().trim() {
"week" | "tydzień" | "tydzien" => Ok(Week),
"month" | "miesiąc" | "miesiac" => Ok(Month),
"year" | "rok" => Ok(Year),
"all" | "everything" | "wszystko" => Ok(All),
_ => bail!("invalid period"),
},
}
}
}
impl From<HeatmapPeriod> for String {
fn from(val: HeatmapPeriod) -> Self {
use HeatmapPeriod::{All, Month, Week, Year};
match val {
Week => "week".s(),
Month => "month".s(),
Year => "year".s(),
All => "everything".s(),
}
}
}
pub(crate) fn workers(mx: &Client, config: &Config) -> anyhow::Result<Vec<WorkerInfo>> {
let module_config: ModuleConfig = config.typed_module_config(module_path!())?;
Ok(vec![WorkerInfo::new(
"observer",
"observes SpaceAPI endpoints for changes",
"spaceapi",
mx.clone(),
module_config,
presence_observer,
)])
}
#[derive(Clone, Debug)]
struct PresenceData {
handle: String,
}
impl PresenceData {
const INSERT_DATAPOINT: &str = r"INSERT INTO presence (ts, url, count)
VALUES ( now(), $1, $2 )";
async fn insert(self, url: String, count: i64) -> anyhow::Result<()> {
let client = DBPools::get_client(self.handle.as_str()).await?;
let statement = client
.prepare_typed_cached(Self::INSERT_DATAPOINT, &[dbtype::VARCHAR, dbtype::INT8])
.await?;
client.execute(&statement, &[&url, &count]).await?;
Ok(())
}
}
pub async fn presence_observer(client: Client, module_config: ModuleConfig) -> anyhow::Result<()> {
let mut interval = interval(Duration::from_secs(module_config.presence_interval));
let mut present: HashMap<String, Vec<String>> = HashMap::default();
let mut first_loop: HashMap<String, bool> = HashMap::default();
for url in module_config.presence_map.keys() {
present.insert(url.clone(), vec![]);
first_loop.insert(url.clone(), true);
}
loop {
interval.tick().await;
for (url, rooms) in &module_config.presence_map {
let presence = PresenceData {
handle: module_config.presence_db.clone(),
};
trace!("fetching spaceapi url: {}", url);
let data = match fetch_and_decode_json::<space_api::SpaceAPI>(url.to_owned()).await {
Ok(d) => d,
Err(fe) => {
error!("error fetching data: {fe}");
if let Err(e) = presence.insert(url.clone(), -1).await {
error!("error storing spaceapi persistence data: {e}");
};
continue;
}
};
let current: Vec<String> = names_dehighlighted(data.sensors.people_now_present);
#[allow(
clippy::cast_possible_wrap,
reason = "vector length limited by presence at a physical location"
)]
if let Err(e) = presence.insert(url.clone(), current.len() as i64).await {
error!("error storing spaceapi persistence data: {e}");
};
let mut arrived: Vec<String> = vec![];
let mut left: Vec<String> = vec![];
let mut also_there: Vec<String> = vec![];
for name in ¤t {
if !present[url].contains(name) {
arrived.push(name.clone());
};
}
for name in &present[url] {
if current.contains(name) {
also_there.push(name.clone());
} else {
left.push(name.clone());
};
}
present.insert(url.clone(), current);
if first_loop[url] {
first_loop.insert(url.clone(), false);
continue;
}
let mut response_parts: Vec<String> = vec![];
if !arrived.is_empty() {
response_parts.push(["arrived: ", &arrived.join(", ")].concat());
};
if !left.is_empty() {
response_parts.push(["left: ", &left.join(", ")].concat());
};
if !also_there.is_empty() {
let qualifier = if arrived.is_empty() { "still" } else { "also" };
response_parts.push([qualifier, " there: ", &also_there.join(", ")].concat());
};
if arrived.is_empty() && left.is_empty() {
continue;
};
let response = RoomMessageEventContent::notice_plain(response_parts.join(", "));
for maybe_room in rooms {
let room_id: OwnedRoomId = if let Ok(r) = maybe_room.clone().try_into() {
r
} else {
let Ok(alias_id) = OwnedRoomAliasId::try_from(maybe_room.clone()) else {
error!("couldn't parse room name: {}", maybe_room);
continue;
};
match client.resolve_room_alias(&alias_id).await {
Err(e) => {
error!("couldn't resolve room alias: {e}");
continue;
}
Ok(r) => r.room_id,
}
};
let Some(room) = client.get_room(&room_id) else {
continue;
};
if let Err(e) = room.send(response.clone()).await {
error!("error while sending presence update: {e}");
};
}
}
}
}
#[must_use]
pub fn names_dehighlighted(present: Vec<space_api::PeopleNowPresent>) -> Vec<String> {
let mut dehighlighted: Vec<String> = vec![];
for sensor in present {
for name in sensor.names {
let mut chars = name.chars();
dehighlighted.push(chars.next().map_or_else(String::new, |first| {
first.to_string() + "\u{200B}" + chars.as_str()
}));
}
}
dehighlighted
}
pub mod space_api {
use serde_derive::Deserialize;
#[allow(dead_code, missing_docs)]
#[derive(Clone, Deserialize)]
pub struct SpaceAPI {
pub api_compatibility: Vec<String>,
pub space: String,
pub logo: String,
pub url: String,
pub location: Location,
pub state: State,
pub contact: Contact,
pub projects: Vec<String>,
pub feeds: Feeds,
pub sensors: Sensors,
}
#[allow(dead_code, missing_docs)]
#[derive(Clone, Deserialize)]
pub struct Location {
pub lat: f64,
pub lon: f64,
pub address: String,
}
#[allow(dead_code, missing_docs)]
#[derive(Clone, Deserialize)]
pub struct State {
pub open: bool,
pub message: String,
pub icon: Icon,
}
#[allow(dead_code, missing_docs)]
#[derive(Clone, Deserialize)]
pub struct Icon {
pub open: String,
pub closed: String,
}
#[allow(dead_code, missing_docs)]
#[derive(Clone, Deserialize)]
pub struct Contact {
pub facebook: String,
pub irc: String,
pub mastodon: String,
pub matrix: String,
pub ml: String,
pub twitter: String,
}
#[allow(dead_code, missing_docs)]
#[derive(Clone, Deserialize)]
pub struct Feeds {
pub blog: Blog,
pub calendar: Calendar,
pub wiki: Wiki,
}
#[allow(dead_code, missing_docs)]
#[derive(Clone, Deserialize)]
pub struct Blog {
#[serde(rename = "type")]
pub type_field: String,
pub url: String,
}
#[allow(dead_code, missing_docs)]
#[derive(Clone, Deserialize)]
pub struct Calendar {
#[serde(rename = "type")]
pub type_field: String,
pub url: String,
}
#[allow(dead_code, missing_docs)]
#[derive(Clone, Deserialize)]
pub struct Wiki {
#[serde(rename = "type")]
pub type_field: String,
pub url: String,
}
#[allow(dead_code, missing_docs)]
#[derive(Clone, Deserialize)]
pub struct Sensors {
pub people_now_present: Vec<PeopleNowPresent>,
}
#[allow(dead_code, missing_docs)]
#[derive(Clone, Deserialize, Debug)]
pub struct PeopleNowPresent {
pub value: u32,
pub names: Vec<String>,
}
}
pub mod heatmap_api {
use plotters::{prelude::*, style::full_palette::PURPLE_A400};
use serde_derive::Deserialize;
use serde_nested_with::serde_nested;
use serde_this_or_that::as_f64;
#[allow(dead_code)]
#[derive(Default, Debug, Clone, Deserialize)]
pub(crate) struct Root {
pub copyright: String,
pub license: String,
pub data: Data,
pub tz: String,
pub period: String,
#[serde(rename = "space-state")]
pub space_state: String,
}
#[allow(dead_code)]
#[serde_nested]
#[derive(Default, Debug, Clone, Deserialize)]
pub struct Data {
#[serde(rename = "2")]
#[serde_nested(sub = "f64", serde(deserialize_with = "as_f64"))]
pub n2: Vec<f64>,
#[serde(rename = "3")]
#[serde_nested(sub = "f64", serde(deserialize_with = "as_f64"))]
pub n3: Vec<f64>,
#[serde(rename = "4")]
#[serde_nested(sub = "f64", serde(deserialize_with = "as_f64"))]
pub n4: Vec<f64>,
#[serde(rename = "5")]
#[serde_nested(sub = "f64", serde(deserialize_with = "as_f64"))]
pub n5: Vec<f64>,
#[serde(rename = "6")]
#[serde_nested(sub = "f64", serde(deserialize_with = "as_f64"))]
pub n6: Vec<f64>,
#[serde(rename = "7")]
#[serde_nested(sub = "f64", serde(deserialize_with = "as_f64"))]
pub n7: Vec<f64>,
#[serde(rename = "1")]
#[serde_nested(sub = "f64", serde(deserialize_with = "as_f64"))]
pub n1: Vec<f64>,
pub avg: Vec<f64>,
}
pub fn draw(out_file: &str, data: &Data) -> anyhow::Result<()> {
const GAMMA: f64 = 2.2;
let colormap: Box<dyn ColorMap<RGBAColor>> = Box::new(ViridisRGBA {});
let root = BitMapBackend::new(out_file, (1010, 330))
.into_drawing_area()
.margin(5, 5, 5, 5);
root.fill(&TRANSPARENT)?;
let text_style = TextStyle::from(("monospace", 20).into_font());
let hour_areas = root.margin(0, 970, 40, 0).split_evenly((1, 24));
for (i, area) in hour_areas.iter().enumerate() {
let text_zero = format!("{i:0>2}");
let text = format!("{text_zero:>3}");
area.draw_text(&text, &text_style.color(&PURPLE_A400), (5, 13))?;
}
let day_areas = root.margin(40, 0, 0, 270).split_evenly((7, 1));
for (i, area) in day_areas.iter().enumerate() {
let day: Weekday = i.into();
let text = format!("{day:?}");
area.draw_text(&text, &text_style.color(&PURPLE_A400), (5, 13))?;
}
let drawing_areas = root.margin(40, 0, 40, 0).split_evenly((7, 24));
let mut areas = drawing_areas.into_iter();
for datapoint in skip_last(data.n2.iter())
.chain(skip_last(data.n3.iter()))
.chain(skip_last(data.n4.iter()))
.chain(skip_last(data.n5.iter()))
.chain(skip_last(data.n6.iter()))
.chain(skip_last(data.n7.iter()))
.chain(skip_last(data.n1.iter()))
{
let area = areas
.next()
.ok_or_else(|| anyhow::anyhow!("not enough areas?"))?;
let value = format!("{:>3}", (datapoint * 100.0).round());
#[allow(
clippy::cast_possible_truncation,
reason = "values returned from API are in 0.0..1.0 range only"
)]
let bg_color = colormap.get_color(datapoint.to_owned() as f32);
let (c_red, c_green, c_blue) = bg_color.rgb();
let c_red: f64 = Into::<f64>::into(c_red) / 255.0;
let c_green: f64 = Into::<f64>::into(c_green) / 255.0;
let c_blue: f64 = Into::<f64>::into(c_blue) / 255.0;
let l: f64 = 0.0722f64.mul_add(
c_blue.powf(GAMMA),
0.2126f64.mul_add(c_red.powf(GAMMA), 0.7152 * c_green.powf(GAMMA)),
);
let text_color = if l > 0.5_f64.powf(GAMMA) {
&BLACK
} else {
&WHITE
};
area.margin(2, 2, 2, 2).fill(&bg_color)?;
area.draw_text(&value, &text_style.color(text_color), (5, 13))?;
}
Ok(())
}
#[derive(Debug)]
enum Weekday {
Mon,
Tue,
Wed,
Thu,
Fri,
Sat,
Sun,
}
impl From<usize> for Weekday {
fn from(value: usize) -> Self {
use Weekday::{Fri, Mon, Sat, Sun, Thu, Tue, Wed};
match value {
1 => Tue,
2 => Wed,
3 => Thu,
4 => Fri,
5 => Sat,
6 => Sun,
_ => Mon,
}
}
}
fn skip_last<I, T>(iter: I) -> impl Iterator<Item = T>
where
I: Iterator<Item = T> + std::iter::DoubleEndedIterator + std::iter::ExactSizeIterator,
{
iter.rev().skip(1).rev()
}
}