mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
feat(threads): gateway, messages and push thread support (#3250)
This commit is contained in:
1 parent
4e730832c7
commit
5f4295e399
108 files changed
+15857
-176
No files matched your search
@@ -53,6 +53,7 @@ impl Job {
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum Audience {
|
||||
Standard,
|
||||
Thread,
|
||||
Ring,
|
||||
}
|
||||
|
||||
@@ -60,6 +61,7 @@ impl Audience {
|
||||
fn admits(self, subscription: &Subscription) -> bool {
|
||||
match self {
|
||||
Self::Standard => subscription.platform() != Some(Platform::IosApnsVoip),
|
||||
Self::Thread => Self::Standard.admits(subscription) && subscription.thread_channels,
|
||||
Self::Ring => Self::rings(subscription),
|
||||
}
|
||||
}
|
||||
@@ -377,7 +379,11 @@ async fn run_message_job(
|
||||
&subscriptions,
|
||||
envelopes,
|
||||
deadline,
|
||||
Audience::Standard,
|
||||
if job.is_thread() {
|
||||
Audience::Thread
|
||||
} else {
|
||||
Audience::Standard
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let duration_ms = elapsed_ms(started_ms);
|
||||
@@ -672,3 +678,34 @@ fn reap(result: Result<(), tokio::task::JoinError>) {
|
||||
warn!(error = %error, "push job task failed");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn subscription(platform: &str, thread_channels: bool) -> Subscription {
|
||||
Subscription {
|
||||
subscription_id: "sub".to_owned(),
|
||||
endpoint: "token".to_owned(),
|
||||
p256dh_key: None,
|
||||
auth_key: None,
|
||||
platform: Some(platform.to_owned()),
|
||||
app_id: None,
|
||||
provider_environment: None,
|
||||
thread_channels,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_thread_push_reaches_only_devices_that_registered_the_capability() {
|
||||
assert!(Audience::Thread.admits(&subscription("android_fcm", true)));
|
||||
assert!(!Audience::Thread.admits(&subscription("android_fcm", false)));
|
||||
assert!(!Audience::Thread.admits(&subscription("ios_apns_voip", true)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_standard_push_ignores_the_capability() {
|
||||
assert!(Audience::Standard.admits(&subscription("ios_apns", false)));
|
||||
assert!(Audience::Standard.admits(&subscription("ios_apns", true)));
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ pub const QUEUE_GROUP: &str = "fluxer-push";
|
||||
|
||||
const SUPPORTED_VERSION: u8 = 1;
|
||||
const DIRECT_MESSAGE_GUILD_ID: &str = "0";
|
||||
const THREAD_KIND: &str = "thread";
|
||||
const FORUM_THREAD_CREATED_KIND: &str = "forum_thread_created";
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
|
||||
pub struct MessageJob {
|
||||
@@ -19,6 +21,14 @@ pub struct MessageJob {
|
||||
pub message_id: String,
|
||||
pub notification: NotificationFields,
|
||||
pub user_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub kind: Option<String>,
|
||||
#[serde(default)]
|
||||
pub parent_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub parent_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub channel_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
|
||||
@@ -72,6 +82,17 @@ impl MessageJob {
|
||||
pub fn is_direct_message(&self) -> bool {
|
||||
self.guild_id == DIRECT_MESSAGE_GUILD_ID
|
||||
}
|
||||
|
||||
pub fn is_thread(&self) -> bool {
|
||||
matches!(
|
||||
self.kind.as_deref(),
|
||||
Some(THREAD_KIND | FORUM_THREAD_CREATED_KIND)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_forum_thread_created(&self) -> bool {
|
||||
self.kind.as_deref() == Some(FORUM_THREAD_CREATED_KIND)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decode_message(bytes: &[u8]) -> Result<MessageJob, JobError> {
|
||||
@@ -132,4 +153,66 @@ mod tests {
|
||||
"1"
|
||||
);
|
||||
}
|
||||
|
||||
const BASE: &str = r#"{"v":1,"config_version":3,"guild_id":"5","channel_id":"6","message_id":"7","notification":{"title":"t","body":"b","icon":"i","badge":"g","tag":"x","notification_tag":"y","url":"/u"},"user_ids":["8"]"#;
|
||||
|
||||
#[test]
|
||||
fn a_job_without_thread_fields_decodes_as_before() {
|
||||
let job = decode_message(format!("{BASE}}}").as_bytes()).expect("the job decodes");
|
||||
assert!(!job.is_thread());
|
||||
assert_eq!(job.parent_id, None);
|
||||
assert_eq!(job.parent_name, None);
|
||||
assert_eq!(job.channel_name, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_thread_job_decodes_its_optional_fields() {
|
||||
let job = decode_message(
|
||||
format!(r#"{BASE},"kind":"thread","parent_id":"9","parent_name":"general","channel_name":"plans"}}"#)
|
||||
.as_bytes(),
|
||||
)
|
||||
.expect("the job decodes");
|
||||
assert!(job.is_thread());
|
||||
assert_eq!(job.parent_id.as_deref(), Some("9"));
|
||||
assert_eq!(job.parent_name.as_deref(), Some("general"));
|
||||
assert_eq!(job.channel_name.as_deref(), Some("plans"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gateway_thread_job_decodes_as_a_thread() {
|
||||
let job = decode_message(
|
||||
br#"{"v":1,"config_version":3,"guild_id":"5","channel_id":"6","message_id":"7","notification":{"title":"ada (#ideas, #general, Guild)","body":"hi","icon":"icon","badge":"badge","tag":"channel:6:7","notification_tag":"channel:6","url":"/channels/5/6/7","image_url":null},"user_ids":["8"],"kind":"thread","parent_id":"200","parent_name":"general","channel_name":"ideas"}"#,
|
||||
)
|
||||
.expect("the job decodes");
|
||||
assert!(job.is_thread());
|
||||
assert_eq!(job.parent_id.as_deref(), Some("200"));
|
||||
assert_eq!(job.parent_name.as_deref(), Some("general"));
|
||||
assert_eq!(job.channel_name.as_deref(), Some("ideas"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_forum_thread_created_job_reaches_thread_capable_devices_only() {
|
||||
let job = decode_message(
|
||||
format!(
|
||||
r#"{BASE},"kind":"forum_thread_created","parent_id":"9","channel_name":"post"}}"#
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.expect("the job decodes");
|
||||
assert!(job.is_thread());
|
||||
assert!(job.is_forum_thread_created());
|
||||
let thread = decode_message(format!(r#"{BASE},"kind":"thread"}}"#).as_bytes())
|
||||
.expect("the job decodes");
|
||||
assert!(!thread.is_forum_thread_created());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_fields_nested_in_the_notification_do_not_mark_a_thread() {
|
||||
let job = decode_message(
|
||||
br#"{"v":1,"config_version":3,"guild_id":"5","channel_id":"6","message_id":"7","notification":{"title":"t","body":"b","icon":"i","badge":"g","tag":"x","notification_tag":"y","url":"/u","kind":"thread","parent_id":"200"},"user_ids":["8"]}"#,
|
||||
)
|
||||
.expect("the job decodes");
|
||||
assert!(!job.is_thread());
|
||||
assert_eq!(job.parent_id, None);
|
||||
}
|
||||
}
|
||||
+114
-3
@@ -26,10 +26,17 @@ 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 MINIMAL_DATA_KEYS: [&str; 9] = [
|
||||
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",
|
||||
@@ -46,7 +53,7 @@ pub fn web_push_message(job: &MessageJob, target_user_id: &str, badge_count: u32
|
||||
} else {
|
||||
Value::String(job.guild_id.clone())
|
||||
};
|
||||
let data = json!({
|
||||
let mut data = json!({
|
||||
"channel_id": job.channel_id,
|
||||
"author_avatar_url": fields.icon,
|
||||
"message_id": job.message_id,
|
||||
@@ -57,6 +64,7 @@ pub fn web_push_message(job: &MessageJob, target_user_id: &str, badge_count: u32
|
||||
"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,
|
||||
@@ -81,6 +89,28 @@ pub fn web_push_message(job: &MessageJob, target_user_id: &str, badge_count: u32
|
||||
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!({
|
||||
@@ -200,7 +230,7 @@ fn fcm_notification_message(device_token: &str, envelope: &Value) -> Value {
|
||||
data.insert("image_url".to_owned(), url.into());
|
||||
}
|
||||
let mut android_notification = json!({
|
||||
"channel_id": "fluxer_default_push",
|
||||
"channel_id": fcm_channel_id(envelope),
|
||||
"tag": tag,
|
||||
"click_action": FCM_CLICK_ACTION,
|
||||
});
|
||||
@@ -220,6 +250,18 @@ fn fcm_notification_message(device_token: &str, envelope: &Value) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -616,6 +658,21 @@ mod tests {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -669,6 +726,60 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[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);
|
||||
|
||||
@@ -239,6 +239,7 @@ mod consent_tests {
|
||||
platform: Some("web_push".to_owned()),
|
||||
app_id: None,
|
||||
provider_environment: None,
|
||||
thread_channels: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ impl Hop {
|
||||
}
|
||||
.to_owned()
|
||||
}),
|
||||
thread_channels: sub.thread_channels,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ pub struct Subscription {
|
||||
pub platform: Option<String>,
|
||||
pub app_id: Option<String>,
|
||||
pub provider_environment: Option<String>,
|
||||
#[serde(default)]
|
||||
pub thread_channels: bool,
|
||||
}
|
||||
|
||||
impl Subscription {
|
||||
|
||||
Reference in new issue
Block a user