feat(threads): gateway, messages and push thread support (#3250)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-07 15:24:40 +02:00
1 parent 4e730832c7
commit 5f4295e399
108 files changed
+15857 -176

No files matched your search

+38 -1
View File
@@ -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)));
}
}
+83
View File
@@ -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
View File
@@ -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);
+1
View File
@@ -239,6 +239,7 @@ mod consent_tests {
platform: Some("web_push".to_owned()),
app_id: None,
provider_environment: None,
thread_channels: false,
}
}
+1
View File
@@ -47,6 +47,7 @@ impl Hop {
}
.to_owned()
}),
thread_channels: sub.thread_channels,
}
}
}
+2
View File
@@ -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 {