Extend message features
This commit is contained in:
1 parent
2665c61d33
commit
439575acd0
20 files changed
+1326
-155
No files matched your search
+224
-7
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user