Extend message features

This commit is contained in:
jude committed 2026-10-11 13:16:02 +01:00
1 parent 2665c61d33
commit 439575acd0
20 files changed
+1326 -155

No files matched your search

+224 -7
View File
@@ -114,6 +114,126 @@ pub fn payload(reminder: &impl Serialize) -> Value {
payload
}
/// Validate Discord's link buttons, embed count and text limits.
pub fn validate_payload(message: &Value) -> Result<(), String> {
if !message.is_object() {
return Err("message_json must be an object".into());
}
if let Some(content) = message.get("content") {
if content.as_str().is_none_or(|text| text.chars().count() > 2000) {
return Err("Message content exceeds 2000 characters or is not text".into());
}
}
validate_components(message)?;
let Some(embeds) = message.get("embeds") else {
return Ok(());
};
let embeds = embeds.as_array().ok_or("embeds must be an array")?;
if embeds.len() > 10 {
return Err("A reminder can have at most 10 embeds".into());
}
let mut total = 0;
for (index, embed) in embeds.iter().enumerate() {
if !embed.is_object() {
return Err(format!("Embed {} must be an object", index + 1));
}
let mut check_text = |value: Option<&Value>, limit: usize| -> Result<(), String> {
if let Some(value) = value {
let text = value
.as_str()
.ok_or_else(|| format!("Embed {} has a non-text field", index + 1))?;
let length = text.trim().chars().count();
if length > limit {
return Err(format!(
"Embed {} exceeds a {} character field limit",
index + 1,
limit
));
}
total += length;
}
Ok(())
};
for (path, limit) in
[("/title", 256), ("/description", 4096), ("/author/name", 256), ("/footer/text", 2048)]
{
check_text(embed.pointer(path), limit)?;
}
if let Some(fields) = embed.get("fields") {
let fields = fields.as_array().ok_or("Embed fields must be an array")?;
if fields.len() > 25 {
return Err("An embed can have at most 25 fields".into());
}
for field in fields {
if !field.is_object() {
return Err("Embed fields must be objects".into());
}
for (name, limit) in [("name", 256), ("value", 1024)] {
let text = field.get(name).ok_or("Embed fields require a name and value")?;
if text.as_str().is_none_or(|text| text.trim().is_empty()) {
return Err("Embed fields require a non-empty name and value".into());
}
check_text(Some(text), limit)?;
}
}
}
}
if total > 6000 {
return Err("Embed text exceeds 6000 characters across all embeds".into());
}
Ok(())
}
fn validate_components(message: &Value) -> Result<(), String> {
let Some(components) = message.get("components") else {
return Ok(());
};
let rows = components.as_array().ok_or("components must be an array")?;
// Components V2 has a different layout and count limit. Leave its validation to Discord.
if message["flags"].as_u64().is_some_and(|flags| flags & (1 << 15) != 0) {
return Ok(());
}
if rows.len() > 5 {
return Err("A reminder can have at most 5 button rows (25 buttons)".into());
}
for row in rows {
if row["type"].as_u64() != Some(1) {
continue;
}
let buttons = row["components"].as_array().ok_or("Button rows require components")?;
if buttons.is_empty() || buttons.len() > 5 {
return Err("A button row must have between 1 and 5 components".into());
}
for button in buttons {
if button["type"].as_u64() != Some(2) || button["style"].as_u64() != Some(5) {
continue;
}
let label = button.get("label").and_then(Value::as_str);
if label.is_some_and(|label| label.chars().count() > 80)
|| (label.is_none_or(str::is_empty) && !button["emoji"].is_object())
|| button.get("label").is_some_and(|label| !label.is_string())
{
return Err(
"Link buttons require a label of at most 80 characters or an emoji".into()
);
}
let url = button["url"].as_str().ok_or("Link buttons require a URL")?;
if url.chars().count() > 512
|| !reqwest::Url::parse(url)
.is_ok_and(|url| ["http", "https"].contains(&url.scheme()))
{
return Err(
"Link buttons require an HTTP or HTTPS URL of at most 512 characters".into()
);
}
if button.get("custom_id").is_some() || button.get("sku_id").is_some() {
return Err("Link buttons cannot have a custom_id or sku_id".into());
}
}
}
Ok(())
}
/// Present the first embed through the existing dashboard controls.
pub fn response(reminder: impl Serialize) -> Value {
let mut reminder = serde_json::to_value(reminder).expect("Reminder fields are serializable");
@@ -123,10 +243,7 @@ pub fn response(reminder: impl Serialize) -> Value {
}
return reminder;
}
let payload = reminder
.as_object_mut()
.and_then(|object| object.remove("message_json"))
.unwrap_or(Value::Null);
let payload = reminder["message_json"].clone();
if payload.is_null() {
return reminder;
}
@@ -211,6 +328,7 @@ pub async fn update_field(
serde_json::to_value(value).map_err(|e| sqlx::Error::Encode(e.into()))?
};
patch(&mut message, field, value);
validate_payload(&message).map_err(sqlx::Error::Protocol)?;
sqlx::query("UPDATE reminders SET message_json = ? WHERE uid = ?")
.bind(Json(message))
.bind(uid)
@@ -260,8 +378,8 @@ mod tests {
"avatar_url": "https://example.com/avatar.png"
})
);
let mut expected_response = legacy.clone();
expected_response.as_object_mut().unwrap().remove("message_json");
let expected_response = legacy.clone();
assert_eq!(response(legacy), expected_response);
}
@@ -286,7 +404,7 @@ mod tests {
assert_eq!(reminder["uid"], "reminder-id");
assert_eq!(reminder["utc_time"], "2026-10-12T12:00:00");
assert_eq!(reminder["enabled"], true);
assert!(reminder.get("message_json").is_none());
assert_eq!(reminder["message_json"]["embeds"].as_array().unwrap().len(), 1);
}
#[test]
@@ -322,6 +440,105 @@ mod tests {
assert_eq!(message["embeds"], json!([]));
}
#[test]
fn validates_embed_count_and_combined_text_boundaries() {
assert!(validate_payload(&json!({"embeds": vec![json!({"title": "embed"}); 10]})).is_ok());
assert!(validate_payload(&json!({"embeds": vec![json!({"title": "embed"}); 11]})).is_err());
let mut message = json!({"embeds": [{"description": "x".repeat(3000)}, {"description": "y".repeat(3000)}]});
assert!(validate_payload(&message).is_ok());
message["embeds"][1]["title"] = json!("x");
assert!(validate_payload(&message).is_err());
}
#[test]
fn validates_each_embed_and_field() {
assert!(validate_payload(&json!({"embeds": [{"description": "x".repeat(4097)}]})).is_err());
assert!(validate_payload(
&json!({"embeds": [{"fields": vec![json!({"name": "x", "value": "y"}); 26]}]})
)
.is_err());
for message in [
json!([]),
json!({"embeds": {}}),
json!({"embeds": [null]}),
json!({"embeds": [{"fields": [{"name": "x"}]}]}),
json!({"embeds": [{"description": 1}]}),
] {
assert!(validate_payload(&message).is_err());
}
}
#[test]
fn dashboard_response_retains_all_embeds_for_editing() {
let message = json!({"embeds": [{"title": "first"}, {"title": "second"}], "allowed_mentions": {"parse": []}});
let displayed =
response(json!({"message_json": message, "content": "", "uid": "unchanged"}));
assert_eq!(displayed["message_json"], message);
assert_eq!(displayed["embed_title"], "first");
assert_eq!(displayed["uid"], "unchanged");
}
#[test]
fn link_buttons_survive_payload_response_and_legacy_edits() {
let components = json!([{"type": 1, "components": [{
"type": 2, "style": 5, "label": "Open link", "url": "https://example.com"
}]}]);
let mut message = json!({"content": "hello", "components": components});
assert!(validate_payload(&message).is_ok());
assert_eq!(payload(&json!({"message_json": message})), message);
assert_eq!(response(json!({"message_json": message}))["message_json"], message);
patch(&mut message, "content", json!("updated"));
patch(&mut message, "embed_description", json!("embed"));
assert_eq!(message["components"], components);
}
#[test]
fn validates_link_button_count_and_text_boundaries() {
let button = json!({"type": 2, "style": 5, "label": "x".repeat(80),
"url": format!("https://example.com/{}", "x".repeat(492))});
let row = json!({"type": 1, "components": vec![button.clone(); 5]});
assert!(validate_payload(&json!({"components": vec![row.clone(); 5]})).is_ok());
assert!(validate_payload(&json!({"components": vec![row; 6]})).is_err());
assert!(validate_payload(
&json!({"components": [{"type": 1, "components": vec![button.clone(); 6]}]})
)
.is_err());
for (field, value) in [
("label", json!("x".repeat(81))),
("url", json!(format!("https://example.com/{}", "x".repeat(493)))),
] {
let mut invalid = button.clone();
invalid[field] = value;
assert!(validate_payload(
&json!({"components": [{"type": 1, "components": [invalid]}]})
)
.is_err());
}
}
#[test]
fn rejects_invalid_link_buttons_without_embeds() {
for change in [
json!({"url": ""}),
json!({"url": "javascript:alert(1)"}),
json!({"url": "not a URL"}),
json!({"label": ""}),
json!({"label": 1}),
json!({"custom_id": "interactive"}),
] {
let mut button =
json!({"type": 2, "style": 5, "label": "Open", "url": "https://example.com"});
button.as_object_mut().unwrap().extend(change.as_object().unwrap().clone());
assert!(validate_payload(
&json!({"components": [{"type": 1, "components": [button]}]})
)
.is_err());
}
assert!(validate_payload(&json!({"components": {}})).is_err());
assert!(validate_payload(&json!({"components": [{"type": 1, "components": []}]})).is_err());
assert!(validate_payload(&json!({"components": [{"type": 1, "components": [{"type": 2, "style": 5, "emoji": {"name": "🔗"}, "url": "https://example.com"}]}]})).is_ok());
}
#[tokio::test]
#[ignore = "requires MESSAGE_JSON_TEST_DATABASE_URL pointing to an isolated MySQL database"]
async fn database_edits_convert_legacy_messages_and_preserve_payload() {
+1 -1
View File
@@ -682,7 +682,7 @@ impl Reminder {
let token = webhook.token.as_ref().ok_or(Error::Other("Webhook token missing"))?;
let message = cache_http
.http()
.execute_webhook(
.execute_webhook_with_components(
webhook.id,
reminder.thread_id.map(ChannelId::new),
token.expose_secret(),
+10 -2
View File
@@ -57,7 +57,12 @@ macro_rules! check_url_opt {
macro_rules! update_field {
($pool:expr, $error:ident, $reminder:ident.[$field:ident]) => {
if let Some(value) = &$reminder.$field {
let result = if crate::message::is_message_field(stringify!($field)) {
// The complete payload takes precedence over legacy message controls.
let result = if crate::message::is_message_field(stringify!($field))
&& $reminder.message_json.is_some()
{
Ok(())
} else if crate::message::is_message_field(stringify!($field)) {
crate::message::update_field($pool, &$reminder.uid, stringify!($field), value).await
} else {
let result = sqlx::query(concat!(
@@ -70,7 +75,10 @@ macro_rules! update_field {
.execute($pool)
.await
.map(|_| ());
if result.is_ok() && ["attachment", "attachment_name"].contains(&stringify!($field)) {
if result.is_ok()
&& $reminder.message_json.is_none()
&& ["attachment", "attachment_name"].contains(&stringify!($field))
{
crate::message::update_field($pool, &$reminder.uid, "attachments", &()).await
} else {
result
@@ -153,6 +153,11 @@ pub async fn edit_reminder(
let user_id =
cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap();
if let Some(message) = &reminder.message_json {
crate::message::validate_payload(message).map_err(|error| json!({"error": error}))?;
update_field!(transaction.executor(), error, reminder.[message_json]);
}
if reminder.message_ok() {
update_field!(transaction.executor(), error, reminder.[
content,
+30 -29
View File
@@ -87,39 +87,42 @@ pub async fn create_reminder(
let channel = channel.unwrap();
if reminder.message_json.as_ref().is_some_and(|message| !message.is_object()) {
return Err(json!({"error": "message_json must be an object"}));
let message_json = Json(crate::message::payload(&reminder));
if let Err(error) = crate::message::validate_payload(&message_json) {
return Err(json!({"error": error}));
}
// validate lengths
check_length!(MAX_NAME_LENGTH, reminder.name);
check_length!(MAX_CONTENT_LENGTH, reminder.content);
check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder.embed_description);
check_length!(MAX_EMBED_TITLE_LENGTH, reminder.embed_title);
check_length!(MAX_EMBED_AUTHOR_LENGTH, reminder.embed_author);
check_length!(MAX_EMBED_FOOTER_LENGTH, reminder.embed_footer);
check_length_opt!(MAX_EMBED_FIELDS, reminder.embed_fields);
if let Some(fields) = &reminder.embed_fields {
for field in &fields.0 {
check_length!(MAX_EMBED_FIELD_VALUE_LENGTH, field.value);
check_length!(MAX_EMBED_FIELD_TITLE_LENGTH, field.title);
if reminder.message_json.is_none() {
check_length!(MAX_CONTENT_LENGTH, reminder.content);
check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder.embed_description);
check_length!(MAX_EMBED_TITLE_LENGTH, reminder.embed_title);
check_length!(MAX_EMBED_AUTHOR_LENGTH, reminder.embed_author);
check_length!(MAX_EMBED_FOOTER_LENGTH, reminder.embed_footer);
check_length_opt!(MAX_EMBED_FIELDS, reminder.embed_fields);
if let Some(fields) = &reminder.embed_fields {
for field in &fields.0 {
check_length!(MAX_EMBED_FIELD_VALUE_LENGTH, field.value);
check_length!(MAX_EMBED_FIELD_TITLE_LENGTH, field.title);
}
}
}
check_length_opt!(
MAX_URL_LENGTH,
reminder.embed_footer_url,
reminder.embed_thumbnail_url,
reminder.embed_author_url,
reminder.embed_image_url
);
check_length_opt!(
MAX_URL_LENGTH,
reminder.embed_footer_url,
reminder.embed_thumbnail_url,
reminder.embed_author_url,
reminder.embed_image_url
);
// validate urls
check_url_opt!(
reminder.embed_footer_url,
reminder.embed_thumbnail_url,
reminder.embed_author_url,
reminder.embed_image_url
);
// validate urls
check_url_opt!(
reminder.embed_footer_url,
reminder.embed_thumbnail_url,
reminder.embed_author_url,
reminder.embed_image_url
);
}
// validate time and interval
if reminder.utc_time < Utc::now().naive_utc() {
@@ -151,8 +154,6 @@ pub async fn create_reminder(
let name = if reminder.name.is_empty() { name_default() } else { reminder.name.clone() };
let new_uid = generate_uid();
let message_json = Json(crate::message::payload(&reminder));
// write to db
match sqlx::query!(
"INSERT INTO reminders (
@@ -131,6 +131,11 @@ pub async fn edit_reminder(
let mut error = vec![];
let user_id = user_id_cookie.unwrap();
if let Some(message) = &reminder.message_json {
crate::message::validate_payload(message).map_err(|error| json!({"error": error}))?;
update_field!(transaction.executor(), error, reminder.[message_json]);
}
if reminder.message_ok() {
update_field!(transaction.executor(), error, reminder.[
content,
+85 -31
View File
@@ -254,6 +254,8 @@ pub struct ReminderCsv {
#[derive(Deserialize)]
pub struct PatchReminder {
#[serde(default)]
message_json: Option<Json<serde_json::Value>>,
uid: String,
#[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
@@ -322,6 +324,9 @@ pub struct PatchReminder {
impl PatchReminder {
fn message_ok(&self) -> bool {
if let Some(message) = &self.message_json {
return crate::message::validate_payload(message).is_ok();
}
self.content.as_ref().map_or(true, |c| c.len() <= MAX_CONTENT_LENGTH)
&& self.embed_author.as_ref().map_or(true, |c| c.len() <= MAX_EMBED_AUTHOR_LENGTH)
&& self
@@ -433,42 +438,45 @@ pub(crate) async fn create_reminder(
let channel = channel.unwrap();
if reminder.message_json.as_ref().is_some_and(|message| !message.is_object()) {
return Err(json!({"error": "message_json must be an object"}));
let message_json = Json(crate::message::payload(&reminder));
if let Err(error) = crate::message::validate_payload(&message_json) {
return Err(json!({"error": error}));
}
// validate lengths
check_length!(MAX_NAME_LENGTH, reminder.name);
check_length!(MAX_CONTENT_LENGTH, reminder.content);
check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder.embed_description);
check_length!(MAX_EMBED_TITLE_LENGTH, reminder.embed_title);
check_length!(MAX_EMBED_AUTHOR_LENGTH, reminder.embed_author);
check_length!(MAX_EMBED_FOOTER_LENGTH, reminder.embed_footer);
check_length_opt!(MAX_EMBED_FIELDS, reminder.embed_fields);
if let Some(fields) = &reminder.embed_fields {
for field in &fields.0 {
check_length!(MAX_EMBED_FIELD_VALUE_LENGTH, field.value);
check_length!(MAX_EMBED_FIELD_TITLE_LENGTH, field.title);
if reminder.message_json.is_none() {
check_length!(MAX_CONTENT_LENGTH, reminder.content);
check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder.embed_description);
check_length!(MAX_EMBED_TITLE_LENGTH, reminder.embed_title);
check_length!(MAX_EMBED_AUTHOR_LENGTH, reminder.embed_author);
check_length!(MAX_EMBED_FOOTER_LENGTH, reminder.embed_footer);
check_length_opt!(MAX_EMBED_FIELDS, reminder.embed_fields);
if let Some(fields) = &reminder.embed_fields {
for field in &fields.0 {
check_length!(MAX_EMBED_FIELD_VALUE_LENGTH, field.value);
check_length!(MAX_EMBED_FIELD_TITLE_LENGTH, field.title);
}
}
}
check_length_opt!(MAX_USERNAME_LENGTH, reminder.username);
check_length_opt!(
MAX_URL_LENGTH,
reminder.embed_footer_url,
reminder.embed_thumbnail_url,
reminder.embed_author_url,
reminder.embed_image_url,
reminder.avatar
);
check_length_opt!(MAX_USERNAME_LENGTH, reminder.username);
check_length_opt!(
MAX_URL_LENGTH,
reminder.embed_footer_url,
reminder.embed_thumbnail_url,
reminder.embed_author_url,
reminder.embed_image_url,
reminder.avatar
);
// validate urls
check_url_opt!(
reminder.embed_footer_url,
reminder.embed_thumbnail_url,
reminder.embed_author_url,
reminder.embed_image_url,
reminder.avatar
);
// validate urls
check_url_opt!(
reminder.embed_footer_url,
reminder.embed_thumbnail_url,
reminder.embed_author_url,
reminder.embed_image_url,
reminder.avatar
);
}
// validate time and interval
if reminder.utc_time < Utc::now().naive_utc() {
@@ -498,7 +506,6 @@ pub(crate) async fn create_reminder(
}
let name = if reminder.name.is_empty() { name_default() } else { reminder.name.clone() };
let message_json = Json(crate::message::payload(&reminder));
let new_uid = generate_uid();
@@ -845,3 +852,50 @@ async fn render_dashboard(cookies: &CookieJar<'_>) -> DashboardPage {
DashboardPage::Unauthorised(Redirect::to("/login/discord"))
}
}
#[cfg(test)]
mod message_json_tests {
use super::*;
#[tokio::test]
#[ignore = "requires MESSAGE_JSON_TEST_DATABASE_URL pointing to an isolated MySQL database"]
async fn patch_keeps_embeds_and_buttons_and_ignores_legacy_message_fields() {
let pool =
sqlx::MySqlPool::connect(&std::env::var("MESSAGE_JSON_TEST_DATABASE_URL").unwrap())
.await
.unwrap();
let mut transaction = pool.begin().await.unwrap();
let channel = sqlx::query("INSERT INTO channels (channel) VALUES (?)")
.bind(rand::random::<u64>())
.execute(&mut *transaction)
.await
.unwrap()
.last_insert_id();
let uid = format!("multi-embed-test-{}", rand::random::<u64>());
sqlx::query("INSERT INTO reminders (uid, channel_id, `utc_time`) VALUES (?, ?, NOW())")
.bind(&uid)
.bind(channel)
.execute(&mut *transaction)
.await
.unwrap();
let payload = json!({"content": "current", "embeds": [{"title": "first"}, {"title": "second"}],
"components": [{"type": 1, "components": [{"type": 2, "style": 5,
"label": "Open link", "url": "https://example.com"}]}]});
let reminder: PatchReminder = serde_json::from_value(json!({
"uid": uid, "message_json": payload, "content": "stale", "embed_title": "stale"
}))
.unwrap();
let mut errors = vec![];
assert!(reminder.message_ok());
update_field!(&mut *transaction, errors, reminder.[message_json, content, embed_title]);
assert!(errors.is_empty());
let stored: (Json<serde_json::Value>,) =
sqlx::query_as("SELECT message_json FROM reminders WHERE uid = ?")
.bind(&uid)
.fetch_one(&mut *transaction)
.await
.unwrap();
assert_eq!(stored.0 .0, payload);
transaction.rollback().await.unwrap();
}
}