atagen

malice

public
Star0
Watch0
0b88effaec6d8c5b10a7d8def9b9a3c999d2442f
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git

Blame: crates/malice-push/src/payload.rs

aa3dce5114d0atagen2026-10-26 06:09 UTC1
use serde_json::{Value, json};
aa3dce5114d0atagen2026-10-26 06:09 UTC2
aa3dce5114d0atagen2026-10-26 06:09 UTC3
const TITLE_CHARS: usize = 80;
aa3dce5114d0atagen2026-10-26 06:09 UTC4
const BODY_CHARS: usize = 400;
aa3dce5114d0atagen2026-10-26 06:09 UTC5
aa3dce5114d0atagen2026-10-26 06:09 UTC6
/// What a push says about its event, before it becomes notification text.
aa3dce5114d0atagen2026-10-26 06:09 UTC7
#[derive(Debug, Clone, PartialEq, Eq)]
aa3dce5114d0atagen2026-10-26 06:09 UTC8
pub enum Content {
aa3dce5114d0atagen2026-10-26 06:09 UTC9
    Text(String),
aa3dce5114d0atagen2026-10-26 06:09 UTC10
    Emote(String),
aa3dce5114d0atagen2026-10-26 06:09 UTC11
    /// e.g. "sent an image", shown after the sender's name
aa3dce5114d0atagen2026-10-26 06:09 UTC12
    Action(&'static str),
aa3dce5114d0atagen2026-10-26 06:09 UTC13
    Encrypted,
aa3dce5114d0atagen2026-10-26 06:09 UTC14
}
aa3dce5114d0atagen2026-10-26 06:09 UTC15
aa3dce5114d0atagen2026-10-26 06:09 UTC16
impl Content {
aa3dce5114d0atagen2026-10-26 06:09 UTC17
    pub fn of(event_type: Option<&str>, content: Option<&Value>, user_is_target: bool) -> Self {
aa3dce5114d0atagen2026-10-26 06:09 UTC18
        let field = |name: &str| content.and_then(|content| content.get(name)?.as_str());
aa3dce5114d0atagen2026-10-26 06:09 UTC19
        match event_type.unwrap_or_default() {
aa3dce5114d0atagen2026-10-26 06:09 UTC20
            "m.room.encrypted" => Self::Encrypted,
aa3dce5114d0atagen2026-10-26 06:09 UTC21
            "m.room.message" => match (field("msgtype"), field("body")) {
aa3dce5114d0atagen2026-10-26 06:09 UTC22
                (Some("m.text" | "m.notice"), Some(body)) => Self::Text(strip_reply_fallback(body)),
aa3dce5114d0atagen2026-10-26 06:09 UTC23
                (Some("m.emote"), Some(body)) => Self::Emote(strip_reply_fallback(body)),
aa3dce5114d0atagen2026-10-26 06:09 UTC24
                (Some("m.image"), _) => Self::Action("sent an image"),
aa3dce5114d0atagen2026-10-26 06:09 UTC25
                (Some("m.video"), _) => Self::Action("sent a video"),
aa3dce5114d0atagen2026-10-26 06:09 UTC26
                (Some("m.audio"), _) => Self::Action("sent an audio clip"),
aa3dce5114d0atagen2026-10-26 06:09 UTC27
                (Some("m.file"), _) => Self::Action("sent a file"),
aa3dce5114d0atagen2026-10-26 06:09 UTC28
                (Some("m.location"), _) => Self::Action("shared a location"),
aa3dce5114d0atagen2026-10-26 06:09 UTC29
                (_, Some(body)) => Self::Text(strip_reply_fallback(body)),
aa3dce5114d0atagen2026-10-26 06:09 UTC30
                (_, None) => Self::Action("sent a message"),
aa3dce5114d0atagen2026-10-26 06:09 UTC31
            },
aa3dce5114d0atagen2026-10-26 06:09 UTC32
            "m.sticker" => Self::Action("sent a sticker"),
aa3dce5114d0atagen2026-10-26 06:09 UTC33
            "m.room.member" if user_is_target && field("membership") == Some("invite") => {
aa3dce5114d0atagen2026-10-26 06:09 UTC34
                Self::Action("invited you")
aa3dce5114d0atagen2026-10-26 06:09 UTC35
            }
aa3dce5114d0atagen2026-10-26 06:09 UTC36
            "m.call.invite" => Self::Action("is calling"),
aa3dce5114d0atagen2026-10-26 06:09 UTC37
            "m.poll.start" | "org.matrix.msc3381.poll.start" => Self::Action("started a poll"),
aa3dce5114d0atagen2026-10-26 06:09 UTC38
            _ => Self::Action("sent a message"),
aa3dce5114d0atagen2026-10-26 06:09 UTC39
        }
aa3dce5114d0atagen2026-10-26 06:09 UTC40
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC41
aa3dce5114d0atagen2026-10-26 06:09 UTC42
    pub fn body(&self, sender: &str, direct: bool) -> String {
aa3dce5114d0atagen2026-10-26 06:09 UTC43
        match (self, direct) {
aa3dce5114d0atagen2026-10-26 06:09 UTC44
            (Self::Text(text), true) => text.clone(),
aa3dce5114d0atagen2026-10-26 06:09 UTC45
            (Self::Text(text), false) => format!("{sender}: {text}"),
aa3dce5114d0atagen2026-10-26 06:09 UTC46
            (Self::Emote(text), _) => format!("* {sender} {text}"),
aa3dce5114d0atagen2026-10-26 06:09 UTC47
            (Self::Action(action), _) => format!("{sender} {action}"),
aa3dce5114d0atagen2026-10-26 06:09 UTC48
            (Self::Encrypted, true) => "Encrypted message".to_owned(),
aa3dce5114d0atagen2026-10-26 06:09 UTC49
            (Self::Encrypted, false) => format!("{sender}: Encrypted message"),
aa3dce5114d0atagen2026-10-26 06:09 UTC50
        }
aa3dce5114d0atagen2026-10-26 06:09 UTC51
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC52
}
aa3dce5114d0atagen2026-10-26 06:09 UTC53
aa3dce5114d0atagen2026-10-26 06:09 UTC54
/// Older clients quote the replied-to message as leading "> " lines and a
aa3dce5114d0atagen2026-10-26 06:09 UTC55
/// blank line, which would crowd out the reply itself.
aa3dce5114d0atagen2026-10-26 06:09 UTC56
fn strip_reply_fallback(body: &str) -> String {
aa3dce5114d0atagen2026-10-26 06:09 UTC57
    if !body.starts_with("> ") {
aa3dce5114d0atagen2026-10-26 06:09 UTC58
        return body.to_owned();
aa3dce5114d0atagen2026-10-26 06:09 UTC59
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC60
    let mut lines = body.lines().skip_while(|line| line.starts_with('>'));
aa3dce5114d0atagen2026-10-26 06:09 UTC61
    let rest: Vec<&str> = match lines.next() {
aa3dce5114d0atagen2026-10-26 06:09 UTC62
        Some("") | None => lines.collect(),
aa3dce5114d0atagen2026-10-26 06:09 UTC63
        Some(first) => std::iter::once(first).chain(lines).collect(),
aa3dce5114d0atagen2026-10-26 06:09 UTC64
    };
aa3dce5114d0atagen2026-10-26 06:09 UTC65
    if rest.is_empty() {
aa3dce5114d0atagen2026-10-26 06:09 UTC66
        body.to_owned()
aa3dce5114d0atagen2026-10-26 06:09 UTC67
    } else {
aa3dce5114d0atagen2026-10-26 06:09 UTC68
        rest.join("\n")
aa3dce5114d0atagen2026-10-26 06:09 UTC69
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC70
}
aa3dce5114d0atagen2026-10-26 06:09 UTC71
aa3dce5114d0atagen2026-10-26 06:09 UTC72
/// The notification one push shows, in the Declarative Web Push shape so
aa3dce5114d0atagen2026-10-26 06:09 UTC73
/// Safari can display it without running the service worker.
aa3dce5114d0atagen2026-10-26 06:09 UTC74
#[derive(Debug, Clone, Default, PartialEq, Eq)]
aa3dce5114d0atagen2026-10-26 06:09 UTC75
pub struct Notice {
aa3dce5114d0atagen2026-10-26 06:09 UTC76
    pub title: String,
aa3dce5114d0atagen2026-10-26 06:09 UTC77
    pub body: String,
aa3dce5114d0atagen2026-10-26 06:09 UTC78
    pub room_id: String,
aa3dce5114d0atagen2026-10-26 06:09 UTC79
    pub event_id: String,
aa3dce5114d0atagen2026-10-26 06:09 UTC80
    /// the body is generic because the event could not be read
aa3dce5114d0atagen2026-10-26 06:09 UTC81
    pub encrypted: bool,
aa3dce5114d0atagen2026-10-26 06:09 UTC82
    pub mention: bool,
aa3dce5114d0atagen2026-10-26 06:09 UTC83
    pub silent: bool,
aa3dce5114d0atagen2026-10-26 06:09 UTC84
    pub unread: Option<u64>,
aa3dce5114d0atagen2026-10-26 06:09 UTC85
}
aa3dce5114d0atagen2026-10-26 06:09 UTC86
aa3dce5114d0atagen2026-10-26 06:09 UTC87
impl Notice {
aa3dce5114d0atagen2026-10-26 06:09 UTC88
    /// `origin` without a trailing slash, or empty for a relative `navigate`.
aa3dce5114d0atagen2026-10-26 06:09 UTC89
    pub fn to_value(&self, origin: &str) -> Value {
aa3dce5114d0atagen2026-10-26 06:09 UTC90
        let room = &self.room_id;
aa3dce5114d0atagen2026-10-26 06:09 UTC91
        let mut notification = json!({
aa3dce5114d0atagen2026-10-26 06:09 UTC92
            "title": truncate(&self.title, TITLE_CHARS),
aa3dce5114d0atagen2026-10-26 06:09 UTC93
            "body": truncate(&self.body, BODY_CHARS),
aa3dce5114d0atagen2026-10-26 06:09 UTC94
            "navigate": format!("{origin}/?room={}", query_escape(room)),
aa3dce5114d0atagen2026-10-26 06:09 UTC95
            "tag": format!("room:{room}"),
aa3dce5114d0atagen2026-10-26 06:09 UTC96
            "silent": self.silent,
aa3dce5114d0atagen2026-10-26 06:09 UTC97
        });
aa3dce5114d0atagen2026-10-26 06:09 UTC98
        if let Some(unread) = self.unread {
aa3dce5114d0atagen2026-10-26 06:09 UTC99
            notification["app_badge"] = Value::String(unread.to_string());
aa3dce5114d0atagen2026-10-26 06:09 UTC100
        }
aa3dce5114d0atagen2026-10-26 06:09 UTC101
        json!({
aa3dce5114d0atagen2026-10-26 06:09 UTC102
            "web_push": 8030,
aa3dce5114d0atagen2026-10-26 06:09 UTC103
            "notification": notification,
aa3dce5114d0atagen2026-10-26 06:09 UTC104
            "malice": {
aa3dce5114d0atagen2026-10-26 06:09 UTC105
                "room_id": room,
aa3dce5114d0atagen2026-10-26 06:09 UTC106
                "event_id": self.event_id,
aa3dce5114d0atagen2026-10-26 06:09 UTC107
                "encrypted": self.encrypted,
aa3dce5114d0atagen2026-10-26 06:09 UTC108
                "mention": self.mention,
aa3dce5114d0atagen2026-10-26 06:09 UTC109
                "unread": self.unread,
aa3dce5114d0atagen2026-10-26 06:09 UTC110
            },
aa3dce5114d0atagen2026-10-26 06:09 UTC111
        })
aa3dce5114d0atagen2026-10-26 06:09 UTC112
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC113
aa3dce5114d0atagen2026-10-26 06:09 UTC114
    pub fn to_bytes(&self, origin: &str) -> Vec<u8> {
aa3dce5114d0atagen2026-10-26 06:09 UTC115
        serde_json::to_vec(&self.to_value(origin)).expect("a json value serialises")
aa3dce5114d0atagen2026-10-26 06:09 UTC116
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC117
}
aa3dce5114d0atagen2026-10-26 06:09 UTC118
aa3dce5114d0atagen2026-10-26 06:09 UTC119
fn truncate(text: &str, max: usize) -> String {
aa3dce5114d0atagen2026-10-26 06:09 UTC120
    match text.char_indices().nth(max) {
aa3dce5114d0atagen2026-10-26 06:09 UTC121
        Some((end, _)) => format!("{}…", text[..end].trim_end()),
aa3dce5114d0atagen2026-10-26 06:09 UTC122
        None => text.to_owned(),
aa3dce5114d0atagen2026-10-26 06:09 UTC123
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC124
}
aa3dce5114d0atagen2026-10-26 06:09 UTC125
aa3dce5114d0atagen2026-10-26 06:09 UTC126
fn query_escape(value: &str) -> String {
aa3dce5114d0atagen2026-10-26 06:09 UTC127
    let mut out = String::with_capacity(value.len());
aa3dce5114d0atagen2026-10-26 06:09 UTC128
    for byte in value.bytes() {
aa3dce5114d0atagen2026-10-26 06:09 UTC129
        match byte {
aa3dce5114d0atagen2026-10-26 06:09 UTC130
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'!' | b':' => {
aa3dce5114d0atagen2026-10-26 06:09 UTC131
                out.push(byte as char);
aa3dce5114d0atagen2026-10-26 06:09 UTC132
            }
aa3dce5114d0atagen2026-10-26 06:09 UTC133
            _ => out.push_str(&format!("%{byte:02X}")),
aa3dce5114d0atagen2026-10-26 06:09 UTC134
        }
aa3dce5114d0atagen2026-10-26 06:09 UTC135
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC136
    out
aa3dce5114d0atagen2026-10-26 06:09 UTC137
}
aa3dce5114d0atagen2026-10-26 06:09 UTC138
aa3dce5114d0atagen2026-10-26 06:09 UTC139
#[cfg(test)]
aa3dce5114d0atagen2026-10-26 06:09 UTC140
mod tests {
aa3dce5114d0atagen2026-10-26 06:09 UTC141
    use super::*;
aa3dce5114d0atagen2026-10-26 06:09 UTC142
aa3dce5114d0atagen2026-10-26 06:09 UTC143
    #[test]
aa3dce5114d0atagen2026-10-26 06:09 UTC144
    fn content_reads_messages_and_hides_encrypted_ones() {
aa3dce5114d0atagen2026-10-26 06:09 UTC145
        let text = json!({"msgtype": "m.text", "body": "> <@a:x> old\n\nnew"});
aa3dce5114d0atagen2026-10-26 06:09 UTC146
        assert_eq!(
aa3dce5114d0atagen2026-10-26 06:09 UTC147
            Content::of(Some("m.room.message"), Some(&text), false),
aa3dce5114d0atagen2026-10-26 06:09 UTC148
            Content::Text("new".to_owned())
aa3dce5114d0atagen2026-10-26 06:09 UTC149
        );
aa3dce5114d0atagen2026-10-26 06:09 UTC150
        assert_eq!(
aa3dce5114d0atagen2026-10-26 06:09 UTC151
            Content::of(
aa3dce5114d0atagen2026-10-26 06:09 UTC152
                Some("m.room.encrypted"),
aa3dce5114d0atagen2026-10-26 06:09 UTC153
                Some(&json!({"ciphertext": "x"})),
aa3dce5114d0atagen2026-10-26 06:09 UTC154
                false
aa3dce5114d0atagen2026-10-26 06:09 UTC155
            ),
aa3dce5114d0atagen2026-10-26 06:09 UTC156
            Content::Encrypted
aa3dce5114d0atagen2026-10-26 06:09 UTC157
        );
aa3dce5114d0atagen2026-10-26 06:09 UTC158
        let image = json!({"msgtype": "m.image", "body": "cat.png"});
aa3dce5114d0atagen2026-10-26 06:09 UTC159
        assert_eq!(
aa3dce5114d0atagen2026-10-26 06:09 UTC160
            Content::of(Some("m.room.message"), Some(&image), false).body("Ann", false),
aa3dce5114d0atagen2026-10-26 06:09 UTC161
            "Ann sent an image"
aa3dce5114d0atagen2026-10-26 06:09 UTC162
        );
aa3dce5114d0atagen2026-10-26 06:09 UTC163
        assert_eq!(Content::Text("hi".to_owned()).body("Ann", false), "Ann: hi");
aa3dce5114d0atagen2026-10-26 06:09 UTC164
        assert_eq!(Content::Text("hi".to_owned()).body("Ann", true), "hi");
aa3dce5114d0atagen2026-10-26 06:09 UTC165
        let invite = json!({"membership": "invite"});
aa3dce5114d0atagen2026-10-26 06:09 UTC166
        assert_eq!(
aa3dce5114d0atagen2026-10-26 06:09 UTC167
            Content::of(Some("m.room.member"), Some(&invite), true),
aa3dce5114d0atagen2026-10-26 06:09 UTC168
            Content::Action("invited you")
aa3dce5114d0atagen2026-10-26 06:09 UTC169
        );
aa3dce5114d0atagen2026-10-26 06:09 UTC170
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC171
aa3dce5114d0atagen2026-10-26 06:09 UTC172
    #[test]
aa3dce5114d0atagen2026-10-26 06:09 UTC173
    fn notice_matches_the_wire_contract_and_stays_small() {
aa3dce5114d0atagen2026-10-26 06:09 UTC174
        let notice = Notice {
aa3dce5114d0atagen2026-10-26 06:09 UTC175
            title: "Room".to_owned(),
aa3dce5114d0atagen2026-10-26 06:09 UTC176
            body: "x".repeat(5000),
aa3dce5114d0atagen2026-10-26 06:09 UTC177
            room_id: "!r:example.org".to_owned(),
aa3dce5114d0atagen2026-10-26 06:09 UTC178
            event_id: "$e".to_owned(),
aa3dce5114d0atagen2026-10-26 06:09 UTC179
            encrypted: false,
aa3dce5114d0atagen2026-10-26 06:09 UTC180
            mention: true,
aa3dce5114d0atagen2026-10-26 06:09 UTC181
            silent: false,
aa3dce5114d0atagen2026-10-26 06:09 UTC182
            unread: Some(3),
aa3dce5114d0atagen2026-10-26 06:09 UTC183
        };
aa3dce5114d0atagen2026-10-26 06:09 UTC184
        let value = notice.to_value("https://chat.example");
aa3dce5114d0atagen2026-10-26 06:09 UTC185
        assert_eq!(value["web_push"], 8030);
aa3dce5114d0atagen2026-10-26 06:09 UTC186
        assert_eq!(
aa3dce5114d0atagen2026-10-26 06:09 UTC187
            value["notification"]["navigate"],
aa3dce5114d0atagen2026-10-26 06:09 UTC188
            "https://chat.example/?room=!r:example.org"
aa3dce5114d0atagen2026-10-26 06:09 UTC189
        );
aa3dce5114d0atagen2026-10-26 06:09 UTC190
        assert_eq!(value["notification"]["tag"], "room:!r:example.org");
aa3dce5114d0atagen2026-10-26 06:09 UTC191
        assert_eq!(value["notification"]["app_badge"], "3");
aa3dce5114d0atagen2026-10-26 06:09 UTC192
        assert_eq!(value["malice"]["unread"], 3);
aa3dce5114d0atagen2026-10-26 06:09 UTC193
        assert_eq!(value["malice"]["mention"], true);
aa3dce5114d0atagen2026-10-26 06:09 UTC194
        assert!(notice.to_bytes("https://chat.example").len() < 3 * 1024);
aa3dce5114d0atagen2026-10-26 06:09 UTC195
        let unknown = Notice {
aa3dce5114d0atagen2026-10-26 06:09 UTC196
            unread: None,
aa3dce5114d0atagen2026-10-26 06:09 UTC197
            ..notice
aa3dce5114d0atagen2026-10-26 06:09 UTC198
        }
aa3dce5114d0atagen2026-10-26 06:09 UTC199
        .to_value("");
aa3dce5114d0atagen2026-10-26 06:09 UTC200
        assert!(unknown["notification"].get("app_badge").is_none());
aa3dce5114d0atagen2026-10-26 06:09 UTC201
        assert_eq!(unknown["malice"]["unread"], Value::Null);
aa3dce5114d0atagen2026-10-26 06:09 UTC202
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC203
}