smol bot
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
use anyhow::Result;
use mindus::{data::map::ReadError, *};
use poise::{serenity_prelude::*, CreateReply};
use std::{
    ops::ControlFlow,
    time::{Duration, Instant},
};

use super::{strip_colors, SUCCESS};

fn string((x, f): (ReadError, &str)) -> String {
    match x {
        ReadError::Decompress(_) | ReadError::Header(_) => {
            format!("not a map.")
        }
        ReadError::NoSuchBlock(b) => {
            format!("couldnt find block `{b}`. error originates from `{f}`")
        }
        ReadError::Version(v) => {
            format!(
                "unsupported version: `{v}`. supported versions: `7`. error originates from `{f}`",
            )
        }
        ReadError::Read(r) => {
            format!("failed to read map. error: `{r}`. originates from `{f}`")
        }
        ReadError::ReadState(r) => {
            format!("failed to read dyn data in map. error: `{r}`. originates from `{f}`")
        }
    }
}

pub async fn download(a: &Attachment) -> Result<(Result<Map, (ReadError, &str)>, Duration)> {
    let s = a.download().await?;
    let then = Instant::now();

    // could ignore, but i think if you have a msav, you dont want to ignore failures.
    Ok((
        Map::deserialize(&mut mindus::data::DataRead::new(&s)).map_err(|x| (x, &*a.filename)),
        then.elapsed(),
    ))
}

pub async fn scour(m: &Message) -> Result<Option<(Result<Map, (ReadError, &str)>, Duration)>> {
    for a in &m.attachments {
        if a.filename.ends_with("msav") {
            return Ok(Some(download(a).await?));
        }
    }
    Ok(None)
}

pub async fn reply(a: &Attachment) -> Result<ControlFlow<CreateReply, String>> {
    let (m, deser_took) = match download(a).await? {
        (Err(e), _) => return Ok(ControlFlow::Continue(string(e))),
        (Ok(m), deser_took) => (m, deser_took),
    };
    let (a, e) = embed(m, deser_took).await;
    Ok(ControlFlow::Break(
        CreateReply::default().attachment(a).embed(e),
    ))
}

struct Timings {
    deser_took: Duration,
    render_took: Duration,
    compression_took: Duration,
    total: Duration,
}
async fn render(m: Map, deser_took: Duration) -> (Timings, Vec<u8>) {
    tokio::task::spawn_blocking(move || {
        let render_took = Instant::now();
        let i = m.render();
        let render_took = render_took.elapsed();
        let compression_took = Instant::now();
        let png = super::png(i);
        let compression_took = compression_took.elapsed();
        let total = deser_took + render_took + compression_took;
        (
            Timings {
                deser_took,
                render_took,
                compression_took,
                total,
            },
            png,
        )
    })
    .await
    .unwrap()
}

pub async fn find(
    msg: &Message,
    c: &serenity::client::Context,
) -> Result<Option<(String, Map, Duration)>> {
    match scour(msg).await? {
        None => Ok(None),
        Some((Err(e), _)) => {
            msg.reply(c, string(e)).await?;
            Ok(None)
        }
        Some((Ok(m), deser_took)) => Ok(Some((
            msg.author_nick(c).await.unwrap_or(msg.author.name.clone()),
            m,
            deser_took,
        ))),
    }
}

pub async fn with(msg: &Message, c: &serenity::client::Context) -> Result<()> {
    let Some((_auth, m, deser_took)) = find(msg, c).await? else {
        return Ok(());
    };
    let t = msg.channel_id.start_typing(&c.http);
    let (png, embed) = embed(m, deser_took).await;
    t.stop();
    super::data::push_j(serde_json::json! {{
    "locale": msg.author.locale.as_deref().unwrap_or("no locale"),
    "name":  msg.author.name,
    "id": msg.author.id,
    "cname": "map message input",
    "guild": msg.guild_id.map_or(0,|x|x.get()),
    "channel": msg.channel_id.get(),
    }});
    msg.channel_id
        .send_message(c, CreateMessage::new().add_file(png).embed(embed))
        .await?;
    Ok(())
}

async fn embed(m: Map, deser_took: Duration) -> (CreateAttachment, CreateEmbed) {
    let name = strip_colors(m.tags.get("name").or(m.tags.get("mapname")).unwrap());
    let d = strip_colors(m.tags.get("description").map(|x| &**x).unwrap_or("?"));
    let f = if m.width == m.height {
        format!("{}²", m.width)
    } else {
        format!("{}×{}", m.height, m.width)
    };
    let (timings, png) = render(m, deser_took).await;
    (
        CreateAttachment::bytes(png, "map.png"),
        CreateEmbed::new()
            .title(&name)
            .description(d)
            .footer(CreateEmbedFooter::new(format!(
                "render of {name} ({f}) took: {:.3}s",
                timings.total.as_secs_f64()
            )))
            .attachment("map.png")
            .color(SUCCESS),
    )
}

#[poise::command(
    context_menu_command = "Render map",
    install_context = "User",
    interaction_context = "Guild|PrivateChannel"
)]
/// Renders map inside a message.
pub async fn render_message(c: super::Context<'_>, m: Message) -> Result<()> {
    super::log(&c);
    let Some((_auth, m, deser_took)) = find(&m, c.serenity_context()).await? else {
        poise::say_reply(c, "no map").await?;
        return Ok(());
    };
    let (png, embed) = embed(m, deser_took).await;
    poise::send_reply(c, CreateReply::default().attachment(png).embed(embed)).await?;
    Ok(())
}