mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 04:55:42 +09:00
794 lines
26 KiB
Rust
794 lines
26 KiB
Rust
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
use crate::job::{ClearJob, MessageJob, RingJob};
|
|
use crate::metrics::PayloadShrink;
|
|
use crate::unix_seconds;
|
|
use serde_json::{Map, Value, json};
|
|
|
|
const WEB_PUSH_MARKER: u64 = 8030;
|
|
const CLEAR_TYPE: &str = "notification_clear";
|
|
const CLEAR_ACTION: &str = "clear_channel";
|
|
const RING_TYPE: &str = "call_ring";
|
|
const FALLBACK_TAG: &str = "fluxer-message";
|
|
const FALLBACK_TITLE: &str = "Fluxer";
|
|
const APNS_CATEGORY: &str = "fluxer_message";
|
|
const FCM_CLICK_ACTION: &str = "FLUXER_MESSAGE";
|
|
const APNS_SOUND: &str = "default";
|
|
const APNS_ALERT_EXPIRATION_SECONDS: i64 = 86_400;
|
|
const APNS_BACKGROUND_EXPIRATION_SECONDS: i64 = 86_400;
|
|
const APNS_COLLAPSE_ID_MAX_BYTES: usize = 64;
|
|
const SHRUNK_BODY_MAX_BYTES: usize = 40;
|
|
const MINIMAL_TITLE_MAX_BYTES: usize = 120;
|
|
const MEDIA_KEYS: [&str; 2] = ["image_url", "image"];
|
|
const ICON_KEYS: [&str; 3] = ["icon", "badge", "author_avatar_url"];
|
|
const RING_CALLER_ID_KEY: &str = "caller_id";
|
|
const RING_CALLER_NAME_KEY: &str = "caller_name";
|
|
const RING_CALLER_AVATAR_KEY: &str = "caller_avatar_url";
|
|
const RING_AVATAR_KEYS: [&str; 1] = [RING_CALLER_AVATAR_KEY];
|
|
const RING_IDENTITY_KEYS: [&str; 2] = [RING_CALLER_ID_KEY, RING_CALLER_NAME_KEY];
|
|
const THREAD_DATA_KEYS: [&str; 3] = ["parent_id", "parent_name", "channel_name"];
|
|
const NOTIF_TYPE_KEY: &str = "notif_type_id";
|
|
const FORUM_THREAD_CREATED_NOTIF_TYPE: u64 = 9;
|
|
const FCM_DEFAULT_CHANNEL_ID: &str = "fluxer_default_push";
|
|
const FCM_FORUM_THREAD_CREATED_CHANNEL_ID: &str = "fluxer_forum_thread_created";
|
|
const MINIMAL_DATA_KEYS: [&str; 11] = [
|
|
"type",
|
|
"action",
|
|
"channel_id",
|
|
"parent_id",
|
|
NOTIF_TYPE_KEY,
|
|
"message_id",
|
|
"guild_id",
|
|
"target_user_id",
|
|
"notification_tag",
|
|
"url",
|
|
"badge_count",
|
|
];
|
|
|
|
pub fn web_push_message(job: &MessageJob, target_user_id: &str, badge_count: u32) -> Value {
|
|
let fields = &job.notification;
|
|
let image_url = fields.image_url.as_deref();
|
|
let guild_id = if job.is_direct_message() {
|
|
Value::Null
|
|
} else {
|
|
Value::String(job.guild_id.clone())
|
|
};
|
|
let mut data = json!({
|
|
"channel_id": job.channel_id,
|
|
"author_avatar_url": fields.icon,
|
|
"message_id": job.message_id,
|
|
"notification_tag": fields.notification_tag,
|
|
"guild_id": guild_id,
|
|
"url": fields.url,
|
|
"badge_count": badge_count,
|
|
"target_user_id": target_user_id,
|
|
"has_media": image_url.is_some(),
|
|
});
|
|
merge_thread_fields(&mut data, job);
|
|
let notification = json!({
|
|
"title": fields.title,
|
|
"body": fields.body,
|
|
"icon": fields.icon,
|
|
"badge": fields.badge,
|
|
"tag": fields.tag,
|
|
"navigate": fields.url,
|
|
"app_badge": badge_count.to_string(),
|
|
"data": data,
|
|
});
|
|
let mut envelope = json!({
|
|
"web_push": WEB_PUSH_MARKER,
|
|
"notification": notification,
|
|
"title": fields.title,
|
|
"body": fields.body,
|
|
"icon": fields.icon,
|
|
"badge": fields.badge,
|
|
"tag": fields.tag,
|
|
"data": data,
|
|
});
|
|
merge_image_fields(&mut envelope, image_url);
|
|
envelope
|
|
}
|
|
|
|
fn merge_thread_fields(data: &mut Value, job: &MessageJob) {
|
|
let Some(object) = data.as_object_mut() else {
|
|
return;
|
|
};
|
|
let fields = [
|
|
job.parent_id.as_deref(),
|
|
job.parent_name.as_deref(),
|
|
job.channel_name.as_deref(),
|
|
];
|
|
for (key, value) in THREAD_DATA_KEYS.into_iter().zip(fields) {
|
|
if let Some(value) = value.filter(|value| !value.is_empty()) {
|
|
object.insert(key.to_owned(), value.into());
|
|
}
|
|
}
|
|
if job.is_forum_thread_created() {
|
|
object.insert(
|
|
NOTIF_TYPE_KEY.to_owned(),
|
|
FORUM_THREAD_CREATED_NOTIF_TYPE.into(),
|
|
);
|
|
}
|
|
}
|
|
|
|
pub fn web_push_clear(job: &ClearJob, badge_count: u32) -> Value {
|
|
let tag = format!("channel:{}", job.channel_id);
|
|
let data = json!({
|
|
"type": CLEAR_TYPE,
|
|
"action": CLEAR_ACTION,
|
|
"channel_id": job.channel_id,
|
|
"message_id": job.message_id,
|
|
"target_user_id": job.user_id,
|
|
"notification_tag": tag,
|
|
"tag": tag,
|
|
"badge_count": badge_count,
|
|
});
|
|
json!({
|
|
"type": CLEAR_TYPE,
|
|
"action": CLEAR_ACTION,
|
|
"silent": true,
|
|
"tag": tag,
|
|
"notification_tag": tag,
|
|
"data": data,
|
|
"badge_count": badge_count,
|
|
"web_push": WEB_PUSH_MARKER,
|
|
"notification": {
|
|
"tag": tag,
|
|
"data": data,
|
|
"silent": true,
|
|
"close": true,
|
|
},
|
|
})
|
|
}
|
|
|
|
pub fn web_push_call_ring(job: &RingJob) -> Value {
|
|
let mut data = json!({
|
|
"type": RING_TYPE,
|
|
"channel_id": job.channel_id,
|
|
"message_id": job.message_id,
|
|
"target_user_id": job.user_id,
|
|
"started_at_ms": job.started_at_ms,
|
|
"expires_at_ms": job.expires_at_ms,
|
|
});
|
|
put_caller(&mut data, job);
|
|
json!({
|
|
"web_push": WEB_PUSH_MARKER,
|
|
"type": RING_TYPE,
|
|
"data": data,
|
|
})
|
|
}
|
|
|
|
fn put_caller(data: &mut Value, job: &RingJob) {
|
|
let Some(object) = data.as_object_mut() else {
|
|
return;
|
|
};
|
|
let caller = [
|
|
(RING_CALLER_ID_KEY, job.caller_id.as_deref()),
|
|
(RING_CALLER_NAME_KEY, job.caller_name.as_deref()),
|
|
(RING_CALLER_AVATAR_KEY, job.caller_avatar_url.as_deref()),
|
|
];
|
|
for (key, value) in caller {
|
|
if let Some(value) = value.filter(|value| !value.is_empty()) {
|
|
object.insert(key.to_owned(), value.into());
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn fcm_message(device_token: &str, envelope: &Value) -> Value {
|
|
if is_clear(envelope) {
|
|
return fcm_clear_message(device_token, envelope);
|
|
}
|
|
fcm_notification_message(device_token, envelope)
|
|
}
|
|
|
|
fn fcm_clear_message(device_token: &str, envelope: &Value) -> Value {
|
|
let tag = match envelope.get("notification_tag") {
|
|
Some(value) => value.as_str().unwrap_or(FALLBACK_TAG),
|
|
None => envelope
|
|
.get("tag")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or(FALLBACK_TAG),
|
|
};
|
|
let mut data = stringified_data(envelope.get("data"));
|
|
data.insert("type".to_owned(), CLEAR_TYPE.into());
|
|
data.insert("action".to_owned(), CLEAR_ACTION.into());
|
|
data.insert("notification_tag".to_owned(), tag.into());
|
|
json!({
|
|
"message": {
|
|
"token": device_token,
|
|
"data": data,
|
|
"android": {
|
|
"priority": "NORMAL",
|
|
"ttl": "86400s",
|
|
"collapse_key": format!("clear:{tag}"),
|
|
},
|
|
"fcm_options": {"analytics_label": CLEAR_TYPE},
|
|
},
|
|
})
|
|
}
|
|
|
|
fn fcm_notification_message(device_token: &str, envelope: &Value) -> Value {
|
|
let notification = envelope.get("notification");
|
|
let title = sanitized_text(fcm_text(notification, envelope, "title", FALLBACK_TITLE));
|
|
let body = sanitized_text(fcm_text(notification, envelope, "body", ""));
|
|
let tag = envelope
|
|
.get("tag")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or(FALLBACK_TAG);
|
|
let image_url = first_non_empty([
|
|
field(envelope, "image_url"),
|
|
notification.and_then(|value| field(value, "image")),
|
|
notification.and_then(|value| field(value, "image_url")),
|
|
]);
|
|
let mut notification_body = json!({"title": title, "body": body});
|
|
put_image(&mut notification_body, image_url);
|
|
let mut data = stringified_data(envelope.get("data"));
|
|
data.insert("title".to_owned(), title.as_str().into());
|
|
data.insert("body".to_owned(), body.as_str().into());
|
|
data.insert("tag".to_owned(), tag.into());
|
|
if let Some(url) = image_url {
|
|
data.insert("image_url".to_owned(), url.into());
|
|
}
|
|
let mut android_notification = json!({
|
|
"channel_id": fcm_channel_id(envelope),
|
|
"tag": tag,
|
|
"click_action": FCM_CLICK_ACTION,
|
|
});
|
|
put_image(&mut android_notification, image_url);
|
|
json!({
|
|
"message": {
|
|
"token": device_token,
|
|
"notification": notification_body,
|
|
"data": data,
|
|
"android": {
|
|
"priority": "HIGH",
|
|
"ttl": "86400s",
|
|
"notification": android_notification,
|
|
},
|
|
"fcm_options": {"analytics_label": "message_create"},
|
|
},
|
|
})
|
|
}
|
|
|
|
fn fcm_channel_id(envelope: &Value) -> &'static str {
|
|
let notif_type = envelope
|
|
.get("data")
|
|
.and_then(|data| data.get(NOTIF_TYPE_KEY))
|
|
.and_then(Value::as_u64);
|
|
if notif_type == Some(FORUM_THREAD_CREATED_NOTIF_TYPE) {
|
|
FCM_FORUM_THREAD_CREATED_CHANNEL_ID
|
|
} else {
|
|
FCM_DEFAULT_CHANNEL_ID
|
|
}
|
|
}
|
|
|
|
pub fn apns_payload(envelope: &Value) -> Value {
|
|
let data = envelope.get("data");
|
|
let mut payload = data.and_then(Value::as_object).cloned().unwrap_or_default();
|
|
if is_clear(envelope) {
|
|
payload.insert("type".to_owned(), CLEAR_TYPE.into());
|
|
payload.insert("action".to_owned(), CLEAR_ACTION.into());
|
|
payload.insert("aps".to_owned(), json!({"content-available": 1}));
|
|
return Value::Object(payload);
|
|
}
|
|
let notification = envelope.get("notification");
|
|
let title = notification
|
|
.and_then(|value| field(value, "title"))
|
|
.or_else(|| field(envelope, "title"))
|
|
.unwrap_or(FALLBACK_TITLE);
|
|
let body = notification
|
|
.and_then(|value| field(value, "body"))
|
|
.or_else(|| field(envelope, "body"))
|
|
.unwrap_or("");
|
|
let url = data
|
|
.and_then(|data| field(data, "url"))
|
|
.or_else(|| notification.and_then(|value| field(value, "navigate")));
|
|
let image_url = first_non_empty([
|
|
field(envelope, "image_url"),
|
|
notification.and_then(|value| field(value, "image")),
|
|
]);
|
|
let thread_id = data
|
|
.and_then(|data| field(data, "notification_tag"))
|
|
.map(str::to_owned)
|
|
.or_else(|| {
|
|
data.and_then(|data| field(data, "channel_id"))
|
|
.map(|channel_id| format!("channel:{channel_id}"))
|
|
})
|
|
.unwrap_or_else(|| FALLBACK_TAG.to_owned());
|
|
let mut aps = Map::new();
|
|
aps.insert("alert".to_owned(), json!({"title": title, "body": body}));
|
|
aps.insert("sound".to_owned(), APNS_SOUND.into());
|
|
aps.insert("thread-id".to_owned(), thread_id.into());
|
|
aps.insert("category".to_owned(), APNS_CATEGORY.into());
|
|
aps.insert("interruption-level".to_owned(), "active".into());
|
|
aps.insert("relevance-score".to_owned(), Value::from(0.5));
|
|
if let Some(badge) = badge_number(data.and_then(|data| data.get("badge_count"))) {
|
|
aps.insert("badge".to_owned(), badge.into());
|
|
}
|
|
aps.insert("mutable-content".to_owned(), 1.into());
|
|
payload.insert("title".to_owned(), title.into());
|
|
payload.insert("body".to_owned(), body.into());
|
|
payload.remove("url");
|
|
if let Some(url) = url {
|
|
payload.insert("url".to_owned(), url.into());
|
|
}
|
|
payload.remove("image_url");
|
|
if let Some(image_url) = image_url {
|
|
payload.insert("image_url".to_owned(), image_url.into());
|
|
}
|
|
payload.insert("aps".to_owned(), Value::Object(aps));
|
|
Value::Object(payload)
|
|
}
|
|
|
|
pub fn apns_delivery_headers(envelope: &Value) -> Vec<(String, String)> {
|
|
let clear = is_clear(envelope);
|
|
let expiration = unix_seconds()
|
|
+ if clear {
|
|
APNS_BACKGROUND_EXPIRATION_SECONDS
|
|
} else {
|
|
APNS_ALERT_EXPIRATION_SECONDS
|
|
};
|
|
let mut headers = vec![
|
|
(
|
|
"apns-push-type".to_owned(),
|
|
if clear { "background" } else { "alert" }.to_owned(),
|
|
),
|
|
(
|
|
"apns-priority".to_owned(),
|
|
if clear { "5" } else { "10" }.to_owned(),
|
|
),
|
|
("apns-expiration".to_owned(), expiration.to_string()),
|
|
("content-type".to_owned(), "application/json".to_owned()),
|
|
];
|
|
let collapse_id = field(envelope, "tag").or_else(|| {
|
|
envelope
|
|
.get("data")
|
|
.and_then(|data| field(data, "message_id"))
|
|
});
|
|
if let Some(collapse_id) = collapse_id.filter(|id| id.len() <= APNS_COLLAPSE_ID_MAX_BYTES) {
|
|
headers.push(("apns-collapse-id".to_owned(), collapse_id.to_owned()));
|
|
}
|
|
headers
|
|
}
|
|
|
|
fn put_image(target: &mut Value, image_url: Option<&str>) {
|
|
let (Some(image_url), Some(object)) = (image_url, target.as_object_mut()) else {
|
|
return;
|
|
};
|
|
object.insert("image".to_owned(), image_url.into());
|
|
}
|
|
|
|
fn fcm_text<'a>(
|
|
notification: Option<&'a Value>,
|
|
envelope: &'a Value,
|
|
key: &str,
|
|
fallback: &'a str,
|
|
) -> &'a str {
|
|
notification
|
|
.and_then(|value| value.get(key))
|
|
.or_else(|| envelope.get(key))
|
|
.and_then(Value::as_str)
|
|
.unwrap_or(fallback)
|
|
}
|
|
|
|
fn stringified_data(data: Option<&Value>) -> Map<String, Value> {
|
|
data.and_then(Value::as_object)
|
|
.map(|data| {
|
|
data.iter()
|
|
.map(|(key, value)| (key.clone(), stringified(value)))
|
|
.collect()
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn stringified(value: &Value) -> Value {
|
|
match value {
|
|
Value::String(text) => Value::String(text.clone()),
|
|
Value::Null => Value::String("null".to_owned()),
|
|
Value::Bool(flag) => Value::String(flag.to_string()),
|
|
Value::Number(number) => Value::String(number.to_string()),
|
|
other => Value::String(other.to_string()),
|
|
}
|
|
}
|
|
|
|
fn sanitized_text(value: &str) -> String {
|
|
value
|
|
.chars()
|
|
.filter(|character| displayable(*character))
|
|
.collect()
|
|
}
|
|
|
|
fn displayable(character: char) -> bool {
|
|
match character {
|
|
'\n' | '\t' => true,
|
|
'\u{0}'..='\u{1f}'
|
|
| '\u{7f}'..='\u{9f}'
|
|
| '\u{200e}'..='\u{200f}'
|
|
| '\u{202a}'..='\u{202e}'
|
|
| '\u{2066}'..='\u{2069}' => false,
|
|
_ => true,
|
|
}
|
|
}
|
|
|
|
fn field<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
|
|
value.get(key).and_then(non_empty)
|
|
}
|
|
|
|
fn first_non_empty<const N: usize>(candidates: [Option<&str>; N]) -> Option<&str> {
|
|
candidates.into_iter().flatten().next()
|
|
}
|
|
|
|
fn badge_number(value: Option<&Value>) -> Option<u64> {
|
|
match value? {
|
|
Value::Number(number) => {
|
|
let badge = number.as_f64()?;
|
|
badge.is_finite().then(|| badge.max(0.0).trunc() as u64)
|
|
}
|
|
Value::String(text) => text
|
|
.parse::<i64>()
|
|
.ok()
|
|
.map(|badge| badge.max(0).unsigned_abs()),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum RecordKind {
|
|
Message,
|
|
Clear,
|
|
Ring,
|
|
}
|
|
|
|
pub fn record_kind(envelope: &Value) -> RecordKind {
|
|
let kind = envelope.get("type").and_then(Value::as_str);
|
|
if kind == Some(RING_TYPE) {
|
|
return RecordKind::Ring;
|
|
}
|
|
if kind == Some(CLEAR_TYPE)
|
|
|| envelope.get("action").and_then(Value::as_str) == Some(CLEAR_ACTION)
|
|
{
|
|
return RecordKind::Clear;
|
|
}
|
|
RecordKind::Message
|
|
}
|
|
|
|
pub fn is_clear(envelope: &Value) -> bool {
|
|
matches!(record_kind(envelope), RecordKind::Clear)
|
|
}
|
|
|
|
pub fn fit(envelope: &Value, budget: usize) -> (Vec<u8>, Option<PayloadShrink>) {
|
|
let serialized = serialize(envelope);
|
|
if serialized.len() <= budget {
|
|
return (serialized, None);
|
|
}
|
|
if matches!(record_kind(envelope), RecordKind::Ring) {
|
|
return fit_ring(envelope, budget);
|
|
}
|
|
let mut working = envelope.clone();
|
|
for step in PayloadShrink::ALL {
|
|
working = shrink(&working, step, budget);
|
|
let serialized = serialize(&working);
|
|
if serialized.len() <= budget {
|
|
return (serialized, Some(step));
|
|
}
|
|
}
|
|
(serialize(&working), Some(PayloadShrink::Minimal))
|
|
}
|
|
|
|
fn fit_ring(envelope: &Value, budget: usize) -> (Vec<u8>, Option<PayloadShrink>) {
|
|
let mut working = envelope.clone();
|
|
drop_keys(&mut working, &RING_AVATAR_KEYS);
|
|
let serialized = serialize(&working);
|
|
if serialized.len() <= budget {
|
|
return (serialized, Some(PayloadShrink::Icons));
|
|
}
|
|
drop_keys(&mut working, &RING_IDENTITY_KEYS);
|
|
(serialize(&working), Some(PayloadShrink::Minimal))
|
|
}
|
|
|
|
fn shrink(envelope: &Value, step: PayloadShrink, budget: usize) -> Value {
|
|
let mut working = envelope.clone();
|
|
match step {
|
|
PayloadShrink::Media => drop_keys(&mut working, &MEDIA_KEYS),
|
|
PayloadShrink::Icons => drop_keys(&mut working, &ICON_KEYS),
|
|
PayloadShrink::Body => truncate_body(&mut working),
|
|
PayloadShrink::Minimal => working = minimal(envelope, budget),
|
|
}
|
|
working
|
|
}
|
|
|
|
fn minimal(envelope: &Value, budget: usize) -> Value {
|
|
let title = truncate_bytes(
|
|
first_text(envelope, "title").unwrap_or(FALLBACK_TITLE),
|
|
MINIMAL_TITLE_MAX_BYTES,
|
|
);
|
|
let tag = first_text(envelope, "tag")
|
|
.unwrap_or(FALLBACK_TAG)
|
|
.to_owned();
|
|
let url = first_text(envelope, "navigate")
|
|
.or_else(|| {
|
|
envelope
|
|
.get("data")
|
|
.and_then(|data| data.get("url"))
|
|
.and_then(non_empty)
|
|
})
|
|
.unwrap_or_default()
|
|
.to_owned();
|
|
let data = minimal_data(envelope.get("data"));
|
|
|
|
let candidates = [
|
|
minimal_envelope(&title, &tag, &url, data.clone()),
|
|
minimal_envelope(&title, &tag, "", data),
|
|
minimal_envelope(&title, &tag, "", Map::new()),
|
|
minimal_envelope(&title, "", "", Map::new()),
|
|
];
|
|
for candidate in candidates {
|
|
if serialize(&candidate).len() <= budget {
|
|
return candidate;
|
|
}
|
|
}
|
|
title_only(&title, budget)
|
|
}
|
|
|
|
fn minimal_envelope(title: &str, tag: &str, url: &str, data: Map<String, Value>) -> Value {
|
|
json!({
|
|
"web_push": WEB_PUSH_MARKER,
|
|
"title": title,
|
|
"tag": tag,
|
|
"data": data,
|
|
"notification": {
|
|
"title": title,
|
|
"tag": tag,
|
|
"navigate": url,
|
|
"data": data,
|
|
},
|
|
})
|
|
}
|
|
|
|
fn title_only(title: &str, budget: usize) -> Value {
|
|
let mut allowance = title.len();
|
|
loop {
|
|
let candidate = json!({
|
|
"web_push": WEB_PUSH_MARKER,
|
|
"title": truncate_bytes(title, allowance),
|
|
});
|
|
if allowance == 0 || serialize(&candidate).len() <= budget {
|
|
return candidate;
|
|
}
|
|
allowance -= 1;
|
|
}
|
|
}
|
|
|
|
fn minimal_data(data: Option<&Value>) -> Map<String, Value> {
|
|
let Some(data) = data.and_then(Value::as_object) else {
|
|
return Map::new();
|
|
};
|
|
MINIMAL_DATA_KEYS
|
|
.into_iter()
|
|
.filter_map(|key| data.get(key).map(|value| (key.to_owned(), value.clone())))
|
|
.collect()
|
|
}
|
|
|
|
fn drop_keys(envelope: &mut Value, keys: &[&str]) {
|
|
for_each_block(envelope, &mut |block| {
|
|
for key in keys {
|
|
block.remove(*key);
|
|
}
|
|
});
|
|
}
|
|
|
|
fn truncate_body(envelope: &mut Value) {
|
|
for_each_block(envelope, &mut |block| {
|
|
let Some(body) = block.get("body").and_then(Value::as_str) else {
|
|
return;
|
|
};
|
|
let shortened = truncate_bytes(body, SHRUNK_BODY_MAX_BYTES);
|
|
block.insert("body".to_owned(), shortened.into());
|
|
});
|
|
}
|
|
|
|
fn for_each_block(envelope: &mut Value, apply: &mut impl FnMut(&mut Map<String, Value>)) {
|
|
let Some(root) = envelope.as_object_mut() else {
|
|
return;
|
|
};
|
|
for key in ["notification", "data"] {
|
|
if let Some(nested) = root.get_mut(key) {
|
|
for_each_block(nested, apply);
|
|
}
|
|
}
|
|
apply(root);
|
|
}
|
|
|
|
fn truncate_bytes(text: &str, max_bytes: usize) -> String {
|
|
if text.len() <= max_bytes {
|
|
return text.to_owned();
|
|
}
|
|
let mut end = max_bytes;
|
|
while end > 0 && !text.is_char_boundary(end) {
|
|
end -= 1;
|
|
}
|
|
text[..end].to_owned()
|
|
}
|
|
|
|
fn first_text<'a>(envelope: &'a Value, key: &str) -> Option<&'a str> {
|
|
envelope
|
|
.get(key)
|
|
.and_then(non_empty)
|
|
.or_else(|| envelope.get("notification")?.get(key).and_then(non_empty))
|
|
}
|
|
|
|
fn merge_image_fields(target: &mut Value, image_url: Option<&str>) {
|
|
let (Some(image_url), Some(object)) = (image_url, target.as_object_mut()) else {
|
|
return;
|
|
};
|
|
object.insert("image_url".to_owned(), image_url.into());
|
|
}
|
|
|
|
fn non_empty(value: &Value) -> Option<&str> {
|
|
value.as_str().filter(|text| !text.is_empty())
|
|
}
|
|
|
|
fn serialize(value: &Value) -> Vec<u8> {
|
|
serde_json::to_vec(value).expect("a json value serialises")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::job::{ClearJob, MessageJob, NotificationFields};
|
|
|
|
const CHANNEL_ID: &str = "9876543210987654321";
|
|
const MESSAGE_ID: &str = "1122334455667788990";
|
|
const USER_ID: &str = "1234567890123456789";
|
|
|
|
fn message_job(image_url: Option<&str>) -> MessageJob {
|
|
MessageJob {
|
|
v: 1,
|
|
guild_id: "0".to_owned(),
|
|
channel_id: CHANNEL_ID.to_owned(),
|
|
message_id: MESSAGE_ID.to_owned(),
|
|
notification: NotificationFields {
|
|
title: "Elias".to_owned(),
|
|
body: "see you tomorrow".to_owned(),
|
|
icon: "https://media.fluxer.app/avatars/1/a.webp".to_owned(),
|
|
badge: "https://media.fluxer.app/badge.png".to_owned(),
|
|
tag: format!("channel:{CHANNEL_ID}:{MESSAGE_ID}"),
|
|
notification_tag: format!("channel:{CHANNEL_ID}"),
|
|
url: format!("/channels/@me/{CHANNEL_ID}"),
|
|
image_url: image_url.map(str::to_owned),
|
|
},
|
|
user_ids: vec![USER_ID.to_owned()],
|
|
kind: None,
|
|
parent_id: None,
|
|
parent_name: None,
|
|
channel_name: None,
|
|
}
|
|
}
|
|
|
|
fn thread_job() -> MessageJob {
|
|
MessageJob {
|
|
guild_id: "4455".to_owned(),
|
|
kind: Some("thread".to_owned()),
|
|
parent_id: Some("7788".to_owned()),
|
|
parent_name: Some("general".to_owned()),
|
|
channel_name: Some("release plans".to_owned()),
|
|
..message_job(None)
|
|
}
|
|
}
|
|
|
|
fn clear_job() -> ClearJob {
|
|
ClearJob {
|
|
v: 1,
|
|
user_id: USER_ID.to_owned(),
|
|
channel_id: CHANNEL_ID.to_owned(),
|
|
message_id: MESSAGE_ID.to_owned(),
|
|
after_message_id: None,
|
|
}
|
|
}
|
|
|
|
fn apns_for(image_url: Option<&str>) -> Value {
|
|
apns_payload(&web_push_message(&message_job(image_url), USER_ID, 3))
|
|
}
|
|
|
|
#[test]
|
|
fn a_message_without_an_image_still_runs_the_notification_service_extension() {
|
|
assert_eq!(apns_for(None)["aps"]["mutable-content"], json!(1));
|
|
}
|
|
|
|
#[test]
|
|
fn a_message_with_an_image_still_runs_the_notification_service_extension() {
|
|
assert_eq!(
|
|
apns_for(Some("https://media.fluxer.app/proxy/attachment.webp"))["aps"]["mutable-content"],
|
|
json!(1)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_background_clear_never_runs_the_notification_service_extension() {
|
|
let payload = apns_payload(&web_push_clear(&clear_job(), 3));
|
|
assert_eq!(payload["aps"], json!({"content-available": 1}));
|
|
}
|
|
|
|
#[test]
|
|
fn the_apns_category_is_the_identifier_the_app_registers() {
|
|
assert_eq!(apns_for(None)["aps"]["category"], json!("fluxer_message"));
|
|
}
|
|
|
|
#[test]
|
|
fn the_android_click_action_keeps_its_own_identifier() {
|
|
let message = fcm_message(
|
|
"device-token",
|
|
&web_push_message(&message_job(None), USER_ID, 3),
|
|
);
|
|
assert_eq!(
|
|
message["message"]["android"]["notification"]["click_action"],
|
|
json!("FLUXER_MESSAGE")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_message_outside_a_thread_carries_no_thread_keys() {
|
|
let envelope = web_push_message(&message_job(None), USER_ID, 3);
|
|
for key in THREAD_DATA_KEYS {
|
|
assert!(envelope["data"].get(key).is_none(), "{key} leaked");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_thread_message_carries_its_parent_and_names() {
|
|
let envelope = web_push_message(&thread_job(), USER_ID, 3);
|
|
assert_eq!(envelope["data"]["parent_id"], json!("7788"));
|
|
assert_eq!(envelope["data"]["parent_name"], json!("general"));
|
|
assert_eq!(envelope["data"]["channel_name"], json!("release plans"));
|
|
assert_eq!(apns_payload(&envelope)["parent_id"], json!("7788"));
|
|
}
|
|
|
|
#[test]
|
|
fn only_a_forum_thread_created_message_carries_its_notification_type() {
|
|
let thread = web_push_message(&thread_job(), USER_ID, 3);
|
|
assert!(thread["data"].get(NOTIF_TYPE_KEY).is_none());
|
|
let forum = MessageJob {
|
|
kind: Some("forum_thread_created".to_owned()),
|
|
..thread_job()
|
|
};
|
|
let envelope = web_push_message(&forum, USER_ID, 3);
|
|
assert_eq!(envelope["data"][NOTIF_TYPE_KEY], json!(9));
|
|
assert_eq!(envelope["data"]["parent_id"], json!("7788"));
|
|
let message = fcm_message("device-token", &envelope);
|
|
assert_eq!(message["message"]["data"][NOTIF_TYPE_KEY], json!("9"));
|
|
assert_eq!(
|
|
message["message"]["android"]["notification"]["channel_id"],
|
|
json!(FCM_FORUM_THREAD_CREATED_CHANNEL_ID)
|
|
);
|
|
assert_eq!(
|
|
fcm_message("device-token", &thread)["message"]["android"]["notification"]["channel_id"],
|
|
json!(FCM_DEFAULT_CHANNEL_ID)
|
|
);
|
|
let budget = serialize(&minimal(&envelope, usize::MAX)).len();
|
|
let (bytes, _step) = fit(&envelope, budget);
|
|
let shrunk: Value = serde_json::from_slice(&bytes).expect("the shrunk payload is json");
|
|
assert_eq!(shrunk["data"][NOTIF_TYPE_KEY], json!(9));
|
|
}
|
|
|
|
#[test]
|
|
fn a_shrunk_thread_message_keeps_its_parent_id() {
|
|
let envelope = web_push_message(&thread_job(), USER_ID, 3);
|
|
let budget = serialize(&minimal(&envelope, usize::MAX)).len();
|
|
let (bytes, step) = fit(&envelope, budget);
|
|
assert_eq!(step, Some(PayloadShrink::Minimal));
|
|
let shrunk: Value = serde_json::from_slice(&bytes).expect("the shrunk payload is json");
|
|
assert_eq!(shrunk["data"]["parent_id"], json!("7788"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_shrunk_clear_still_tells_the_client_it_is_a_clear() {
|
|
let envelope = web_push_clear(&clear_job(), 3);
|
|
let budget = serialize(&envelope).len() - 1;
|
|
let (bytes, step) = fit(&envelope, budget);
|
|
assert_eq!(step, Some(PayloadShrink::Minimal));
|
|
let shrunk: Value = serde_json::from_slice(&bytes).expect("the shrunk payload is json");
|
|
assert_eq!(shrunk["data"]["type"], json!(CLEAR_TYPE));
|
|
assert_eq!(shrunk["data"]["action"], json!(CLEAR_ACTION));
|
|
}
|
|
}
|