atagen

malice

public
Star0
Watch0
984f84f9bf6c54c04625b38858111a7760036f51
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
crates/malice-push/src/payload.rsRust203 lines7.3 KB
1
use serde_json::{Value, json};
2
3
const TITLE_CHARS: usize = 80;
4
const BODY_CHARS: usize = 400;
5
6
/// What a push says about its event, before it becomes notification text.
7
#[derive(Debug, Clone, PartialEq, Eq)]
8
pub enum Content {
9
    Text(String),
10
    Emote(String),
11
    /// e.g. "sent an image", shown after the sender's name
12
    Action(&'static str),
13
    Encrypted,
14
}
15
16
impl Content {
17
    pub fn of(event_type: Option<&str>, content: Option<&Value>, user_is_target: bool) -> Self {
18
        let field = |name: &str| content.and_then(|content| content.get(name)?.as_str());
19
        match event_type.unwrap_or_default() {
20
            "m.room.encrypted" => Self::Encrypted,
21
            "m.room.message" => match (field("msgtype"), field("body")) {
22
                (Some("m.text" | "m.notice"), Some(body)) => Self::Text(strip_reply_fallback(body)),
23
                (Some("m.emote"), Some(body)) => Self::Emote(strip_reply_fallback(body)),
24
                (Some("m.image"), _) => Self::Action("sent an image"),
25
                (Some("m.video"), _) => Self::Action("sent a video"),
26
                (Some("m.audio"), _) => Self::Action("sent an audio clip"),
27
                (Some("m.file"), _) => Self::Action("sent a file"),
28
                (Some("m.location"), _) => Self::Action("shared a location"),
29
                (_, Some(body)) => Self::Text(strip_reply_fallback(body)),
30
                (_, None) => Self::Action("sent a message"),
31
            },
32
            "m.sticker" => Self::Action("sent a sticker"),
33
            "m.room.member" if user_is_target && field("membership") == Some("invite") => {
34
                Self::Action("invited you")
35
            }
36
            "m.call.invite" => Self::Action("is calling"),
37
            "m.poll.start" | "org.matrix.msc3381.poll.start" => Self::Action("started a poll"),
38
            _ => Self::Action("sent a message"),
39
        }
40
    }
41
42
    pub fn body(&self, sender: &str, direct: bool) -> String {
43
        match (self, direct) {
44
            (Self::Text(text), true) => text.clone(),
45
            (Self::Text(text), false) => format!("{sender}: {text}"),
46
            (Self::Emote(text), _) => format!("* {sender} {text}"),
47
            (Self::Action(action), _) => format!("{sender} {action}"),
48
            (Self::Encrypted, true) => "Encrypted message".to_owned(),
49
            (Self::Encrypted, false) => format!("{sender}: Encrypted message"),
50
        }
51
    }
52
}
53
54
/// Older clients quote the replied-to message as leading "> " lines and a
55
/// blank line, which would crowd out the reply itself.
56
fn strip_reply_fallback(body: &str) -> String {
57
    if !body.starts_with("> ") {
58
        return body.to_owned();
59
    }
60
    let mut lines = body.lines().skip_while(|line| line.starts_with('>'));
61
    let rest: Vec<&str> = match lines.next() {
62
        Some("") | None => lines.collect(),
63
        Some(first) => std::iter::once(first).chain(lines).collect(),
64
    };
65
    if rest.is_empty() {
66
        body.to_owned()
67
    } else {
68
        rest.join("\n")
69
    }
70
}
71
72
/// The notification one push shows, in the Declarative Web Push shape so
73
/// Safari can display it without running the service worker.
74
#[derive(Debug, Clone, Default, PartialEq, Eq)]
75
pub struct Notice {
76
    pub title: String,
77
    pub body: String,
78
    pub room_id: String,
79
    pub event_id: String,
80
    /// the body is generic because the event could not be read
81
    pub encrypted: bool,
82
    pub mention: bool,
83
    pub silent: bool,
84
    pub unread: Option<u64>,
85
}
86
87
impl Notice {
88
    /// `origin` without a trailing slash, or empty for a relative `navigate`.
89
    pub fn to_value(&self, origin: &str) -> Value {
90
        let room = &self.room_id;
91
        let mut notification = json!({
92
            "title": truncate(&self.title, TITLE_CHARS),
93
            "body": truncate(&self.body, BODY_CHARS),
94
            "navigate": format!("{origin}/?room={}", query_escape(room)),
95
            "tag": format!("room:{room}"),
96
            "silent": self.silent,
97
        });
98
        if let Some(unread) = self.unread {
99
            notification["app_badge"] = Value::String(unread.to_string());
100
        }
101
        json!({
102
            "web_push": 8030,
103
            "notification": notification,
104
            "malice": {
105
                "room_id": room,
106
                "event_id": self.event_id,
107
                "encrypted": self.encrypted,
108
                "mention": self.mention,
109
                "unread": self.unread,
110
            },
111
        })
112
    }
113
114
    pub fn to_bytes(&self, origin: &str) -> Vec<u8> {
115
        serde_json::to_vec(&self.to_value(origin)).expect("a json value serialises")
116
    }
117
}
118
119
fn truncate(text: &str, max: usize) -> String {
120
    match text.char_indices().nth(max) {
121
        Some((end, _)) => format!("{}…", text[..end].trim_end()),
122
        None => text.to_owned(),
123
    }
124
}
125
126
fn query_escape(value: &str) -> String {
127
    let mut out = String::with_capacity(value.len());
128
    for byte in value.bytes() {
129
        match byte {
130
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'!' | b':' => {
131
                out.push(byte as char);
132
            }
133
            _ => out.push_str(&format!("%{byte:02X}")),
134
        }
135
    }
136
    out
137
}
138
139
#[cfg(test)]
140
mod tests {
141
    use super::*;
142
143
    #[test]
144
    fn content_reads_messages_and_hides_encrypted_ones() {
145
        let text = json!({"msgtype": "m.text", "body": "> <@a:x> old\n\nnew"});
146
        assert_eq!(
147
            Content::of(Some("m.room.message"), Some(&text), false),
148
            Content::Text("new".to_owned())
149
        );
150
        assert_eq!(
151
            Content::of(
152
                Some("m.room.encrypted"),
153
                Some(&json!({"ciphertext": "x"})),
154
                false
155
            ),
156
            Content::Encrypted
157
        );
158
        let image = json!({"msgtype": "m.image", "body": "cat.png"});
159
        assert_eq!(
160
            Content::of(Some("m.room.message"), Some(&image), false).body("Ann", false),
161
            "Ann sent an image"
162
        );
163
        assert_eq!(Content::Text("hi".to_owned()).body("Ann", false), "Ann: hi");
164
        assert_eq!(Content::Text("hi".to_owned()).body("Ann", true), "hi");
165
        let invite = json!({"membership": "invite"});
166
        assert_eq!(
167
            Content::of(Some("m.room.member"), Some(&invite), true),
168
            Content::Action("invited you")
169
        );
170
    }
171
172
    #[test]
173
    fn notice_matches_the_wire_contract_and_stays_small() {
174
        let notice = Notice {
175
            title: "Room".to_owned(),
176
            body: "x".repeat(5000),
177
            room_id: "!r:example.org".to_owned(),
178
            event_id: "$e".to_owned(),
179
            encrypted: false,
180
            mention: true,
181
            silent: false,
182
            unread: Some(3),
183
        };
184
        let value = notice.to_value("https://chat.example");
185
        assert_eq!(value["web_push"], 8030);
186
        assert_eq!(
187
            value["notification"]["navigate"],
188
            "https://chat.example/?room=!r:example.org"
189
        );
190
        assert_eq!(value["notification"]["tag"], "room:!r:example.org");
191
        assert_eq!(value["notification"]["app_badge"], "3");
192
        assert_eq!(value["malice"]["unread"], 3);
193
        assert_eq!(value["malice"]["mention"], true);
194
        assert!(notice.to_bytes("https://chat.example").len() < 3 * 1024);
195
        let unknown = Notice {
196
            unread: None,
197
            ..notice
198
        }
199
        .to_value("");
200
        assert!(unknown["notification"].get("app_badge").is_none());
201
        assert_eq!(unknown["malice"]["unread"], Value::Null);
202
    }
203
}