Move message content to message_json
This commit is contained in:
1 parent
644adc3f87
commit
2665c61d33
33 files changed
+1044
-298
No files matched your search
@@ -10,6 +10,7 @@ mod event_handlers;
|
||||
#[cfg(not(test))]
|
||||
mod hooks;
|
||||
mod interval_parser;
|
||||
mod message;
|
||||
mod metrics;
|
||||
#[cfg(not(test))]
|
||||
mod models;
|
||||
|
||||
+387
@@ -0,0 +1,387 @@
|
||||
//! Conversion between the dashboard's message fields and Discord webhook payloads.
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::{types::Json, FromRow, MySqlConnection};
|
||||
|
||||
#[derive(Default, Deserialize, Serialize, FromRow)]
|
||||
#[serde(default)]
|
||||
struct MessageFields {
|
||||
content: String,
|
||||
tts: bool,
|
||||
username: Option<String>,
|
||||
avatar: Option<String>,
|
||||
embed_title: String,
|
||||
embed_description: String,
|
||||
embed_author: String,
|
||||
embed_author_url: Option<String>,
|
||||
embed_footer: String,
|
||||
embed_footer_url: Option<String>,
|
||||
embed_image_url: Option<String>,
|
||||
embed_thumbnail_url: Option<String>,
|
||||
embed_color: u32,
|
||||
embed_fields: Option<Json<Value>>,
|
||||
message_json: Option<Json<Value>>,
|
||||
attachment_name: Option<String>,
|
||||
has_attachment: bool,
|
||||
}
|
||||
|
||||
const EMBED_PATHS: &[(&str, &[&str])] = &[
|
||||
("embed_title", &["title"]),
|
||||
("embed_description", &["description"]),
|
||||
("embed_author", &["author", "name"]),
|
||||
("embed_author_url", &["author", "icon_url"]),
|
||||
("embed_footer", &["footer", "text"]),
|
||||
("embed_footer_url", &["footer", "icon_url"]),
|
||||
("embed_image_url", &["image", "url"]),
|
||||
("embed_thumbnail_url", &["thumbnail", "url"]),
|
||||
("embed_color", &["color"]),
|
||||
("embed_fields", &["fields"]),
|
||||
];
|
||||
|
||||
fn set_path(target: &mut Value, path: &[&str], value: Value) {
|
||||
if !target.is_object() {
|
||||
*target = json!({});
|
||||
}
|
||||
let object = target.as_object_mut().unwrap();
|
||||
if path.len() == 1 {
|
||||
if value.is_null() {
|
||||
object.remove(path[0]);
|
||||
} else {
|
||||
object.insert(path[0].to_string(), value);
|
||||
}
|
||||
} else {
|
||||
set_path(object.entry(path[0]).or_insert_with(|| json!({})), &path[1..], value);
|
||||
}
|
||||
}
|
||||
|
||||
fn embed_has_content(embed: &Value) -> bool {
|
||||
["/title", "/description", "/author/name", "/footer/text", "/footer/icon_url",
|
||||
"/image/url", "/thumbnail/url"]
|
||||
.iter()
|
||||
.any(|path| embed.pointer(path).and_then(Value::as_str).is_some_and(|s| !s.trim().is_empty()))
|
||||
|| embed["fields"].as_array().is_some_and(|fields| !fields.is_empty())
|
||||
// Preserve payload features the dashboard does not edit.
|
||||
|| embed.as_object().is_some_and(|object| object.keys().any(|key|
|
||||
!["title", "description", "author", "footer", "image", "thumbnail", "color", "fields"].contains(&key.as_str())))
|
||||
}
|
||||
|
||||
fn discord_fields(value: &Value) -> Value {
|
||||
match value.as_array() {
|
||||
Some(fields) => Value::Array(
|
||||
fields
|
||||
.iter()
|
||||
.map(|field| {
|
||||
json!({
|
||||
"name": field["title"], "value": field["value"], "inline": field["inline"]
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
None => json!([]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Use an explicit payload verbatim, or build one from existing dashboard fields.
|
||||
pub fn payload(reminder: &impl Serialize) -> Value {
|
||||
let fields = serde_json::to_value(reminder).expect("Reminder fields are serializable");
|
||||
if !fields["message_json"].is_null() {
|
||||
return fields["message_json"].clone();
|
||||
}
|
||||
let mut payload = json!({ "content": fields["content"], "tts": fields["tts"] });
|
||||
if fields["username"].as_str().is_some_and(|s| !s.is_empty()) {
|
||||
payload["username"] = fields["username"].clone();
|
||||
}
|
||||
if !fields["avatar"].is_null() {
|
||||
payload["avatar_url"] = fields["avatar"].clone();
|
||||
}
|
||||
let mut embed = json!({});
|
||||
for (field, path) in EMBED_PATHS {
|
||||
let value = if *field == "embed_fields" {
|
||||
discord_fields(&fields[*field])
|
||||
} else {
|
||||
fields[*field].clone()
|
||||
};
|
||||
set_path(&mut embed, path, value);
|
||||
}
|
||||
if embed_has_content(&embed) {
|
||||
payload["embeds"] = json!([embed]);
|
||||
}
|
||||
if (!fields["attachment"].is_null() || fields["has_attachment"].as_bool() == Some(true))
|
||||
&& fields["attachment_name"].is_string()
|
||||
{
|
||||
payload["attachments"] = json!([{ "id": 0, "filename": fields["attachment_name"] }]);
|
||||
}
|
||||
payload
|
||||
}
|
||||
|
||||
/// 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");
|
||||
if let Some(reminders) = reminder.as_array_mut() {
|
||||
for reminder in reminders {
|
||||
*reminder = response(&*reminder);
|
||||
}
|
||||
return reminder;
|
||||
}
|
||||
let payload = reminder
|
||||
.as_object_mut()
|
||||
.and_then(|object| object.remove("message_json"))
|
||||
.unwrap_or(Value::Null);
|
||||
if payload.is_null() {
|
||||
return reminder;
|
||||
}
|
||||
reminder["content"] = payload["content"].as_str().unwrap_or_default().into();
|
||||
reminder["tts"] = payload["tts"].as_bool().unwrap_or(false).into();
|
||||
reminder["username"] = payload["username"].clone();
|
||||
reminder["avatar"] = payload["avatar_url"].clone();
|
||||
for (field, path) in EMBED_PATHS {
|
||||
let value = payload["embeds"][0]
|
||||
.pointer(&format!("/{}", path.join("/")))
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null);
|
||||
reminder[*field] = match *field {
|
||||
"embed_fields" => Value::Array(value.as_array().map(|fields| fields.iter().map(|field| json!({
|
||||
"title": field["name"], "value": field["value"], "inline": field["inline"].as_bool().unwrap_or(false)
|
||||
})).collect()).unwrap_or_default()),
|
||||
"embed_color" => value.as_u64().unwrap_or(0).into(),
|
||||
"embed_title" | "embed_description" | "embed_author" | "embed_footer" => value.as_str().unwrap_or_default().into(),
|
||||
_ => value,
|
||||
};
|
||||
}
|
||||
reminder
|
||||
}
|
||||
|
||||
pub fn is_message_field(field: &str) -> bool {
|
||||
["content", "tts", "username", "avatar"].contains(&field)
|
||||
|| EMBED_PATHS.iter().any(|(name, _)| *name == field)
|
||||
}
|
||||
|
||||
fn patch(payload: &mut Value, field: &str, mut value: Value) {
|
||||
if let Some((_, path)) = EMBED_PATHS.iter().find(|(name, _)| *name == field) {
|
||||
if !payload["embeds"].is_array() {
|
||||
payload["embeds"] = json!([]);
|
||||
}
|
||||
let embeds = payload["embeds"].as_array_mut().unwrap();
|
||||
if embeds.is_empty() {
|
||||
embeds.push(json!({}));
|
||||
}
|
||||
if field == "embed_fields" {
|
||||
value = discord_fields(&value);
|
||||
}
|
||||
set_path(&mut embeds[0], path, value);
|
||||
if !embed_has_content(&embeds[0]) {
|
||||
embeds.remove(0);
|
||||
}
|
||||
} else {
|
||||
if field == "username" && value.as_str() == Some("") {
|
||||
value = Value::Null;
|
||||
}
|
||||
set_path(payload, &[if field == "avatar" { "avatar_url" } else { field }], value);
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert legacy rows on first edit and preserve unedited payload keys.
|
||||
pub async fn update_field(
|
||||
connection: &mut MySqlConnection,
|
||||
uid: &str,
|
||||
field: &str,
|
||||
value: &impl Serialize,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
let fields = sqlx::query_as::<_, MessageFields>(
|
||||
"SELECT content, tts, username, avatar, embed_title, embed_description,
|
||||
embed_author, embed_author_url, embed_footer, embed_footer_url, embed_image_url,
|
||||
embed_thumbnail_url, embed_color, embed_fields, message_json, attachment_name,
|
||||
attachment IS NOT NULL AS has_attachment FROM reminders WHERE uid = ? FOR UPDATE",
|
||||
)
|
||||
.bind(uid)
|
||||
.fetch_one(&mut *connection)
|
||||
.await?;
|
||||
let mut message = payload(&fields);
|
||||
let value = if field == "attachments" {
|
||||
if fields.has_attachment {
|
||||
fields
|
||||
.attachment_name
|
||||
.as_ref()
|
||||
.map(|name| json!([{ "id": 0, "filename": name }]))
|
||||
.unwrap_or_else(|| json!([]))
|
||||
} else {
|
||||
json!([])
|
||||
}
|
||||
} else {
|
||||
serde_json::to_value(value).map_err(|e| sqlx::Error::Encode(e.into()))?
|
||||
};
|
||||
patch(&mut message, field, value);
|
||||
sqlx::query("UPDATE reminders SET message_json = ? WHERE uid = ?")
|
||||
.bind(Json(message))
|
||||
.bind(uid)
|
||||
.execute(connection)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn builds_discord_payload_and_omits_empty_embed() {
|
||||
assert_eq!(
|
||||
payload(&json!({"content": "hello", "tts": true})),
|
||||
json!({"content": "hello", "tts": true})
|
||||
);
|
||||
let message = payload(&json!({"content": "", "tts": false, "embed_description": "body",
|
||||
"embed_fields": [{"title": "heading", "value": "value", "inline": true}],
|
||||
"attachment": "bytes", "attachment_name": "file.txt"}));
|
||||
assert_eq!(message["embeds"][0]["fields"][0]["name"], "heading");
|
||||
assert_eq!(message["attachments"], json!([{ "id": 0, "filename": "file.txt" }]));
|
||||
assert_eq!(response(json!({"message_json": message}))["embed_description"], "body");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_payload_is_exact() {
|
||||
let exact = json!({"content": "<<timenow:UTC:%Y>>", "flags": 32768, "components": [], "custom": true});
|
||||
assert_eq!(
|
||||
payload(&json!({"message_json": exact, "content": "ignored", "tts": true})),
|
||||
exact
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_payload_falls_back_to_legacy_fields() {
|
||||
let legacy = json!({
|
||||
"message_json": null, "content": "legacy", "tts": false,
|
||||
"username": "Reminder", "avatar": "https://example.com/avatar.png"
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
payload(&legacy),
|
||||
json!({
|
||||
"content": "legacy", "tts": false, "username": "Reminder",
|
||||
"avatar_url": "https://example.com/avatar.png"
|
||||
})
|
||||
);
|
||||
let mut expected_response = legacy.clone();
|
||||
expected_response.as_object_mut().unwrap().remove("message_json");
|
||||
assert_eq!(response(legacy), expected_response);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dashboard_response_uses_payload_and_preserves_schedule() {
|
||||
let reminder = response(json!({
|
||||
"uid": "reminder-id", "utc_time": "2026-10-12T12:00:00", "enabled": true,
|
||||
"content": "stale", "embed_description": "stale", "tts": false,
|
||||
"message_json": {
|
||||
"content": "current", "tts": true,
|
||||
"embeds": [{"description": "body", "fields": [{"name": "heading", "value": "value"}]}]
|
||||
}
|
||||
}));
|
||||
|
||||
assert_eq!(reminder["content"], "current");
|
||||
assert_eq!(reminder["tts"], true);
|
||||
assert_eq!(reminder["embed_description"], "body");
|
||||
assert_eq!(
|
||||
reminder["embed_fields"],
|
||||
json!([{"title": "heading", "value": "value", "inline": false}])
|
||||
);
|
||||
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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_webhook_identity_preserves_other_payload_keys() {
|
||||
let mut message = json!({
|
||||
"content": "hello", "username": "Reminder",
|
||||
"avatar_url": "https://example.com/avatar.png", "flags": 4
|
||||
});
|
||||
|
||||
patch(&mut message, "username", json!(""));
|
||||
patch(&mut message, "avatar", Value::Null);
|
||||
|
||||
assert_eq!(message, json!({"content": "hello", "flags": 4}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_preserve_extra_payload_features() {
|
||||
let mut message = json!({"flags": 4, "embeds": [{"description": "before", "url": "https://example.com"}, {"title": "second"}]});
|
||||
patch(&mut message, "embed_description", json!("after"));
|
||||
patch(&mut message, "embed_author_url", json!("https://example.com/icon.png"));
|
||||
assert_eq!(message["flags"], 4);
|
||||
assert_eq!(message["embeds"][0]["description"], "after");
|
||||
assert_eq!(message["embeds"][0]["url"], "https://example.com");
|
||||
assert_eq!(message["embeds"][1]["title"], "second");
|
||||
patch(&mut message, "embed_author_url", Value::Null);
|
||||
assert!(message["embeds"][0]["author"]["icon_url"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_last_embed_content_removes_embed() {
|
||||
let mut message = json!({"embeds": [{"description": "body", "color": 123}]});
|
||||
patch(&mut message, "embed_description", json!(""));
|
||||
assert_eq!(message["embeds"], json!([]));
|
||||
}
|
||||
|
||||
#[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() {
|
||||
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!("message-json-test-{}", rand::random::<u64>());
|
||||
sqlx::query("INSERT INTO reminders (uid, channel_id, `utc_time`, content, embed_description, attachment, attachment_name) VALUES (?, ?, NOW(), 'legacy', 'embed', 'bytes', 'file.txt')")
|
||||
.bind(&uid).bind(channel).execute(&mut *transaction).await.unwrap();
|
||||
update_field(&mut transaction, &uid, "content", &"edited").await.unwrap();
|
||||
let stored: (Json<Value>, String) =
|
||||
sqlx::query_as("SELECT message_json, content FROM reminders WHERE uid = ?")
|
||||
.bind(&uid)
|
||||
.fetch_one(&mut *transaction)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(stored.0["content"], "edited");
|
||||
assert_eq!(stored.0["embeds"][0]["description"], "embed");
|
||||
assert_eq!(stored.0["attachments"][0]["filename"], "file.txt");
|
||||
assert_eq!(stored.1, "legacy");
|
||||
let exact = json!({"content": "exact", "custom": [1, 2], "embeds": [{"description": "first"}, {"description": "second"}]});
|
||||
sqlx::query("UPDATE reminders SET message_json = ? WHERE uid = ?")
|
||||
.bind(Json(exact))
|
||||
.bind(&uid)
|
||||
.execute(&mut *transaction)
|
||||
.await
|
||||
.unwrap();
|
||||
update_field(&mut transaction, &uid, "tts", &true).await.unwrap();
|
||||
update_field(&mut transaction, &uid, "embed_description", &"updated").await.unwrap();
|
||||
let stored: (Json<Value>,) =
|
||||
sqlx::query_as("SELECT message_json FROM reminders WHERE uid = ?")
|
||||
.bind(&uid)
|
||||
.fetch_one(&mut *transaction)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(stored.0["tts"], true);
|
||||
assert_eq!(stored.0["custom"], json!([1, 2]));
|
||||
assert_eq!(stored.0["embeds"][0]["description"], "updated");
|
||||
assert_eq!(stored.0["embeds"][1]["description"], "second");
|
||||
sqlx::query("UPDATE reminders SET attachment = NULL WHERE uid = ?")
|
||||
.bind(&uid)
|
||||
.execute(&mut *transaction)
|
||||
.await
|
||||
.unwrap();
|
||||
update_field(&mut transaction, &uid, "attachments", &()).await.unwrap();
|
||||
let stored: (Json<Value>,) =
|
||||
sqlx::query_as("SELECT message_json FROM reminders WHERE uid = ?")
|
||||
.bind(&uid)
|
||||
.fetch_one(&mut *transaction)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(stored.0["attachments"], json!([]));
|
||||
transaction.rollback().await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ use poise::serenity_prelude::{
|
||||
model::id::{ChannelId, GuildId, UserId},
|
||||
ChannelType,
|
||||
};
|
||||
use sqlx::MySqlPool;
|
||||
use sqlx::{types::Json, MySqlPool};
|
||||
|
||||
use crate::{
|
||||
consts::{DAY, MAX_TIME, MIN_INTERVAL},
|
||||
@@ -76,6 +76,12 @@ impl ReminderBuilder {
|
||||
if utc_time < (Utc::now() - TimeDelta::try_minutes(1).unwrap()).naive_local() {
|
||||
Err(ReminderError::PastTime)
|
||||
} else {
|
||||
let mut message_json =
|
||||
serde_json::json!({"content": self.content, "tts": self.tts});
|
||||
if let (Some(_), Some(name)) = (&self.attachment, &self.attachment_name) {
|
||||
message_json["attachments"] =
|
||||
serde_json::json!([{ "id": 0, "filename": name }]);
|
||||
}
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO reminders (
|
||||
@@ -88,13 +94,12 @@ impl ReminderBuilder {
|
||||
`interval_days`,
|
||||
`interval_months`,
|
||||
`expires`,
|
||||
`content`,
|
||||
`tts`,
|
||||
`message_json`,
|
||||
`attachment_name`,
|
||||
`attachment`,
|
||||
`set_by`
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
)
|
||||
",
|
||||
self.uid,
|
||||
@@ -106,8 +111,7 @@ impl ReminderBuilder {
|
||||
self.interval_days,
|
||||
self.interval_months,
|
||||
self.expires,
|
||||
self.content,
|
||||
self.tts,
|
||||
Json(message_json),
|
||||
self.attachment_name,
|
||||
self.attachment,
|
||||
self.set_by
|
||||
|
||||
@@ -49,6 +49,7 @@ pub struct Reminder {
|
||||
pub interval_months: Option<u32>,
|
||||
pub expires: Option<NaiveDateTime>,
|
||||
pub enabled: bool,
|
||||
pub message_json: Option<sqlx::types::Json<serde_json::Value>>,
|
||||
pub content: String,
|
||||
pub embed_description: String,
|
||||
pub set_by: Option<u64>,
|
||||
@@ -83,6 +84,7 @@ impl Reminder {
|
||||
reminders.interval_months,
|
||||
reminders.expires,
|
||||
reminders.enabled,
|
||||
reminders.message_json,
|
||||
reminders.content,
|
||||
reminders.embed_description,
|
||||
reminders.set_by
|
||||
@@ -116,6 +118,7 @@ impl Reminder {
|
||||
reminders.interval_months,
|
||||
reminders.expires,
|
||||
reminders.enabled,
|
||||
reminders.message_json,
|
||||
reminders.content,
|
||||
reminders.embed_description,
|
||||
reminders.set_by
|
||||
@@ -156,6 +159,7 @@ impl Reminder {
|
||||
reminders.interval_months,
|
||||
reminders.expires,
|
||||
reminders.enabled,
|
||||
reminders.message_json,
|
||||
reminders.content,
|
||||
reminders.embed_description,
|
||||
reminders.set_by
|
||||
@@ -215,6 +219,7 @@ impl Reminder {
|
||||
reminders.interval_months,
|
||||
reminders.expires,
|
||||
reminders.enabled,
|
||||
reminders.message_json,
|
||||
reminders.content,
|
||||
reminders.embed_description,
|
||||
reminders.set_by
|
||||
@@ -251,6 +256,7 @@ impl Reminder {
|
||||
reminders.interval_months,
|
||||
reminders.expires,
|
||||
reminders.enabled,
|
||||
reminders.message_json,
|
||||
reminders.content,
|
||||
reminders.embed_description,
|
||||
reminders.set_by
|
||||
@@ -284,6 +290,7 @@ impl Reminder {
|
||||
reminders.interval_months,
|
||||
reminders.expires,
|
||||
reminders.enabled,
|
||||
reminders.message_json,
|
||||
reminders.content,
|
||||
reminders.embed_description,
|
||||
reminders.set_by
|
||||
@@ -321,6 +328,13 @@ impl Reminder {
|
||||
}
|
||||
|
||||
pub fn display_content(&self) -> &str {
|
||||
if let Some(message) = &self.message_json {
|
||||
let content = message["content"].as_str().unwrap_or_default();
|
||||
if !content.is_empty() {
|
||||
return content;
|
||||
}
|
||||
return message["embeds"][0]["description"].as_str().unwrap_or_default();
|
||||
}
|
||||
if self.content.is_empty() {
|
||||
&self.embed_description
|
||||
} else {
|
||||
|
||||
+54
-1
@@ -17,6 +17,7 @@ use poise::serenity_prelude::{
|
||||
Error, Result,
|
||||
};
|
||||
use regex::{Captures, Regex};
|
||||
use secrecy::ExposeSecret;
|
||||
use serde::Deserialize;
|
||||
use sqlx::{
|
||||
types::{
|
||||
@@ -341,6 +342,7 @@ pub struct Reminder {
|
||||
tts: bool,
|
||||
pin: bool,
|
||||
content: String,
|
||||
message_json: Option<Json<serde_json::Value>>,
|
||||
attachment: Option<Vec<u8>>,
|
||||
attachment_name: Option<String>,
|
||||
|
||||
@@ -376,6 +378,7 @@ impl Reminder {
|
||||
reminders.`tts` AS tts,
|
||||
reminders.`pin` AS pin,
|
||||
reminders.`content` AS content,
|
||||
reminders.`message_json` AS message_json,
|
||||
reminders.`attachment` AS attachment,
|
||||
reminders.`attachment_name` AS attachment_name,
|
||||
|
||||
@@ -585,6 +588,15 @@ impl Reminder {
|
||||
let _ = http.as_ref().pin_message(self.channel_id.into(), message_id.into(), None).await;
|
||||
}
|
||||
|
||||
fn files(&self) -> Vec<CreateAttachment> {
|
||||
match (&self.attachment, &self.attachment_name) {
|
||||
(Some(attachment), Some(name)) => {
|
||||
vec![CreateAttachment::bytes(attachment.clone(), name)]
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn send(
|
||||
&self,
|
||||
pool: impl Executor<'_, Database = Database> + Copy,
|
||||
@@ -595,6 +607,22 @@ impl Reminder {
|
||||
reminder: &Reminder,
|
||||
embed: Option<CreateEmbed>,
|
||||
) -> Result<()> {
|
||||
if let Some(payload) = &reminder.message_json {
|
||||
let files = reminder.files();
|
||||
let message = cache_http
|
||||
.http()
|
||||
.send_message(
|
||||
ChannelId::new(reminder.thread_id.unwrap_or(reminder.channel_id)),
|
||||
files,
|
||||
&payload.0,
|
||||
)
|
||||
.await?;
|
||||
if reminder.pin {
|
||||
reminder.pin_message(message.id, cache_http.http()).await;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let channel = if let Some(thread_id) = reminder.thread_id {
|
||||
ChannelId::new(thread_id).to_channel(&cache_http).await
|
||||
} else {
|
||||
@@ -650,6 +678,27 @@ impl Reminder {
|
||||
webhook: Webhook,
|
||||
embed: Option<CreateEmbed>,
|
||||
) -> Result<()> {
|
||||
if let Some(payload) = &reminder.message_json {
|
||||
let token = webhook.token.as_ref().ok_or(Error::Other("Webhook token missing"))?;
|
||||
let message = cache_http
|
||||
.http()
|
||||
.execute_webhook(
|
||||
webhook.id,
|
||||
reminder.thread_id.map(ChannelId::new),
|
||||
token.expose_secret(),
|
||||
reminder.pin || reminder.restartable,
|
||||
reminder.files(),
|
||||
&payload.0,
|
||||
)
|
||||
.await?;
|
||||
if reminder.pin {
|
||||
if let Some(message) = message {
|
||||
reminder.pin_message(message.id, cache_http.http()).await;
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut builder = if let Some(thread_id) = reminder.thread_id {
|
||||
ExecuteWebhook::new()
|
||||
.content(&reminder.content)
|
||||
@@ -712,7 +761,11 @@ impl Reminder {
|
||||
.execute(pool)
|
||||
.await;
|
||||
|
||||
let embed = Embed::from_id(pool, self.id).await.map(|e| e.into());
|
||||
let embed = if self.message_json.is_none() {
|
||||
Embed::from_id(pool, self.id).await.map(|e| e.into())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let result = if let (Some(webhook_id), Some(webhook_token)) =
|
||||
(self.webhook_id, &self.webhook_token)
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::Database;
|
||||
pub struct Transaction<'a>(sqlx::Transaction<'a, Database>);
|
||||
|
||||
impl Transaction<'_> {
|
||||
pub fn executor(&mut self) -> impl sqlx::Executor<'_, Database = Database> {
|
||||
pub fn executor(&mut self) -> &mut sqlx::MySqlConnection {
|
||||
&mut *(self.0)
|
||||
}
|
||||
|
||||
|
||||
+20
-10
@@ -57,16 +57,26 @@ macro_rules! check_url_opt {
|
||||
macro_rules! update_field {
|
||||
($pool:expr, $error:ident, $reminder:ident.[$field:ident]) => {
|
||||
if let Some(value) = &$reminder.$field {
|
||||
match sqlx::query(concat!(
|
||||
"UPDATE reminders SET `",
|
||||
stringify!($field),
|
||||
"` = ? WHERE uid = ?"
|
||||
))
|
||||
.bind(value)
|
||||
.bind(&$reminder.uid)
|
||||
.execute($pool)
|
||||
.await
|
||||
{
|
||||
let result = 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!(
|
||||
"UPDATE reminders SET `",
|
||||
stringify!($field),
|
||||
"` = ? WHERE uid = ?"
|
||||
))
|
||||
.bind(value)
|
||||
.bind(&$reminder.uid)
|
||||
.execute($pool)
|
||||
.await
|
||||
.map(|_| ());
|
||||
if result.is_ok() && ["attachment", "attachment_name"].contains(&stringify!($field)) {
|
||||
crate::message::update_field($pool, &$reminder.uid, "attachments", &()).await
|
||||
} else {
|
||||
result
|
||||
}
|
||||
};
|
||||
match result {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
|
||||
@@ -83,6 +83,7 @@ pub async fn get_reminders(
|
||||
reminders.attachment_name,
|
||||
reminders.avatar,
|
||||
channels.channel,
|
||||
reminders.message_json,
|
||||
reminders.content,
|
||||
reminders.embed_author,
|
||||
reminders.embed_author_url,
|
||||
@@ -121,7 +122,7 @@ pub async fn get_reminders(
|
||||
query
|
||||
.fetch_all(pool.inner())
|
||||
.await
|
||||
.map(|reminders| Ok(json!(reminders)))
|
||||
.map(|reminders| Ok(crate::message::response(reminders)))
|
||||
.unwrap_or_else(|e| {
|
||||
warn!("Failed to complete SQL query: {:?}", e);
|
||||
|
||||
@@ -349,6 +350,7 @@ pub async fn edit_reminder(
|
||||
reminders.attachment_name,
|
||||
reminders.avatar,
|
||||
channels.channel,
|
||||
reminders.message_json,
|
||||
reminders.content,
|
||||
reminders.embed_author,
|
||||
reminders.embed_author_url,
|
||||
@@ -379,7 +381,9 @@ pub async fn edit_reminder(
|
||||
.fetch_one(pool.inner())
|
||||
.await
|
||||
{
|
||||
Ok(reminder) => Ok(json!({"reminder": reminder, "errors": error})),
|
||||
Ok(reminder) => {
|
||||
Ok(json!({"reminder": crate::message::response(reminder), "errors": error}))
|
||||
}
|
||||
|
||||
Err(e) => {
|
||||
warn!("Error exiting `edit_reminder': {:?}", e);
|
||||
|
||||
@@ -24,17 +24,24 @@ use crate::web::{
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Reminder {
|
||||
pub message_json: Option<Json<serde_json::Value>>,
|
||||
pub attachment: Option<Attachment>,
|
||||
pub attachment_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub content: String,
|
||||
#[serde(default)]
|
||||
pub embed_author: String,
|
||||
pub embed_author_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub embed_color: u32,
|
||||
#[serde(default)]
|
||||
pub embed_description: String,
|
||||
#[serde(default)]
|
||||
pub embed_footer: String,
|
||||
pub embed_footer_url: Option<String>,
|
||||
pub embed_image_url: Option<String>,
|
||||
pub embed_thumbnail_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub embed_title: String,
|
||||
pub embed_fields: Option<Json<Vec<EmbedField>>>,
|
||||
pub enabled: bool,
|
||||
@@ -44,6 +51,7 @@ pub struct Reminder {
|
||||
pub interval_months: Option<u32>,
|
||||
#[serde(default = "name_default")]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub tts: bool,
|
||||
#[serde(default)]
|
||||
pub uid: String,
|
||||
@@ -79,6 +87,10 @@ 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"}));
|
||||
}
|
||||
|
||||
// validate lengths
|
||||
check_length!(MAX_NAME_LENGTH, reminder.name);
|
||||
check_length!(MAX_CONTENT_LENGTH, reminder.content);
|
||||
@@ -139,55 +151,26 @@ 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 (
|
||||
uid,
|
||||
attachment,
|
||||
attachment_name,
|
||||
channel_id,
|
||||
content,
|
||||
embed_author,
|
||||
embed_author_url,
|
||||
embed_color,
|
||||
embed_description,
|
||||
embed_footer,
|
||||
embed_footer_url,
|
||||
embed_image_url,
|
||||
embed_thumbnail_url,
|
||||
embed_title,
|
||||
embed_fields,
|
||||
enabled,
|
||||
expires,
|
||||
interval_seconds,
|
||||
interval_days,
|
||||
interval_months,
|
||||
name,
|
||||
tts,
|
||||
`utc_time`
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
uid, attachment, attachment_name, channel_id, message_json,
|
||||
enabled, expires, interval_seconds, interval_days, interval_months,
|
||||
name, `utc_time`
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
new_uid,
|
||||
reminder.attachment,
|
||||
reminder.attachment_name,
|
||||
channel,
|
||||
reminder.content,
|
||||
reminder.embed_author,
|
||||
reminder.embed_author_url,
|
||||
reminder.embed_color,
|
||||
reminder.embed_description,
|
||||
reminder.embed_footer,
|
||||
reminder.embed_footer_url,
|
||||
reminder.embed_image_url,
|
||||
reminder.embed_thumbnail_url,
|
||||
reminder.embed_title,
|
||||
reminder.embed_fields,
|
||||
message_json,
|
||||
reminder.enabled,
|
||||
reminder.expires,
|
||||
reminder.interval_seconds,
|
||||
reminder.interval_days,
|
||||
reminder.interval_months,
|
||||
name,
|
||||
reminder.tts,
|
||||
reminder.utc_time,
|
||||
)
|
||||
.execute(transaction.executor())
|
||||
@@ -198,6 +181,7 @@ pub async fn create_reminder(
|
||||
"SELECT
|
||||
reminders.attachment,
|
||||
reminders.attachment_name,
|
||||
reminders.message_json,
|
||||
reminders.content,
|
||||
reminders.embed_author,
|
||||
reminders.embed_author_url,
|
||||
@@ -224,7 +208,7 @@ pub async fn create_reminder(
|
||||
)
|
||||
.fetch_one(transaction.executor())
|
||||
.await
|
||||
.map(|r| Ok(json!(r)))
|
||||
.map(|r| Ok(crate::message::response(r)))
|
||||
.unwrap_or_else(|e| {
|
||||
warn!("Failed to complete SQL query: {:?}", e);
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ pub async fn get_reminders(
|
||||
SELECT
|
||||
reminders.attachment,
|
||||
reminders.attachment_name,
|
||||
reminders.message_json,
|
||||
reminders.content,
|
||||
reminders.embed_author,
|
||||
reminders.embed_author_url,
|
||||
@@ -97,7 +98,7 @@ pub async fn get_reminders(
|
||||
)
|
||||
.fetch_all(pool.inner())
|
||||
.await
|
||||
.map(|r| Ok(json!(r)))
|
||||
.map(|r| Ok(crate::message::response(r)))
|
||||
.unwrap_or_else(|e| {
|
||||
warn!("Failed to complete SQL query: {:?}", e);
|
||||
|
||||
@@ -246,6 +247,7 @@ pub async fn edit_reminder(
|
||||
"
|
||||
SELECT reminders.attachment,
|
||||
reminders.attachment_name,
|
||||
reminders.message_json,
|
||||
reminders.content,
|
||||
reminders.embed_author,
|
||||
reminders.embed_author_url,
|
||||
@@ -275,7 +277,9 @@ pub async fn edit_reminder(
|
||||
.fetch_one(pool.inner())
|
||||
.await
|
||||
{
|
||||
Ok(reminder) => Ok(json!({"reminder": reminder, "errors": error})),
|
||||
Ok(reminder) => {
|
||||
Ok(json!({"reminder": crate::message::response(reminder), "errors": error}))
|
||||
}
|
||||
|
||||
Err(e) => {
|
||||
warn!("Error exiting `edit_reminder': {:?}", e);
|
||||
|
||||
@@ -48,6 +48,7 @@ pub async fn export(
|
||||
reminders.attachment_name,
|
||||
reminders.avatar,
|
||||
CONCAT('#', channels.channel) AS channel,
|
||||
CAST(reminders.message_json AS CHAR) AS message_json,
|
||||
reminders.content,
|
||||
reminders.embed_author,
|
||||
reminders.embed_author_url,
|
||||
@@ -150,6 +151,11 @@ pub async fn import(
|
||||
match channel_id.parse::<u64>() {
|
||||
Ok(channel_id) => {
|
||||
let reminder = CreateReminder {
|
||||
message_json: record
|
||||
.message_json
|
||||
.map(|s| serde_json::from_str(&s).map(sqlx::types::Json))
|
||||
.transpose()
|
||||
.map_err(|_| json!({"error": "Invalid message_json"}))?,
|
||||
attachment: record.attachment,
|
||||
attachment_name: record.attachment_name,
|
||||
avatar: record.avatar,
|
||||
|
||||
@@ -148,22 +148,29 @@ pub struct EmbedField {
|
||||
inline: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct CreateReminder {
|
||||
message_json: Option<Json<serde_json::Value>>,
|
||||
attachment: Option<Attachment>,
|
||||
attachment_name: Option<String>,
|
||||
avatar: Option<String>,
|
||||
#[serde(with = "string")]
|
||||
channel: u64,
|
||||
#[serde(default)]
|
||||
content: String,
|
||||
#[serde(default)]
|
||||
embed_author: String,
|
||||
embed_author_url: Option<String>,
|
||||
#[serde(default)]
|
||||
embed_color: u32,
|
||||
#[serde(default)]
|
||||
embed_description: String,
|
||||
#[serde(default)]
|
||||
embed_footer: String,
|
||||
embed_footer_url: Option<String>,
|
||||
embed_image_url: Option<String>,
|
||||
embed_thumbnail_url: Option<String>,
|
||||
#[serde(default)]
|
||||
embed_title: String,
|
||||
embed_fields: Option<Json<Vec<EmbedField>>>,
|
||||
enabled: bool,
|
||||
@@ -174,6 +181,7 @@ pub struct CreateReminder {
|
||||
#[serde(default = "name_default")]
|
||||
name: String,
|
||||
restartable: bool,
|
||||
#[serde(default)]
|
||||
tts: bool,
|
||||
username: Option<String>,
|
||||
utc_time: NaiveDateTime,
|
||||
@@ -181,6 +189,7 @@ pub struct CreateReminder {
|
||||
|
||||
#[derive(Serialize, FromRow)]
|
||||
pub struct GetReminder {
|
||||
message_json: Option<Json<serde_json::Value>>,
|
||||
attachment_name: Option<String>,
|
||||
avatar: Option<String>,
|
||||
#[serde(with = "string")]
|
||||
@@ -213,6 +222,8 @@ pub struct GetReminder {
|
||||
|
||||
#[derive(Serialize, Deserialize, FromRow)]
|
||||
pub struct ReminderCsv {
|
||||
#[serde(default)]
|
||||
message_json: Option<String>,
|
||||
attachment: Option<Attachment>,
|
||||
attachment_name: Option<String>,
|
||||
avatar: Option<String>,
|
||||
@@ -422,6 +433,10 @@ 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"}));
|
||||
}
|
||||
|
||||
// validate lengths
|
||||
check_length!(MAX_NAME_LENGTH, reminder.name);
|
||||
check_length!(MAX_CONTENT_LENGTH, reminder.content);
|
||||
@@ -483,60 +498,22 @@ pub(crate) async fn create_reminder(
|
||||
}
|
||||
|
||||
let name = if reminder.name.is_empty() { name_default() } else { reminder.name.clone() };
|
||||
let username = if reminder.username.as_ref().map(|s| s.is_empty()).unwrap_or(true) {
|
||||
None
|
||||
} else {
|
||||
reminder.username
|
||||
};
|
||||
let message_json = Json(crate::message::payload(&reminder));
|
||||
|
||||
let new_uid = generate_uid();
|
||||
|
||||
// write to db
|
||||
match sqlx::query!(
|
||||
"INSERT INTO reminders (
|
||||
uid,
|
||||
attachment,
|
||||
attachment_name,
|
||||
channel_id,
|
||||
avatar,
|
||||
content,
|
||||
embed_author,
|
||||
embed_author_url,
|
||||
embed_color,
|
||||
embed_description,
|
||||
embed_footer,
|
||||
embed_footer_url,
|
||||
embed_image_url,
|
||||
embed_thumbnail_url,
|
||||
embed_title,
|
||||
embed_fields,
|
||||
enabled,
|
||||
expires,
|
||||
interval_seconds,
|
||||
interval_days,
|
||||
interval_months,
|
||||
name,
|
||||
restartable,
|
||||
tts,
|
||||
username,
|
||||
`utc_time`
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
uid, attachment, attachment_name, channel_id, message_json,
|
||||
enabled, expires, interval_seconds, interval_days, interval_months,
|
||||
name, restartable, `utc_time`
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
new_uid,
|
||||
reminder.attachment,
|
||||
reminder.attachment_name,
|
||||
channel,
|
||||
reminder.avatar,
|
||||
reminder.content,
|
||||
reminder.embed_author,
|
||||
reminder.embed_author_url,
|
||||
reminder.embed_color,
|
||||
reminder.embed_description,
|
||||
reminder.embed_footer,
|
||||
reminder.embed_footer_url,
|
||||
reminder.embed_image_url,
|
||||
reminder.embed_thumbnail_url,
|
||||
reminder.embed_title,
|
||||
reminder.embed_fields,
|
||||
message_json,
|
||||
reminder.enabled,
|
||||
reminder.expires,
|
||||
reminder.interval_seconds,
|
||||
@@ -544,8 +521,6 @@ pub(crate) async fn create_reminder(
|
||||
reminder.interval_months,
|
||||
name,
|
||||
reminder.restartable,
|
||||
reminder.tts,
|
||||
username,
|
||||
reminder.utc_time,
|
||||
)
|
||||
.execute(transaction.executor())
|
||||
@@ -558,6 +533,7 @@ pub(crate) async fn create_reminder(
|
||||
reminders.attachment_name,
|
||||
reminders.avatar,
|
||||
channels.channel,
|
||||
reminders.message_json,
|
||||
reminders.content,
|
||||
reminders.embed_author,
|
||||
reminders.embed_author_url,
|
||||
@@ -587,7 +563,7 @@ pub(crate) async fn create_reminder(
|
||||
)
|
||||
.fetch_one(transaction.executor())
|
||||
.await
|
||||
.map(|r| Ok(json!(r)))
|
||||
.map(|r| Ok(crate::message::response(r)))
|
||||
.unwrap_or_else(|e| {
|
||||
warn!("Failed to complete SQL query: {:?}", e);
|
||||
|
||||
|
||||
Reference in new issue
Block a user