diff --git a/Cargo.lock b/Cargo.lock
index 07f29dd..a18f360 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -35,7 +35,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
- "cpufeatures",
+ "cpufeatures 0.2.15",
]
[[package]]
@@ -389,6 +389,23 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
+[[package]]
+name = "cfg_aliases"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
+
+[[package]]
+name = "chacha20"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.1",
+ "rand_core 0.10.1",
+]
+
[[package]]
name = "chrono"
version = "0.4.38"
@@ -468,7 +485,7 @@ dependencies = [
"base64 0.22.1",
"hkdf",
"percent-encoding",
- "rand",
+ "rand 0.8.5",
"sha2",
"subtle",
"time",
@@ -500,6 +517,15 @@ dependencies = [
"libc",
]
+[[package]]
+name = "cpufeatures"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "crc"
version = "3.2.1"
@@ -583,7 +609,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
dependencies = [
"generic-array",
- "rand_core",
+ "rand_core 0.6.4",
"typenum",
]
@@ -1066,15 +1092,6 @@ dependencies = [
"slab",
]
-[[package]]
-name = "fxhash"
-version = "0.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
-dependencies = [
- "byteorder",
-]
-
[[package]]
name = "generator"
version = "0.7.5"
@@ -1111,6 +1128,20 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "r-efi",
+ "rand_core 0.10.1",
+ "wasm-bindgen",
+]
+
[[package]]
name = "ghash"
version = "0.5.1"
@@ -1427,6 +1458,7 @@ dependencies = [
"tokio",
"tokio-rustls 0.26.0",
"tower-service",
+ "webpki-roots 0.26.6",
]
[[package]]
@@ -1725,10 +1757,12 @@ checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b"
[[package]]
name = "js-sys"
-version = "0.3.72"
+version = "0.3.105"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6a88f1bda2bd75b0452a14784937d796722fdebfe50df998aeb3f0b7603019a9"
+checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"
dependencies = [
+ "cfg-if",
+ "futures-util",
"wasm-bindgen",
]
@@ -1844,6 +1878,12 @@ dependencies = [
"tracing-subscriber",
]
+[[package]]
+name = "lru-slab"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
+
[[package]]
name = "matchers"
version = "0.1.0"
@@ -2045,7 +2085,7 @@ dependencies = [
"num-integer",
"num-iter",
"num-traits",
- "rand",
+ "rand 0.8.5",
"smallvec",
"zeroize",
]
@@ -2104,15 +2144,15 @@ checksum = "c38841cdd844847e3e7c8d29cef9dcfed8877f8f56f9071f77843ecf3baf937f"
dependencies = [
"base64 0.13.1",
"chrono",
- "getrandom",
+ "getrandom 0.2.15",
"http 0.2.12",
- "rand",
+ "rand 0.8.5",
"reqwest 0.11.27",
"serde",
"serde_json",
"serde_path_to_error",
"sha2",
- "thiserror",
+ "thiserror 1.0.69",
"url",
]
@@ -2276,7 +2316,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879952a81a83930934cbf1786752d6dedc3b1f29e8f8fb2ad1d0a36f377cf442"
dependencies = [
"memchr",
- "thiserror",
+ "thiserror 1.0.69",
"ucd-trie",
]
@@ -2340,7 +2380,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0"
dependencies = [
"phf_shared",
- "rand",
+ "rand 0.8.5",
]
[[package]]
@@ -2427,7 +2467,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
- "cpufeatures",
+ "cpufeatures 0.2.15",
"opaque-debug",
"universal-hash",
]
@@ -2449,9 +2489,9 @@ dependencies = [
[[package]]
name = "proc-macro2"
-version = "1.0.89"
+version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
@@ -2481,7 +2521,7 @@ dependencies = [
"memchr",
"parking_lot",
"protobuf",
- "thiserror",
+ "thiserror 1.0.69",
]
[[package]]
@@ -2501,6 +2541,62 @@ dependencies = [
"unicase",
]
+[[package]]
+name = "quinn"
+version = "0.11.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
+dependencies = [
+ "bytes",
+ "cfg_aliases",
+ "pin-project-lite",
+ "quinn-proto",
+ "quinn-udp",
+ "rustc-hash",
+ "rustls 0.23.16",
+ "socket2",
+ "thiserror 2.0.20",
+ "tokio",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-proto"
+version = "0.11.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83"
+dependencies = [
+ "bytes",
+ "getrandom 0.4.3",
+ "lru-slab",
+ "rand 0.10.2",
+ "rand_pcg",
+ "ring",
+ "rustc-hash",
+ "rustls 0.23.16",
+ "rustls-pki-types",
+ "slab",
+ "thiserror 2.0.20",
+ "tinyvec",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-udp"
+version = "0.5.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
+dependencies = [
+ "cfg_aliases",
+ "libc",
+ "once_cell",
+ "socket2",
+ "tracing",
+ "windows-sys 0.59.0",
+]
+
[[package]]
name = "quote"
version = "1.0.37"
@@ -2510,6 +2606,12 @@ dependencies = [
"proc-macro2",
]
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
[[package]]
name = "rand"
version = "0.8.5"
@@ -2518,7 +2620,18 @@ checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha",
- "rand_core",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
+dependencies = [
+ "chacha20",
+ "getrandom 0.4.3",
+ "rand_core 0.10.1",
]
[[package]]
@@ -2528,7 +2641,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
- "rand_core",
+ "rand_core 0.6.4",
]
[[package]]
@@ -2537,7 +2650,22 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
- "getrandom",
+ "getrandom 0.2.15",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
+
+[[package]]
+name = "rand_pcg"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
+dependencies = [
+ "rand_core 0.10.1",
]
[[package]]
@@ -2641,7 +2769,7 @@ dependencies = [
"oauth2",
"poise",
"prometheus",
- "rand",
+ "rand 0.8.5",
"recordable_derive",
"regex",
"reqwest 0.12.9",
@@ -2678,7 +2806,6 @@ dependencies = [
"js-sys",
"log",
"mime",
- "mime_guess",
"once_cell",
"percent-encoding",
"pin-project-lite",
@@ -2691,12 +2818,10 @@ dependencies = [
"system-configuration 0.5.1",
"tokio",
"tokio-rustls 0.24.1",
- "tokio-util",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
- "wasm-streams",
"web-sys",
"webpki-roots 0.25.4",
"winreg",
@@ -2725,11 +2850,15 @@ dependencies = [
"js-sys",
"log",
"mime",
+ "mime_guess",
"native-tls",
"once_cell",
"percent-encoding",
"pin-project-lite",
+ "quinn",
+ "rustls 0.23.16",
"rustls-pemfile 2.2.0",
+ "rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
@@ -2737,11 +2866,15 @@ dependencies = [
"system-configuration 0.6.1",
"tokio",
"tokio-native-tls",
+ "tokio-rustls 0.26.0",
+ "tokio-util",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
+ "wasm-streams",
"web-sys",
+ "webpki-roots 0.26.6",
"windows-registry",
]
@@ -2753,7 +2886,7 @@ checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d"
dependencies = [
"cc",
"cfg-if",
- "getrandom",
+ "getrandom 0.2.15",
"libc",
"spin",
"untrusted",
@@ -2803,7 +2936,7 @@ dependencies = [
"num_cpus",
"parking_lot",
"pin-project-lite",
- "rand",
+ "rand 0.8.5",
"ref-cast",
"rocket_codegen",
"rocket_http",
@@ -2893,7 +3026,7 @@ dependencies = [
"num-traits",
"pkcs1",
"pkcs8",
- "rand_core",
+ "rand_core 0.6.4",
"signature",
"spki",
"subtle",
@@ -2906,6 +3039,12 @@ version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
+[[package]]
+name = "rustc-hash"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
+
[[package]]
name = "rustix"
version = "0.38.40"
@@ -2982,6 +3121,9 @@ name = "rustls-pki-types"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16f1201b3c9a7ee8039bcadc17b7e605e2945b27eee7631788c1bd2b0643674b"
+dependencies = [
+ "web-time",
+]
[[package]]
name = "rustls-webpki"
@@ -3189,9 +3331,9 @@ dependencies = [
[[package]]
name = "serenity"
-version = "0.12.4"
+version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3d72ec4323681bf9a3cabe40fd080abc2435859b502a1b5aa9bf693f125bfa76"
+checksum = "9bde37f42765dfdc34e2a039e0c84afbf79a3101c1941763b0beb816c2f17541"
dependencies = [
"arrayvec",
"async-trait",
@@ -3202,11 +3344,11 @@ dependencies = [
"dashmap",
"flate2",
"futures",
- "fxhash",
"mime_guess",
"parking_lot",
"percent-encoding",
- "reqwest 0.11.27",
+ "reqwest 0.12.9",
+ "rustc-hash",
"secrecy",
"serde",
"serde_cow",
@@ -3227,7 +3369,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
- "cpufeatures",
+ "cpufeatures 0.2.15",
"digest",
]
@@ -3238,7 +3380,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8"
dependencies = [
"cfg-if",
- "cpufeatures",
+ "cpufeatures 0.2.15",
"digest",
]
@@ -3273,7 +3415,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"digest",
- "rand_core",
+ "rand_core 0.6.4",
]
[[package]]
@@ -3413,7 +3555,7 @@ dependencies = [
"sha2",
"smallvec",
"sqlformat",
- "thiserror",
+ "thiserror 1.0.69",
"tokio",
"tokio-stream",
"tracing",
@@ -3491,7 +3633,7 @@ dependencies = [
"memchr",
"once_cell",
"percent-encoding",
- "rand",
+ "rand 0.8.5",
"rsa",
"serde",
"sha1",
@@ -3499,7 +3641,7 @@ dependencies = [
"smallvec",
"sqlx-core",
"stringprep",
- "thiserror",
+ "thiserror 1.0.69",
"tracing",
"whoami",
]
@@ -3533,14 +3675,14 @@ dependencies = [
"memchr",
"num-bigint",
"once_cell",
- "rand",
+ "rand 0.8.5",
"serde",
"serde_json",
"sha2",
"smallvec",
"sqlx-core",
"stringprep",
- "thiserror",
+ "thiserror 1.0.69",
"tracing",
"whoami",
]
@@ -3638,6 +3780,17 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "syn"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
[[package]]
name = "sync_wrapper"
version = "0.1.2"
@@ -3739,7 +3892,7 @@ dependencies = [
"percent-encoding",
"pest",
"pest_derive",
- "rand",
+ "rand 0.8.5",
"regex",
"serde",
"serde_json",
@@ -3753,7 +3906,16 @@ version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
- "thiserror-impl",
+ "thiserror-impl 1.0.69",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
+dependencies = [
+ "thiserror-impl 2.0.20",
]
[[package]]
@@ -3767,6 +3929,17 @@ dependencies = [
"syn 2.0.87",
]
+[[package]]
+name = "thiserror-impl"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
[[package]]
name = "thread_local"
version = "1.1.8"
@@ -4070,11 +4243,11 @@ dependencies = [
"http 1.1.0",
"httparse",
"log",
- "rand",
+ "rand 0.8.5",
"rustls 0.22.4",
"rustls-pki-types",
"sha1",
- "thiserror",
+ "thiserror 1.0.69",
"url",
"utf-8",
]
@@ -4343,27 +4516,14 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
[[package]]
name = "wasm-bindgen"
-version = "0.2.95"
+version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e"
+checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
dependencies = [
"cfg-if",
"once_cell",
+ "rustversion",
"wasm-bindgen-macro",
-]
-
-[[package]]
-name = "wasm-bindgen-backend"
-version = "0.2.95"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358"
-dependencies = [
- "bumpalo",
- "log",
- "once_cell",
- "proc-macro2",
- "quote",
- "syn 2.0.87",
"wasm-bindgen-shared",
]
@@ -4381,9 +4541,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
-version = "0.2.95"
+version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56"
+checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -4391,22 +4551,25 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
-version = "0.2.95"
+version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68"
+checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
dependencies = [
+ "bumpalo",
"proc-macro2",
"quote",
- "syn 2.0.87",
- "wasm-bindgen-backend",
+ "syn 3.0.6",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
-version = "0.2.95"
+version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d"
+checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
+dependencies = [
+ "unicode-ident",
+]
[[package]]
name = "wasm-streams"
@@ -4431,6 +4594,16 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
[[package]]
name = "webpki-roots"
version = "0.25.4"
diff --git a/Cargo.toml b/Cargo.toml
index 871cce9..8c93ba3 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -31,7 +31,7 @@ futures = "0.3.30"
prometheus = "0.13.3"
rocket = { version = "0.5.0", features = ["tls", "secrets", "json"] }
rocket_dyn_templates = { version = "0.2.0", features = ["tera"] }
-serenity = { version = "0.12", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] }
+serenity = { version = "0.12.5", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] }
oauth2 = "4"
csv = "1.2"
sd-notify = "0.4.1"
diff --git a/reminder-dashboard/package.json b/reminder-dashboard/package.json
index 875efee..17f85a1 100644
--- a/reminder-dashboard/package.json
+++ b/reminder-dashboard/package.json
@@ -5,6 +5,7 @@
"scripts": {
"dev": "vite build --watch --mode development",
"build": "vite build",
+ "test": "node --test tests/*.test.mjs",
"preview": "vite preview",
"prettier": "prettier -w src/"
},
diff --git a/reminder-dashboard/src/api.ts b/reminder-dashboard/src/api.ts
index de689b4..1bc2779 100644
--- a/reminder-dashboard/src/api.ts
+++ b/reminder-dashboard/src/api.ts
@@ -1,3 +1,5 @@
+import { prepareReminder } from "./components/Reminder/message";
+import type { MessagePayload } from "./components/Reminder/message";
import axios from "axios";
enum ColorScheme {
@@ -38,6 +40,7 @@ type EmbedField = {
};
export type Reminder = {
+ message_json?: MessagePayload | null;
attachment: string | null;
attachment_name: string | null;
avatar: string | null;
@@ -186,12 +189,14 @@ export const fetchGuildReminders = (guild: string) => ({
export const patchGuildReminder = (guild: string) => ({
mutationFn: (reminder: Reminder) =>
- axios.patch(`/dashboard/api/guild/${guild}/reminders`, reminder),
+ axios.patch(`/dashboard/api/guild/${guild}/reminders`, prepareReminder(reminder)),
});
export const postGuildReminder = (guild: string) => ({
mutationFn: (reminder: Reminder) =>
- axios.post(`/dashboard/api/guild/${guild}/reminders`, reminder).then((resp) => resp.data),
+ axios
+ .post(`/dashboard/api/guild/${guild}/reminders`, prepareReminder(reminder))
+ .then((resp) => resp.data),
});
export const deleteReminder = () => ({
@@ -257,9 +262,12 @@ export const fetchUserReminders = () => ({
export const postUserReminder = () => ({
mutationFn: (reminder: Reminder) =>
- axios.post(`/dashboard/api/user/reminders`, reminder).then((resp) => resp.data),
+ axios
+ .post(`/dashboard/api/user/reminders`, prepareReminder(reminder))
+ .then((resp) => resp.data),
});
export const patchUserReminder = () => ({
- mutationFn: (reminder: Reminder) => axios.patch(`/dashboard/api/user/reminders`, reminder),
+ mutationFn: (reminder: Reminder) =>
+ axios.patch(`/dashboard/api/user/reminders`, prepareReminder(reminder)),
});
diff --git a/reminder-dashboard/src/components/Reminder/Embed/Embeds.tsx b/reminder-dashboard/src/components/Reminder/Embed/Embeds.tsx
new file mode 100644
index 0000000..7d300ed
--- /dev/null
+++ b/reminder-dashboard/src/components/Reminder/Embed/Embeds.tsx
@@ -0,0 +1,69 @@
+import { Embed } from "./index";
+import { ReminderContext, useReminder } from "../ReminderContext";
+import { Reminder } from "../../../api";
+import {
+ addEmbed,
+ embedFields,
+ embedTextLength,
+ MAX_EMBEDS,
+ MAX_EMBED_TEXT,
+ reminderEmbeds,
+ removeEmbed,
+ updateEmbed,
+} from "../message";
+
+const EmbedEditor = ({ index }: { index: number }) => {
+ const [reminder, setReminder] = useReminder();
+ const embed = reminderEmbeds(reminder)[index];
+ const update = (change: (r: Reminder) => Reminder) => {
+ setReminder((current) => updateEmbed(current, index, change));
+ };
+
+ return (
+
+
+
+
+
+
+ );
+};
+
+export const Embeds = () => {
+ const [reminder, setReminder] = useReminder();
+ const embeds = reminderEmbeds(reminder);
+ const textLength = embedTextLength(embeds);
+
+ return (
+
+ {embeds.map((_, index) => (
+
+ ))}
+
+
+ MAX_EMBED_TEXT ? " has-text-danger" : ""}`}
+ aria-live="polite"
+ >
+ {embeds.length}/{MAX_EMBEDS} embeds ยท {textLength}/{MAX_EMBED_TEXT} characters
+
+
+
+ );
+};
diff --git a/reminder-dashboard/src/components/Reminder/Embed/Fields/index.tsx b/reminder-dashboard/src/components/Reminder/Embed/Fields/index.tsx
index cb5b648..9d3d5e2 100644
--- a/reminder-dashboard/src/components/Reminder/Embed/Fields/index.tsx
+++ b/reminder-dashboard/src/components/Reminder/Embed/Fields/index.tsx
@@ -6,7 +6,10 @@ export const Fields = () => {
return (
- {[...embed_fields, { value: "", title: "", inline: true }].map((field, index) => (
+ {[
+ ...(embed_fields || []),
+ ...(embed_fields?.length >= 25 ? [] : [{ value: "", title: "", inline: true }]),
+ ].map((field, index) => (
{
const [reminder, setReminder] = useReminder();
@@ -21,12 +22,12 @@ export const Embed = () => {
diff --git a/reminder-dashboard/src/components/Reminder/LinkButtons.tsx b/reminder-dashboard/src/components/Reminder/LinkButtons.tsx
new file mode 100644
index 0000000..3d23ac3
--- /dev/null
+++ b/reminder-dashboard/src/components/Reminder/LinkButtons.tsx
@@ -0,0 +1,82 @@
+import { useReminder } from "./ReminderContext";
+import {
+ addLinkButton,
+ canAddLinkButton,
+ linkButtons,
+ MAX_LINK_BUTTONS,
+ updateLinkButton,
+} from "./message";
+
+export const LinkButtons = () => {
+ const [reminder, setReminder] = useReminder();
+ const buttons = linkButtons(reminder);
+
+ return (
+
+ );
+};
diff --git a/reminder-dashboard/src/components/Reminder/LoadTemplate.tsx b/reminder-dashboard/src/components/Reminder/LoadTemplate.tsx
index 8f58e95..f719f37 100644
--- a/reminder-dashboard/src/components/Reminder/LoadTemplate.tsx
+++ b/reminder-dashboard/src/components/Reminder/LoadTemplate.tsx
@@ -82,6 +82,7 @@ const LoadTemplateModal = ({ setModalOpen }) => {
setReminder((reminder) => ({
...reminder,
...template,
+ message_json: null,
// drop the template's ID
id: undefined,
}));
diff --git a/reminder-dashboard/src/components/Reminder/Message.tsx b/reminder-dashboard/src/components/Reminder/Message.tsx
index 07e7e7a..cca82fc 100644
--- a/reminder-dashboard/src/components/Reminder/Message.tsx
+++ b/reminder-dashboard/src/components/Reminder/Message.tsx
@@ -1,7 +1,8 @@
import { Username } from "./Username";
import { Content } from "./Content";
-import { Embed } from "./Embed";
+import { Embeds } from "./Embed/Embeds";
import { Avatar } from "./Avatar";
+import { LinkButtons } from "./LinkButtons";
export const Message = () => (
diff --git a/reminder-dashboard/src/components/Reminder/message.ts b/reminder-dashboard/src/components/Reminder/message.ts
new file mode 100644
index 0000000..8fc1c38
--- /dev/null
+++ b/reminder-dashboard/src/components/Reminder/message.ts
@@ -0,0 +1,303 @@
+import type { Reminder } from "../../api";
+
+export const MAX_EMBEDS = 10;
+export const MAX_EMBED_TEXT = 6000;
+export const DEFAULT_EMBED_COLOR = 9418359;
+export const MAX_BUTTON_ROWS = 5;
+export const MAX_BUTTONS_PER_ROW = 5;
+export const MAX_LINK_BUTTONS = MAX_BUTTON_ROWS * MAX_BUTTONS_PER_ROW;
+
+export type MessageComponent = {
+ type: number;
+ style?: number;
+ label?: string;
+ url?: string;
+ components?: MessageComponent[];
+ [key: string]: unknown;
+};
+
+export type DiscordEmbed = {
+ title?: string;
+ description?: string;
+ color?: number;
+ author?: { name?: string; icon_url?: string; [key: string]: unknown };
+ footer?: { text?: string; icon_url?: string; [key: string]: unknown };
+ image?: { url?: string; [key: string]: unknown };
+ thumbnail?: { url?: string; [key: string]: unknown };
+ fields?: { name: string; value: string; inline?: boolean }[];
+ [key: string]: unknown;
+};
+
+export type MessagePayload = {
+ content?: string;
+ tts?: boolean;
+ username?: string;
+ avatar_url?: string;
+ embeds?: DiscordEmbed[];
+ attachments?: { id: number; filename: string }[];
+ components?: MessageComponent[];
+ [key: string]: unknown;
+};
+
+export function embedFields(embed: DiscordEmbed) {
+ return {
+ embed_title: embed.title || "",
+ embed_description: embed.description || "",
+ embed_color: embed.color ?? DEFAULT_EMBED_COLOR,
+ embed_author: embed.author?.name || "",
+ embed_author_url: embed.author?.icon_url || null,
+ embed_footer: embed.footer?.text || "",
+ embed_footer_url: embed.footer?.icon_url || null,
+ embed_image_url: embed.image?.url || null,
+ embed_thumbnail_url: embed.thumbnail?.url || null,
+ embed_fields: (embed.fields || []).map(({ name, value, inline }) => ({
+ title: name,
+ value,
+ inline: inline ?? false,
+ })),
+ };
+}
+
+export function toEmbed(reminder: Reminder, original: DiscordEmbed = {}): DiscordEmbed {
+ const embed: DiscordEmbed = { ...original, color: reminder.embed_color };
+ for (const [key, value] of [
+ ["title", reminder.embed_title],
+ ["description", reminder.embed_description],
+ ]) {
+ if (value) embed[key] = value;
+ else delete embed[key];
+ }
+ for (const [key, textKey, text, icon] of [
+ ["author", "name", reminder.embed_author, reminder.embed_author_url],
+ ["footer", "text", reminder.embed_footer, reminder.embed_footer_url],
+ ]) {
+ const part = { ...(original[key] as object) };
+ if (text) part[textKey] = text;
+ else delete part[textKey];
+ if (icon) part["icon_url"] = icon;
+ else delete part["icon_url"];
+ if (Object.keys(part).length) embed[key] = part;
+ else delete embed[key];
+ }
+ for (const [key, url] of [
+ ["image", reminder.embed_image_url],
+ ["thumbnail", reminder.embed_thumbnail_url],
+ ]) {
+ if (url) embed[key] = { ...(original[key] as object), url };
+ else delete embed[key];
+ }
+ const fields = (reminder.embed_fields || []).map(({ title, value, inline }) => ({
+ name: title,
+ value,
+ inline,
+ }));
+ if (fields.length) embed.fields = fields;
+ else delete embed.fields;
+ return embed;
+}
+
+export function hasEmbedContent(embed: DiscordEmbed): boolean {
+ return (
+ [
+ embed.title,
+ embed.description,
+ embed.author?.name,
+ embed.footer?.text,
+ embed.footer?.icon_url,
+ embed.image?.url,
+ embed.thumbnail?.url,
+ ].some((value) => !!value?.trim()) ||
+ !!embed.fields?.length ||
+ Object.keys(embed).some(
+ (key) =>
+ ![
+ "title",
+ "description",
+ "author",
+ "footer",
+ "image",
+ "thumbnail",
+ "color",
+ "fields",
+ ].includes(key),
+ )
+ );
+}
+
+export function reminderEmbeds(reminder: Reminder): DiscordEmbed[] {
+ const embeds = reminder.message_json?.embeds;
+ if (embeds) return embeds.length ? embeds : [{ color: DEFAULT_EMBED_COLOR }];
+ return [toEmbed(reminder)];
+}
+
+export function updateEmbed(
+ reminder: Reminder,
+ index: number,
+ update: (r: Reminder) => Reminder,
+): Reminder {
+ const embeds = [...reminderEmbeds(reminder)];
+ const original = embeds[index];
+ if (!original) return reminder;
+ const changed = update({ ...reminder, ...embedFields(original) });
+ embeds[index] = toEmbed(changed, original);
+ return {
+ ...reminder,
+ ...embedFields(embeds[0]),
+ message_json: { ...reminder.message_json, embeds },
+ };
+}
+
+export function addEmbed(reminder: Reminder): Reminder {
+ const embeds = reminderEmbeds(reminder);
+ if (embeds.length >= MAX_EMBEDS) return reminder;
+ return {
+ ...reminder,
+ message_json: {
+ ...reminder.message_json,
+ embeds: [...embeds, { color: DEFAULT_EMBED_COLOR }],
+ },
+ };
+}
+
+export function removeEmbed(reminder: Reminder, index: number): Reminder {
+ const embeds = reminderEmbeds(reminder).filter((_, i) => i !== index);
+ return {
+ ...reminder,
+ ...embedFields(embeds[0] || {}),
+ message_json: { ...reminder.message_json, embeds },
+ };
+}
+
+export function embedTextLength(embeds: DiscordEmbed[]): number {
+ return embeds.reduce(
+ (sum, embed) =>
+ sum +
+ [
+ embed.title,
+ embed.description,
+ embed.author?.name,
+ embed.footer?.text,
+ ...(embed.fields || []).flatMap((field) => [field.name, field.value]),
+ ].reduce((sum, value) => sum + Array.from((value || "").trim()).length, 0),
+ 0,
+ );
+}
+
+export function linkButtons(reminder: Reminder) {
+ return (reminder.message_json?.components || []).flatMap((row, rowIndex) =>
+ row.type === 1
+ ? (row.components || []).flatMap((button, buttonIndex) =>
+ button.type === 2 && button.style === 5
+ ? [{ button, rowIndex, buttonIndex }]
+ : [],
+ )
+ : [],
+ );
+}
+
+function availableButtonRow(components: MessageComponent[]): number {
+ return components.findIndex(
+ (row) =>
+ row.type === 1 &&
+ !!row.components &&
+ row.components.length < MAX_BUTTONS_PER_ROW &&
+ row.components.every((button) => button.type === 2),
+ );
+}
+
+export function canAddLinkButton(reminder: Reminder): boolean {
+ const components = reminder.message_json?.components || [];
+ return availableButtonRow(components) >= 0 || components.length < MAX_BUTTON_ROWS;
+}
+
+export function addLinkButton(reminder: Reminder): Reminder {
+ if (!canAddLinkButton(reminder)) return reminder;
+ const components = [...(reminder.message_json?.components || [])];
+ const rowIndex = availableButtonRow(components);
+ const button: MessageComponent = { type: 2, style: 5, label: "Open link", url: "" };
+ if (rowIndex >= 0) {
+ const row = components[rowIndex];
+ components[rowIndex] = { ...row, components: [...row.components, button] };
+ } else components.push({ type: 1, components: [button] });
+ return { ...reminder, message_json: { ...reminder.message_json, components } };
+}
+
+export function updateLinkButton(
+ reminder: Reminder,
+ rowIndex: number,
+ buttonIndex: number,
+ change: { label?: string; url?: string } | null,
+): Reminder {
+ const components = [...(reminder.message_json?.components || [])];
+ const row = components[rowIndex];
+ const button = row?.components?.[buttonIndex];
+ if (row?.type !== 1 || button?.type !== 2 || button.style !== 5) return reminder;
+ const buttons = [...row.components];
+ if (change) buttons[buttonIndex] = { ...button, ...change };
+ else buttons.splice(buttonIndex, 1);
+ if (buttons.length) components[rowIndex] = { ...row, components: buttons };
+ else components.splice(rowIndex, 1);
+ return { ...reminder, message_json: { ...reminder.message_json, components } };
+}
+
+function validateComponents(components: MessageComponent[] = []) {
+ if (components.length > MAX_BUTTON_ROWS)
+ throw new Error(`A reminder can have at most ${MAX_BUTTON_ROWS} button rows.`);
+ for (const row of components) {
+ if (row.type !== 1) continue;
+ if (
+ !Array.isArray(row.components) ||
+ !row.components.length ||
+ row.components.length > MAX_BUTTONS_PER_ROW
+ )
+ throw new Error(
+ `A button row must have between 1 and ${MAX_BUTTONS_PER_ROW} components.`,
+ );
+ for (const button of row.components) {
+ if (button.type !== 2 || button.style !== 5) continue;
+ if (
+ (button.label !== undefined && typeof button.label !== "string") ||
+ Array.from(button.label || "").length > 80 ||
+ (!button.label && (!button.emoji || typeof button.emoji !== "object"))
+ )
+ throw new Error(
+ "Link buttons require a label of at most 80 characters or an emoji.",
+ );
+ let url: URL;
+ try {
+ url = new URL(button.url);
+ } catch {
+ throw new Error("Link buttons require a valid HTTP or HTTPS URL.");
+ }
+ if (!["http:", "https:"].includes(url.protocol) || Array.from(button.url).length > 512)
+ throw new Error(
+ "Link buttons require an HTTP or HTTPS URL of at most 512 characters.",
+ );
+ if (button.custom_id !== undefined || button.sku_id !== undefined)
+ throw new Error("Link buttons cannot have a custom_id or sku_id.");
+ }
+ }
+}
+
+export function prepareReminder(reminder: Reminder): Reminder {
+ validateComponents(reminder.message_json?.components);
+ const embeds = reminderEmbeds(reminder).filter(hasEmbedContent);
+ if (embeds.length > MAX_EMBEDS)
+ throw new Error(`A reminder can have at most ${MAX_EMBEDS} embeds.`);
+ if (embedTextLength(embeds) > MAX_EMBED_TEXT)
+ throw new Error(`Embed text exceeds ${MAX_EMBED_TEXT} characters across all embeds.`);
+ const message: MessagePayload = {
+ ...reminder.message_json,
+ content: reminder.content,
+ tts: reminder.tts,
+ embeds,
+ };
+ if (reminder.username) message.username = reminder.username;
+ else delete message.username;
+ if (reminder.avatar) message.avatar_url = reminder.avatar;
+ else delete message.avatar_url;
+ if (reminder.attachment_name)
+ message.attachments = [{ id: 0, filename: reminder.attachment_name }];
+ else delete message.attachments;
+ return { ...reminder, ...embedFields(embeds[0] || {}), message_json: message };
+}
diff --git a/reminder-dashboard/src/components/Reminder/styles.scss b/reminder-dashboard/src/components/Reminder/styles.scss
index 6edff79..1007c54 100644
--- a/reminder-dashboard/src/components/Reminder/styles.scss
+++ b/reminder-dashboard/src/components/Reminder/styles.scss
@@ -34,3 +34,55 @@ div.reminderContent {
background-color: #f5f5f5;
border-radius: 8px;
}
+
+.reminder-embed-editor {
+ position: relative;
+ margin-bottom: 12px;
+
+ .discord-embed {
+ padding-bottom: 36px;
+ }
+
+ .remove-embed {
+ position: absolute;
+ right: 24px;
+ bottom: 4px;
+ }
+}
+
+.add-embed-row,
+.add-link-button-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-top: 8px;
+
+ .embed-limit,
+ .link-button-limit {
+ font-size: 0.8rem;
+ color: #737373;
+ }
+}
+
+.reminder-link-buttons {
+ margin-top: 16px;
+}
+
+.link-button-editor {
+ display: flex;
+ align-items: flex-end;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-bottom: 8px;
+
+ label {
+ font-size: 0.8rem;
+ }
+
+ .link-button-label {
+ flex: 1 1 140px;
+ }
+ .link-button-url {
+ flex: 2 1 200px;
+ }
+}
diff --git a/reminder-dashboard/tests/message.test.mjs b/reminder-dashboard/tests/message.test.mjs
new file mode 100644
index 0000000..4b9a6f6
--- /dev/null
+++ b/reminder-dashboard/tests/message.test.mjs
@@ -0,0 +1,186 @@
+import assert from "node:assert/strict";
+import { readFile } from "node:fs/promises";
+import test from "node:test";
+import ts from "typescript";
+
+// Run the pure editor helpers without a browser or additional test dependencies.
+const source = await readFile(
+ new URL("../src/components/Reminder/message.ts", import.meta.url),
+ "utf8",
+);
+const { outputText } = ts.transpileModule(source, {
+ compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2020 },
+});
+const {
+ addEmbed,
+ embedFields,
+ prepareReminder,
+ reminderEmbeds,
+ removeEmbed,
+ updateEmbed,
+ addLinkButton,
+ canAddLinkButton,
+ linkButtons,
+ updateLinkButton,
+} = await import(`data:text/javascript;base64,${Buffer.from(outputText).toString("base64")}`);
+
+function reminder(embeds) {
+ return {
+ content: "hello",
+ tts: false,
+ username: "",
+ avatar: null,
+ attachment_name: null,
+ uid: "unchanged",
+ utc_time: "2026-10-12T12:00:00",
+ ...embedFields(embeds?.[0] || {}),
+ message_json: embeds ? { embeds, allowed_mentions: { parse: [] } } : null,
+ };
+}
+
+test("editing one embed preserves the others and unedited payload properties", () => {
+ const original = reminder([
+ { title: "first" },
+ { description: "second", author: { name: "author", url: "https://example.com" } },
+ ]);
+ const edited = updateEmbed(original, 1, (r) => ({ ...r, embed_description: "updated" }));
+ const payload = prepareReminder(edited).message_json;
+ assert.equal(payload.embeds[0].title, "first");
+ assert.equal(payload.embeds[1].description, "updated");
+ assert.equal(payload.embeds[1].author.url, "https://example.com");
+ assert.deepEqual(payload.allowed_mentions, { parse: [] });
+ assert.equal(edited.uid, original.uid);
+ assert.equal(original.message_json.embeds[1].description, "second");
+});
+
+test("adding stops at ten embeds and removal permits another", () => {
+ let value = reminder([{ title: "first" }]);
+ for (let i = 0; i < 12; i++) value = addEmbed(value);
+ assert.equal(reminderEmbeds(value).length, 10);
+ assert.equal(addEmbed(value), value);
+ value = removeEmbed(value, 0);
+ assert.equal(reminderEmbeds(value).length, 9);
+ value = updateEmbed(value, 0, (r) => ({ ...r, embed_title: "new first" }));
+ assert.equal(prepareReminder(value).message_json.embeds[0].title, "new first");
+ assert.equal(reminderEmbeds(addEmbed(value)).length, 10);
+});
+
+test("saving and reloading multiple embeds preserves order and drops blank editors", () => {
+ let value = addEmbed(reminder([{ description: "first" }]));
+ value = updateEmbed(value, 1, (r) => ({ ...r, embed_description: "second" }));
+ value = addEmbed(value);
+ const saved = prepareReminder(value);
+ assert.deepEqual(
+ saved.message_json.embeds.map((embed) => embed.description),
+ ["first", "second"],
+ );
+ assert.deepEqual(reminderEmbeds(JSON.parse(JSON.stringify(saved))), saved.message_json.embeds);
+ assert.deepEqual(prepareReminder(reminder()).message_json.embeds, []);
+});
+
+test("legacy embeds are retained when a second embed is added", () => {
+ const legacy = {
+ ...reminder(),
+ embed_title: "legacy",
+ embed_fields: [{ title: "name", value: "value", inline: true }],
+ };
+ const saved = prepareReminder(addEmbed(legacy));
+ assert.equal(saved.message_json.embeds[0].title, "legacy");
+ assert.deepEqual(saved.message_json.embeds[0].fields, [
+ { name: "name", value: "value", inline: true },
+ ]);
+});
+
+test("combined embed text accepts 6000 characters and rejects 6001", () => {
+ const embeds = [{ description: "x".repeat(3000) }, { description: "y".repeat(3000) }];
+ assert.equal(prepareReminder(reminder(embeds)).message_json.embeds.length, 2);
+ assert.throws(
+ () => prepareReminder(reminder([embeds[0], { description: "y".repeat(3001) }])),
+ /6000/,
+ );
+ assert.throws(
+ () => prepareReminder(reminder(Array.from({ length: 11 }, () => ({ title: "embed" })))),
+ /10 embeds/,
+ );
+});
+
+test("link buttons can be added, edited, saved, reloaded and removed without losing embeds", () => {
+ const original = reminder([{ title: "first" }, { title: "second" }]);
+ let value = addLinkButton(original);
+ value = updateLinkButton(value, 0, 0, { label: "Visit", url: "https://example.com" });
+ const saved = prepareReminder(value);
+ assert.deepEqual(saved.message_json.components, [
+ {
+ type: 1,
+ components: [{ type: 2, style: 5, label: "Visit", url: "https://example.com" }],
+ },
+ ]);
+ assert.deepEqual(saved.message_json.embeds, original.message_json.embeds);
+ assert.deepEqual(saved.message_json.allowed_mentions, { parse: [] });
+ assert.equal(
+ linkButtons(JSON.parse(JSON.stringify(saved)))[0].button.url,
+ "https://example.com",
+ );
+ assert.deepEqual(
+ prepareReminder(updateLinkButton(saved, 0, 0, null)).message_json.components,
+ [],
+ );
+ assert.equal(original.message_json.components, undefined);
+ const legacy = updateLinkButton(addLinkButton({ ...reminder(), embed_title: "legacy" }), 0, 0, {
+ url: "https://example.com",
+ });
+ assert.equal(prepareReminder(legacy).message_json.embeds[0].title, "legacy");
+});
+
+test("link buttons fill five rows of five and preserve other components", () => {
+ let value = reminder();
+ for (let i = 0; i < 26; i++) value = addLinkButton(value);
+ assert.equal(linkButtons(value).length, 25);
+ assert.deepEqual(
+ value.message_json.components.map((row) => row.components.length),
+ [5, 5, 5, 5, 5],
+ );
+ assert.equal(canAddLinkButton(value), false);
+ assert.equal(addLinkButton(value), value);
+ value = updateLinkButton(value, 2, 2, null);
+ assert.equal(canAddLinkButton(value), true);
+ assert.equal(linkButtons(addLinkButton(value)).length, 25);
+ const other = { type: 1, id: 7, components: [{ type: 3, custom_id: "select", options: [] }] };
+ value = { ...reminder(), message_json: { components: [other] } };
+ value = addLinkButton(value);
+ assert.deepEqual(value.message_json.components[0], other);
+ assert.equal(linkButtons(value)[0].rowIndex, 1);
+});
+
+test("invalid link button URLs, labels and row limits are rejected on save", () => {
+ let value = addLinkButton(reminder());
+ assert.throws(() => prepareReminder(value), /URL/);
+ value = updateLinkButton(value, 0, 0, {
+ url: "https://example.com/" + "x".repeat(492),
+ label: "x".repeat(80),
+ });
+ assert.equal(prepareReminder(value).message_json.components.length, 1);
+ for (const change of [
+ { url: "invalid" },
+ { url: "javascript:alert(1)" },
+ { url: "https://example.com/" + "x".repeat(493) },
+ { label: "" },
+ { label: "x".repeat(81) },
+ ])
+ assert.throws(() => prepareReminder(updateLinkButton(value, 0, 0, change)));
+ const row = value.message_json.components[0];
+ assert.throws(
+ () => prepareReminder({ ...value, message_json: { components: Array(6).fill(row) } }),
+ /5 button rows/,
+ );
+ assert.throws(
+ () =>
+ prepareReminder({
+ ...value,
+ message_json: {
+ components: [{ ...row, components: Array(6).fill(row.components[0]) }],
+ },
+ }),
+ /5 components/,
+ );
+});
diff --git a/src/message.rs b/src/message.rs
index 402d554..c3610dd 100644
--- a/src/message.rs
+++ b/src/message.rs
@@ -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() {
diff --git a/src/postman/sender.rs b/src/postman/sender.rs
index 10ac1e8..a033efa 100644
--- a/src/postman/sender.rs
+++ b/src/postman/sender.rs
@@ -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(),
diff --git a/src/web/macros.rs b/src/web/macros.rs
index a92ac12..cd11e90 100644
--- a/src/web/macros.rs
+++ b/src/web/macros.rs
@@ -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
diff --git a/src/web/routes/dashboard/api/guild/reminders.rs b/src/web/routes/dashboard/api/guild/reminders.rs
index b307aaa..64e22ab 100644
--- a/src/web/routes/dashboard/api/guild/reminders.rs
+++ b/src/web/routes/dashboard/api/guild/reminders.rs
@@ -153,6 +153,11 @@ pub async fn edit_reminder(
let user_id =
cookies.get_private("userid").map(|c| c.value().parse::
().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,
diff --git a/src/web/routes/dashboard/api/user/models.rs b/src/web/routes/dashboard/api/user/models.rs
index ad68f59..56e1ec3 100644
--- a/src/web/routes/dashboard/api/user/models.rs
+++ b/src/web/routes/dashboard/api/user/models.rs
@@ -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 (
diff --git a/src/web/routes/dashboard/api/user/reminders.rs b/src/web/routes/dashboard/api/user/reminders.rs
index f3d9859..9ff3a68 100644
--- a/src/web/routes/dashboard/api/user/reminders.rs
+++ b/src/web/routes/dashboard/api/user/reminders.rs
@@ -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,
diff --git a/src/web/routes/dashboard/mod.rs b/src/web/routes/dashboard/mod.rs
index 44138ce..7bf15fa 100644
--- a/src/web/routes/dashboard/mod.rs
+++ b/src/web/routes/dashboard/mod.rs
@@ -254,6 +254,8 @@ pub struct ReminderCsv {
#[derive(Deserialize)]
pub struct PatchReminder {
+ #[serde(default)]
+ message_json: Option>,
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::())
+ .execute(&mut *transaction)
+ .await
+ .unwrap()
+ .last_insert_id();
+ let uid = format!("multi-embed-test-{}", rand::random::());
+ 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,) =
+ 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();
+ }
+}