flora is a fast and secure runtime that lets you write discord bots for your servers, with a rich TypeScript SDK, without worrying about running infrastructure. [mirror]
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor!(runtime): rework REST

Fixes #20.

+1767 -667
+5 -4
Cargo.lock
··· 1944 1944 "confique", 1945 1945 "cookie", 1946 1946 "croner", 1947 + "dashmap 6.1.0", 1947 1948 "deno_core", 1948 1949 "deno_error", 1949 1950 "deno_fetch", ··· 5961 5962 5962 5963 [[package]] 5963 5964 name = "t0x" 5964 - version = "0.1.5" 5965 + version = "0.1.8" 5965 5966 source = "registry+https://github.com/rust-lang/crates.io-index" 5966 - checksum = "15f72b60a804d4743182bdb2c8d7cc5de3995dc70efd22a42247b10263639e84" 5967 + checksum = "0219c290cd9aecba1b62fe67ef283be5be8bb4f1c35dc3d84c0bd9ff56ba7a74" 5967 5968 dependencies = [ 5968 5969 "oxc_allocator", 5969 5970 "oxc_ast", ··· 5975 5976 5976 5977 [[package]] 5977 5978 name = "t0x-macros" 5978 - version = "0.1.5" 5979 + version = "0.1.8" 5979 5980 source = "registry+https://github.com/rust-lang/crates.io-index" 5980 - checksum = "ca1afde24e33cf369eff79f6e3885d9424cb1b35366eb1a2169a77896c3399e6" 5981 + checksum = "7962d9177208f27a427667a8b865118c2d90ad456a342f5d3069ab752352b026" 5981 5982 dependencies = [ 5982 5983 "proc-macro2", 5983 5984 "quote",
+3 -2
Cargo.toml
··· 17 17 confique = { version = "0.4.0", features = ["toml"] } 18 18 cookie = "0.18" 19 19 croner = "3.0" 20 + dashmap = "6.1.0" 20 21 deno_core = { git = "file:///home/tasky/flora/submodules/deno_core", branch = "flora-locker-compat", default-features = false, features = ["v8_use_custom_libcxx"] } 21 22 deno_error = "0.7.1" 22 23 deno_fetch = "0.249.0" ··· 51 52 sqlx = { version = "0.8.6", features = ["chrono", "macros", "postgres", "runtime-tokio-rustls", "uuid"] } 52 53 syn = { version = "2.0.117", features = ["full", "extra-traits", "parsing"] } 53 54 sys_traits = { version = "0.1.17", features = ["libc", "real"] } 54 - t0x = { version = "0.1.5", features = ["serde-json-impl"] } 55 - t0x-macros = "0.1.5" 55 + t0x = { version = "0.1.8", features = ["serde-json-impl"] } 56 + t0x-macros = "0.1.8" 56 57 thiserror = "2.0.18" 57 58 time = "0.3.47" 58 59 tokio = { version = "1.50.0", features = ["full"] }
+1
apps/runtime/Cargo.toml
··· 12 12 confique.workspace = true 13 13 cookie.workspace = true 14 14 croner.workspace = true 15 + dashmap.workspace = true 15 16 deno_core.workspace = true 16 17 deno_error.workspace = true 17 18 deno_fetch.workspace = true
+21
apps/runtime/src/discord_handler.rs
··· 19 19 #[derive(Clone)] 20 20 pub struct DiscordHandler { 21 21 pub runtime: Arc<BotRuntime>, 22 + pub rest: Arc<crate::services::discord_rest::DiscordRest>, 22 23 pub http: Arc<serenity::http::Http>, 23 24 pub application_id: Arc<std::sync::RwLock<Option<ApplicationId>>>, 24 25 pub deployments: DeploymentService, ··· 359 360 { 360 361 error!("dispatch_js_event (reactionRemoveEmoji) error: {:?}", err); 361 362 } 363 + } 364 + FullEvent::ChannelCreate { channel, .. } 365 + | FullEvent::ChannelUpdate { new: channel, .. } => { 366 + self.rest 367 + .scope_cache() 368 + .warm_channel(channel.id, channel.base.guild_id) 369 + .await; 370 + } 371 + FullEvent::ChannelDelete { channel, .. } => { 372 + self.rest.scope_cache().invalidate_channel(channel.id).await; 373 + } 374 + FullEvent::ThreadCreate { thread, .. } 375 + | FullEvent::ThreadUpdate { new: thread, .. } => { 376 + self.rest 377 + .scope_cache() 378 + .warm_thread(thread.id, thread.base.guild_id) 379 + .await; 380 + } 381 + FullEvent::ThreadDelete { thread, .. } => { 382 + self.rest.scope_cache().invalidate_thread(thread.id).await; 362 383 } 363 384 FullEvent::GuildCreate { 364 385 guild, is_new: _, ..
+2
apps/runtime/src/main.rs
··· 136 136 kv_service.clone(), 137 137 secret_service.clone(), 138 138 config.runtime, 139 + cache_client.clone(), 139 140 )); 140 141 runtime.initialize().await.map_err(|err| eyre!(err))?; 141 142 ··· 160 161 161 162 let handler = Arc::new(DiscordHandler { 162 163 runtime: runtime.clone(), 164 + rest: runtime.discord_rest(), 163 165 http: http.clone(), 164 166 application_id: Arc::new(std::sync::RwLock::new(Some(app_info.id))), 165 167 deployments: deployment_service.clone(),
+39 -49
apps/runtime/src/ops/authz.rs
··· 1 1 use deno_core::OpState; 2 - use deno_error::JsErrorBox; 3 - use serenity::{ 4 - http::Http, 5 - model::{ 6 - channel::Channel, 7 - id::{ChannelId, GuildId, ThreadId, WebhookId}, 8 - }, 9 - }; 2 + use serenity::model::id::{ChannelId, GuildId, ThreadId, WebhookId}; 10 3 11 - pub fn ensure_guild_scope(state: &OpState, guild_id: GuildId) -> Result<(), JsErrorBox> { 4 + use crate::services::scope_cache::ScopeCache; 5 + 6 + use super::FloraError; 7 + 8 + pub fn ensure_guild_scope(state: &OpState, guild_id: GuildId) -> Result<(), FloraError> { 12 9 let runtime_guild_id = runtime_guild_id_from_state(state)?; 13 10 if runtime_guild_id != guild_id { 14 - return Err(JsErrorBox::generic( 15 - "Forbidden: guild is outside runtime scope", 11 + return Err(FloraError::scope_forbidden( 12 + "guild is outside runtime scope", 16 13 )); 17 14 } 18 15 19 16 Ok(()) 20 17 } 21 18 22 - pub fn runtime_guild_id_from_state(state: &OpState) -> Result<GuildId, JsErrorBox> { 19 + pub fn runtime_guild_id_from_state(state: &OpState) -> Result<GuildId, FloraError> { 23 20 runtime_guild_id(state) 24 21 } 25 22 26 23 pub async fn ensure_channel_scope( 27 24 runtime_guild_id: GuildId, 28 - http: &Http, 25 + scope_cache: &ScopeCache, 29 26 channel_id: ChannelId, 30 - ) -> Result<(), JsErrorBox> { 31 - let channel = http 32 - .get_channel(channel_id.widen()) 33 - .await 34 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 35 - 36 - let Some(channel_guild_id) = channel_guild_id(channel) else { 37 - return Err(JsErrorBox::generic( 38 - "Forbidden: channel is not a guild channel", 27 + ) -> Result<(), FloraError> { 28 + let channel_guild_id = scope_cache.resolve_channel(channel_id).await?; 29 + let Some(channel_guild_id) = channel_guild_id else { 30 + return Err(FloraError::scope_forbidden( 31 + "channel is not a guild channel", 39 32 )); 40 33 }; 41 34 42 35 if channel_guild_id != runtime_guild_id { 43 - return Err(JsErrorBox::generic( 44 - "Forbidden: channel is outside runtime scope", 36 + return Err(FloraError::scope_forbidden( 37 + "channel is outside runtime scope", 45 38 )); 46 39 } 47 40 ··· 50 43 51 44 pub async fn ensure_thread_scope( 52 45 runtime_guild_id: GuildId, 53 - http: &Http, 46 + scope_cache: &ScopeCache, 54 47 thread_id: ThreadId, 55 - ) -> Result<(), JsErrorBox> { 56 - ensure_channel_scope(runtime_guild_id, http, ChannelId::new(thread_id.get())).await 48 + ) -> Result<(), FloraError> { 49 + ensure_channel_scope( 50 + runtime_guild_id, 51 + scope_cache, 52 + ChannelId::new(thread_id.get()), 53 + ) 54 + .await 57 55 } 58 56 59 57 pub async fn ensure_webhook_scope( 60 58 runtime_guild_id: GuildId, 61 - http: &Http, 59 + scope_cache: &ScopeCache, 62 60 webhook_id: WebhookId, 63 - ) -> Result<(), JsErrorBox> { 64 - let webhook = http 65 - .get_webhook(webhook_id) 66 - .await 67 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 68 - 69 - let Some(webhook_guild_id) = webhook.guild_id else { 70 - return Err(JsErrorBox::generic( 71 - "Forbidden: webhook is not owned by a guild", 61 + ) -> Result<(), FloraError> { 62 + let webhook_guild_id = scope_cache.resolve_webhook(webhook_id).await?; 63 + let Some(webhook_guild_id) = webhook_guild_id else { 64 + return Err(FloraError::scope_forbidden( 65 + "webhook is not owned by a guild", 72 66 )); 73 67 }; 74 68 75 69 if webhook_guild_id != runtime_guild_id { 76 - return Err(JsErrorBox::generic( 77 - "Forbidden: webhook is outside runtime scope", 70 + return Err(FloraError::scope_forbidden( 71 + "webhook is outside runtime scope", 78 72 )); 79 73 } 80 74 81 75 Ok(()) 82 76 } 83 77 84 - fn runtime_guild_id(state: &OpState) -> Result<GuildId, JsErrorBox> { 78 + fn runtime_guild_id(state: &OpState) -> Result<GuildId, FloraError> { 85 79 let runtime_guild_id = state 86 80 .try_borrow::<String>() 87 - .ok_or_else(|| JsErrorBox::generic("guild context not available"))?; 88 - runtime_guild_id 89 - .parse::<u64>() 90 - .map(GuildId::new) 91 - .map_err(|_| JsErrorBox::generic("invalid runtime guild id")) 92 - } 93 - 94 - fn channel_guild_id(channel: Channel) -> Option<GuildId> { 95 - channel.guild_id() 81 + .ok_or_else(|| FloraError::scope_forbidden("guild context not available"))?; 82 + let Ok(runtime_guild_id) = runtime_guild_id.parse::<u64>() else { 83 + return Err(FloraError::scope_forbidden("invalid runtime guild id")); 84 + }; 85 + Ok(GuildId::new(runtime_guild_id)) 96 86 }
+163 -92
apps/runtime/src/ops/channels.rs
··· 4 4 use deno_core::{OpState, op2}; 5 5 use deno_error::JsErrorBox; 6 6 use flora_macros::expose_input; 7 - use serenity::{ 8 - http::Http, 9 - model::id::{ChannelId, GuildId, MessageId, ThreadId, UserId}, 10 - }; 7 + use serenity::model::id::{ChannelId, GuildId, MessageId, ThreadId, UserId}; 11 8 use std::{cell::RefCell, rc::Rc, sync::Arc}; 12 9 use t0x::T0x; 10 + 11 + use crate::services::discord_rest::{DiscordRest, RestRetry}; 12 + 13 + use super::FloraError; 13 14 14 15 /// Arguments for creating a guild channel. 15 16 #[expose_input] ··· 28 29 state: Rc<RefCell<OpState>>, 29 30 #[serde] args: RawCreateChannel, 30 31 ) -> Result<serde_json::Value, JsErrorBox> { 31 - let http = { 32 + let rest = { 32 33 let state = state.borrow(); 33 - state.borrow::<Arc<Http>>().clone() 34 + state.borrow::<Arc<DiscordRest>>().clone() 34 35 }; 35 36 let guild_id = parse_guild_id(&args.guild_id)?; 36 37 { 37 38 let state = state.borrow(); 38 39 ensure_guild_scope(&state, guild_id)?; 39 40 } 40 - let channel = http 41 - .create_channel(guild_id, &args.payload, args.reason.as_deref()) 42 - .await 43 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 41 + let payload = args.payload.clone(); 42 + let reason = args.reason.clone(); 43 + let route = format!("POST /guilds/{}/channels", guild_id.get()); 44 + let channel = rest 45 + .execute(guild_id, route, RestRetry::None, move |http| { 46 + let payload = payload.clone(); 47 + let reason = reason.clone(); 48 + async move { 49 + http.create_channel(guild_id, &payload, reason.as_deref()) 50 + .await 51 + } 52 + }) 53 + .await?; 44 54 serde_json::to_value(channel).map_err(|err| JsErrorBox::generic(err.to_string())) 45 55 } 46 56 ··· 61 71 state: Rc<RefCell<OpState>>, 62 72 #[serde] args: RawEditChannel, 63 73 ) -> Result<serde_json::Value, JsErrorBox> { 64 - let http = { 74 + let rest = { 65 75 let state = state.borrow(); 66 - state.borrow::<Arc<Http>>().clone() 76 + state.borrow::<Arc<DiscordRest>>().clone() 67 77 }; 68 78 let channel_id = parse_channel_id(&args.channel_id)?; 69 79 let runtime_guild_id = { 70 80 let state = state.borrow(); 71 81 runtime_guild_id_from_state(&state)? 72 82 }; 73 - ensure_channel_scope(runtime_guild_id, &http, channel_id).await?; 74 - let channel = http 75 - .edit_channel(channel_id.widen(), &args.payload, args.reason.as_deref()) 76 - .await 77 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 83 + ensure_channel_scope(runtime_guild_id, rest.scope_cache(), channel_id).await?; 84 + let payload = args.payload.clone(); 85 + let reason = args.reason.clone(); 86 + let route = format!("PATCH /channels/{}", channel_id.get()); 87 + let channel = rest 88 + .execute(runtime_guild_id, route, RestRetry::None, move |http| { 89 + let payload = payload.clone(); 90 + let reason = reason.clone(); 91 + async move { 92 + http.edit_channel(channel_id.widen(), &payload, reason.as_deref()) 93 + .await 94 + } 95 + }) 96 + .await?; 78 97 serde_json::to_value(channel).map_err(|err| JsErrorBox::generic(err.to_string())) 79 98 } 80 99 ··· 93 112 state: Rc<RefCell<OpState>>, 94 113 #[serde] args: RawDeleteChannel, 95 114 ) -> Result<serde_json::Value, JsErrorBox> { 96 - let http = { 115 + let rest = { 97 116 let state = state.borrow(); 98 - state.borrow::<Arc<Http>>().clone() 117 + state.borrow::<Arc<DiscordRest>>().clone() 99 118 }; 100 119 let channel_id = parse_channel_id(&args.channel_id)?; 101 120 let runtime_guild_id = { 102 121 let state = state.borrow(); 103 122 runtime_guild_id_from_state(&state)? 104 123 }; 105 - ensure_channel_scope(runtime_guild_id, &http, channel_id).await?; 106 - let channel = http 107 - .delete_channel(channel_id.widen(), args.reason.as_deref()) 108 - .await 109 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 124 + ensure_channel_scope(runtime_guild_id, rest.scope_cache(), channel_id).await?; 125 + let reason = args.reason.clone(); 126 + let route = format!("DELETE /channels/{}", channel_id.get()); 127 + let channel = rest 128 + .execute(runtime_guild_id, route, RestRetry::None, move |http| { 129 + let reason = reason.clone(); 130 + async move { 131 + http.delete_channel(channel_id.widen(), reason.as_deref()) 132 + .await 133 + } 134 + }) 135 + .await?; 110 136 serde_json::to_value(channel).map_err(|err| JsErrorBox::generic(err.to_string())) 111 137 } 112 138 ··· 127 153 state: Rc<RefCell<OpState>>, 128 154 #[serde] args: RawCreateThread, 129 155 ) -> Result<serde_json::Value, JsErrorBox> { 130 - let http = { 156 + let rest = { 131 157 let state = state.borrow(); 132 - state.borrow::<Arc<Http>>().clone() 158 + state.borrow::<Arc<DiscordRest>>().clone() 133 159 }; 134 160 let channel_id = parse_channel_id(&args.channel_id)?; 135 161 let runtime_guild_id = { 136 162 let state = state.borrow(); 137 163 runtime_guild_id_from_state(&state)? 138 164 }; 139 - ensure_channel_scope(runtime_guild_id, &http, channel_id).await?; 140 - let thread = http 141 - .create_thread(channel_id, &args.payload, args.reason.as_deref()) 142 - .await 143 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 165 + ensure_channel_scope(runtime_guild_id, rest.scope_cache(), channel_id).await?; 166 + let payload = args.payload.clone(); 167 + let reason = args.reason.clone(); 168 + let route = format!("POST /channels/{}/threads", channel_id.get()); 169 + let thread = rest 170 + .execute(runtime_guild_id, route, RestRetry::None, move |http| { 171 + let payload = payload.clone(); 172 + let reason = reason.clone(); 173 + async move { 174 + http.create_thread(channel_id, &payload, reason.as_deref()) 175 + .await 176 + } 177 + }) 178 + .await?; 144 179 serde_json::to_value(thread).map_err(|err| JsErrorBox::generic(err.to_string())) 145 180 } 146 181 ··· 163 198 state: Rc<RefCell<OpState>>, 164 199 #[serde] args: RawCreateThreadFromMessage, 165 200 ) -> Result<serde_json::Value, JsErrorBox> { 166 - let http = { 201 + let rest = { 167 202 let state = state.borrow(); 168 - state.borrow::<Arc<Http>>().clone() 203 + state.borrow::<Arc<DiscordRest>>().clone() 169 204 }; 170 205 let channel_id = parse_channel_id(&args.channel_id)?; 171 206 let runtime_guild_id = { 172 207 let state = state.borrow(); 173 208 runtime_guild_id_from_state(&state)? 174 209 }; 175 - ensure_channel_scope(runtime_guild_id, &http, channel_id).await?; 210 + ensure_channel_scope(runtime_guild_id, rest.scope_cache(), channel_id).await?; 176 211 let message_id = parse_message_id(&args.message_id)?; 177 - let thread = http 178 - .create_thread_from_message( 179 - channel_id, 180 - message_id, 181 - &args.payload, 182 - args.reason.as_deref(), 183 - ) 184 - .await 185 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 212 + let payload = args.payload.clone(); 213 + let reason = args.reason.clone(); 214 + let route = format!( 215 + "POST /channels/{}/messages/{}/threads", 216 + channel_id.get(), 217 + message_id.get() 218 + ); 219 + let thread = rest 220 + .execute(runtime_guild_id, route, RestRetry::None, move |http| { 221 + let payload = payload.clone(); 222 + let reason = reason.clone(); 223 + async move { 224 + http.create_thread_from_message(channel_id, message_id, &payload, reason.as_deref()) 225 + .await 226 + } 227 + }) 228 + .await?; 186 229 serde_json::to_value(thread).map_err(|err| JsErrorBox::generic(err.to_string())) 187 230 } 188 231 ··· 198 241 state: Rc<RefCell<OpState>>, 199 242 #[serde] args: RawThreadId, 200 243 ) -> Result<(), JsErrorBox> { 201 - let http = { 244 + let rest = { 202 245 let state = state.borrow(); 203 - state.borrow::<Arc<Http>>().clone() 246 + state.borrow::<Arc<DiscordRest>>().clone() 204 247 }; 205 248 let thread_id = parse_thread_id(&args.thread_id)?; 206 249 let runtime_guild_id = { 207 250 let state = state.borrow(); 208 251 runtime_guild_id_from_state(&state)? 209 252 }; 210 - ensure_thread_scope(runtime_guild_id, &http, thread_id).await?; 211 - http.join_thread_channel(thread_id) 212 - .await 213 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 253 + ensure_thread_scope(runtime_guild_id, rest.scope_cache(), thread_id).await?; 254 + let route = format!("PUT /channels/{}/thread-members/@me", thread_id.get()); 255 + rest.execute( 256 + runtime_guild_id, 257 + route, 258 + RestRetry::None, 259 + move |http| async move { http.join_thread_channel(thread_id).await }, 260 + ) 261 + .await?; 214 262 Ok(()) 215 263 } 216 264 ··· 219 267 state: Rc<RefCell<OpState>>, 220 268 #[serde] args: RawThreadId, 221 269 ) -> Result<(), JsErrorBox> { 222 - let http = { 270 + let rest = { 223 271 let state = state.borrow(); 224 - state.borrow::<Arc<Http>>().clone() 272 + state.borrow::<Arc<DiscordRest>>().clone() 225 273 }; 226 274 let thread_id = parse_thread_id(&args.thread_id)?; 227 275 let runtime_guild_id = { 228 276 let state = state.borrow(); 229 277 runtime_guild_id_from_state(&state)? 230 278 }; 231 - ensure_thread_scope(runtime_guild_id, &http, thread_id).await?; 232 - http.leave_thread_channel(thread_id) 233 - .await 234 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 279 + ensure_thread_scope(runtime_guild_id, rest.scope_cache(), thread_id).await?; 280 + let route = format!("DELETE /channels/{}/thread-members/@me", thread_id.get()); 281 + rest.execute( 282 + runtime_guild_id, 283 + route, 284 + RestRetry::None, 285 + move |http| async move { http.leave_thread_channel(thread_id).await }, 286 + ) 287 + .await?; 235 288 Ok(()) 236 289 } 237 290 ··· 249 302 state: Rc<RefCell<OpState>>, 250 303 #[serde] args: RawThreadMember, 251 304 ) -> Result<(), JsErrorBox> { 252 - let http = { 305 + let rest = { 253 306 let state = state.borrow(); 254 - state.borrow::<Arc<Http>>().clone() 307 + state.borrow::<Arc<DiscordRest>>().clone() 255 308 }; 256 309 let thread_id = parse_thread_id(&args.thread_id)?; 257 310 let runtime_guild_id = { 258 311 let state = state.borrow(); 259 312 runtime_guild_id_from_state(&state)? 260 313 }; 261 - ensure_thread_scope(runtime_guild_id, &http, thread_id).await?; 314 + ensure_thread_scope(runtime_guild_id, rest.scope_cache(), thread_id).await?; 262 315 let user_id = parse_user_id(&args.user_id)?; 263 - http.add_thread_channel_member(thread_id, user_id) 264 - .await 265 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 316 + let route = format!( 317 + "PUT /channels/{}/thread-members/{}", 318 + thread_id.get(), 319 + user_id.get() 320 + ); 321 + rest.execute( 322 + runtime_guild_id, 323 + route, 324 + RestRetry::None, 325 + move |http| async move { http.add_thread_channel_member(thread_id, user_id).await }, 326 + ) 327 + .await?; 266 328 Ok(()) 267 329 } 268 330 ··· 271 333 state: Rc<RefCell<OpState>>, 272 334 #[serde] args: RawThreadMember, 273 335 ) -> Result<(), JsErrorBox> { 274 - let http = { 336 + let rest = { 275 337 let state = state.borrow(); 276 - state.borrow::<Arc<Http>>().clone() 338 + state.borrow::<Arc<DiscordRest>>().clone() 277 339 }; 278 340 let thread_id = parse_thread_id(&args.thread_id)?; 279 341 let runtime_guild_id = { 280 342 let state = state.borrow(); 281 343 runtime_guild_id_from_state(&state)? 282 344 }; 283 - ensure_thread_scope(runtime_guild_id, &http, thread_id).await?; 345 + ensure_thread_scope(runtime_guild_id, rest.scope_cache(), thread_id).await?; 284 346 let user_id = parse_user_id(&args.user_id)?; 285 - http.remove_thread_channel_member(thread_id, user_id) 286 - .await 287 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 347 + let route = format!( 348 + "DELETE /channels/{}/thread-members/{}", 349 + thread_id.get(), 350 + user_id.get() 351 + ); 352 + rest.execute( 353 + runtime_guild_id, 354 + route, 355 + RestRetry::None, 356 + move |http| async move { http.remove_thread_channel_member(thread_id, user_id).await }, 357 + ) 358 + .await?; 288 359 Ok(()) 289 360 } 290 361 291 - fn parse_guild_id(value: &str) -> Result<GuildId, JsErrorBox> { 292 - value 293 - .parse::<u64>() 294 - .map(GuildId::new) 295 - .map_err(|_| JsErrorBox::generic("Invalid guild id")) 362 + fn parse_guild_id(value: &str) -> Result<GuildId, FloraError> { 363 + let Ok(id) = value.parse::<u64>() else { 364 + return Err(FloraError::invalid_input("guild_id", "invalid snowflake")); 365 + }; 366 + Ok(GuildId::new(id)) 296 367 } 297 368 298 - fn parse_channel_id(value: &str) -> Result<ChannelId, JsErrorBox> { 299 - value 300 - .parse::<u64>() 301 - .map(ChannelId::new) 302 - .map_err(|_| JsErrorBox::generic("Invalid channel id")) 369 + fn parse_channel_id(value: &str) -> Result<ChannelId, FloraError> { 370 + let Ok(id) = value.parse::<u64>() else { 371 + return Err(FloraError::invalid_input("channel_id", "invalid snowflake")); 372 + }; 373 + Ok(ChannelId::new(id)) 303 374 } 304 375 305 - fn parse_message_id(value: &str) -> Result<MessageId, JsErrorBox> { 306 - value 307 - .parse::<u64>() 308 - .map(MessageId::new) 309 - .map_err(|_| JsErrorBox::generic("Invalid message id")) 376 + fn parse_message_id(value: &str) -> Result<MessageId, FloraError> { 377 + let Ok(id) = value.parse::<u64>() else { 378 + return Err(FloraError::invalid_input("message_id", "invalid snowflake")); 379 + }; 380 + Ok(MessageId::new(id)) 310 381 } 311 382 312 - fn parse_thread_id(value: &str) -> Result<ThreadId, JsErrorBox> { 313 - value 314 - .parse::<u64>() 315 - .map(ThreadId::new) 316 - .map_err(|_| JsErrorBox::generic("Invalid thread id")) 383 + fn parse_thread_id(value: &str) -> Result<ThreadId, FloraError> { 384 + let Ok(id) = value.parse::<u64>() else { 385 + return Err(FloraError::invalid_input("thread_id", "invalid snowflake")); 386 + }; 387 + Ok(ThreadId::new(id)) 317 388 } 318 389 319 - fn parse_user_id(value: &str) -> Result<UserId, JsErrorBox> { 320 - value 321 - .parse::<u64>() 322 - .map(UserId::new) 323 - .map_err(|_| JsErrorBox::generic("Invalid user id")) 390 + fn parse_user_id(value: &str) -> Result<UserId, FloraError> { 391 + let Ok(id) = value.parse::<u64>() else { 392 + return Err(FloraError::invalid_input("user_id", "invalid snowflake")); 393 + }; 394 + Ok(UserId::new(id)) 324 395 }
+129 -63
apps/runtime/src/ops/commands.rs
··· 2 2 authz::ensure_guild_scope, 3 3 interaction::{RawSlashCommand, RawSlashCommandOption}, 4 4 }; 5 + use crate::services::discord_rest::{DiscordRest, RestRetry}; 5 6 use deno_core::{OpState, op2}; 6 7 use deno_error::JsErrorBox; 7 8 use flora_macros::expose_input; 8 9 use serenity::{ 9 10 builder::{CreateCommand, CreateCommandOption}, 10 - http::Http, 11 11 model::id::{CommandId, GuildId}, 12 12 }; 13 13 use std::{cell::RefCell, rc::Rc, sync::Arc}; 14 14 use t0x::T0x; 15 + 16 + use super::FloraError; 15 17 16 18 /// Arguments for creating a guild application command. 17 19 #[expose_input] ··· 28 30 state: Rc<RefCell<OpState>>, 29 31 #[serde] args: RawCreateGuildCommand, 30 32 ) -> Result<serde_json::Value, JsErrorBox> { 31 - let http = { 33 + let rest = { 32 34 let state = state.borrow(); 33 - state.borrow::<Arc<Http>>().clone() 35 + state.borrow::<Arc<DiscordRest>>().clone() 34 36 }; 35 37 let guild_id = parse_guild_id(&args.guild_id)?; 36 38 { ··· 38 40 ensure_guild_scope(&state, guild_id)?; 39 41 } 40 42 let command = build_command(args.command)?; 41 - let created = http 42 - .create_guild_command(guild_id, &command) 43 - .await 44 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 43 + let route = format!("POST /guilds/{}/commands", guild_id.get()); 44 + let created = rest 45 + .execute(guild_id, route, RestRetry::None, move |http| { 46 + let command = command.clone(); 47 + async move { http.create_guild_command(guild_id, &command).await } 48 + }) 49 + .await?; 45 50 serde_json::to_value(created).map_err(|err| JsErrorBox::generic(err.to_string())) 46 51 } 47 52 ··· 62 67 state: Rc<RefCell<OpState>>, 63 68 #[serde] args: RawEditGuildCommand, 64 69 ) -> Result<serde_json::Value, JsErrorBox> { 65 - let http = { 70 + let rest = { 66 71 let state = state.borrow(); 67 - state.borrow::<Arc<Http>>().clone() 72 + state.borrow::<Arc<DiscordRest>>().clone() 68 73 }; 69 74 let guild_id = parse_guild_id(&args.guild_id)?; 70 75 { ··· 73 78 } 74 79 let command_id = parse_command_id(&args.command_id)?; 75 80 let command = build_command(args.command)?; 76 - let updated = http 77 - .edit_guild_command(guild_id, command_id, &command) 78 - .await 79 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 81 + let route = format!( 82 + "PATCH /guilds/{}/commands/{}", 83 + guild_id.get(), 84 + command_id.get() 85 + ); 86 + let updated = rest 87 + .execute(guild_id, route, RestRetry::None, move |http| { 88 + let command = command.clone(); 89 + async move { 90 + http.edit_guild_command(guild_id, command_id, &command) 91 + .await 92 + } 93 + }) 94 + .await?; 80 95 serde_json::to_value(updated).map_err(|err| JsErrorBox::generic(err.to_string())) 81 96 } 82 97 ··· 94 109 state: Rc<RefCell<OpState>>, 95 110 #[serde] args: RawDeleteGuildCommand, 96 111 ) -> Result<(), JsErrorBox> { 97 - let http = { 112 + let rest = { 98 113 let state = state.borrow(); 99 - state.borrow::<Arc<Http>>().clone() 114 + state.borrow::<Arc<DiscordRest>>().clone() 100 115 }; 101 116 let guild_id = parse_guild_id(&args.guild_id)?; 102 117 { ··· 104 119 ensure_guild_scope(&state, guild_id)?; 105 120 } 106 121 let command_id = parse_command_id(&args.command_id)?; 107 - http.delete_guild_command(guild_id, command_id) 108 - .await 109 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 122 + let route = format!( 123 + "DELETE /guilds/{}/commands/{}", 124 + guild_id.get(), 125 + command_id.get() 126 + ); 127 + rest.execute(guild_id, route, RestRetry::None, move |http| async move { 128 + http.delete_guild_command(guild_id, command_id).await 129 + }) 130 + .await?; 110 131 Ok(()) 111 132 } 112 133 ··· 125 146 state: Rc<RefCell<OpState>>, 126 147 #[serde] args: RawGuildId, 127 148 ) -> Result<Vec<serde_json::Value>, JsErrorBox> { 128 - let http = { 149 + let rest = { 129 150 let state = state.borrow(); 130 - state.borrow::<Arc<Http>>().clone() 151 + state.borrow::<Arc<DiscordRest>>().clone() 131 152 }; 132 153 let guild_id = parse_guild_id(&args.guild_id)?; 133 154 { 134 155 let state = state.borrow(); 135 156 ensure_guild_scope(&state, guild_id)?; 136 157 } 137 - let commands = http 138 - .get_guild_commands(guild_id) 139 - .await 140 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 158 + let route = format!("GET /guilds/{}/commands", guild_id.get()); 159 + let commands = rest 160 + .execute( 161 + guild_id, 162 + route, 163 + RestRetry::ReadOnly, 164 + move |http| async move { http.get_guild_commands(guild_id).await }, 165 + ) 166 + .await?; 141 167 commands 142 168 .into_iter() 143 169 .map(|cmd| serde_json::to_value(cmd).map_err(|err| JsErrorBox::generic(err.to_string()))) ··· 150 176 state: Rc<RefCell<OpState>>, 151 177 #[serde] args: RawGetGuildCommand, 152 178 ) -> Result<serde_json::Value, JsErrorBox> { 153 - let http = { 179 + let rest = { 154 180 let state = state.borrow(); 155 - state.borrow::<Arc<Http>>().clone() 181 + state.borrow::<Arc<DiscordRest>>().clone() 156 182 }; 157 183 let guild_id = parse_guild_id(&args.guild_id)?; 158 184 { ··· 160 186 ensure_guild_scope(&state, guild_id)?; 161 187 } 162 188 let command_id = parse_command_id(&args.command_id)?; 163 - let command = http 164 - .get_guild_command(guild_id, command_id) 165 - .await 166 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 189 + let route = format!( 190 + "GET /guilds/{}/commands/{}", 191 + guild_id.get(), 192 + command_id.get() 193 + ); 194 + let command = rest 195 + .execute( 196 + guild_id, 197 + route, 198 + RestRetry::ReadOnly, 199 + move |http| async move { http.get_guild_command(guild_id, command_id).await }, 200 + ) 201 + .await?; 167 202 serde_json::to_value(command).map_err(|err| JsErrorBox::generic(err.to_string())) 168 203 } 169 204 ··· 184 219 state: Rc<RefCell<OpState>>, 185 220 #[serde] args: RawCommandPermissions, 186 221 ) -> Result<serde_json::Value, JsErrorBox> { 187 - let http = { 222 + let rest = { 188 223 let state = state.borrow(); 189 - state.borrow::<Arc<Http>>().clone() 224 + state.borrow::<Arc<DiscordRest>>().clone() 190 225 }; 191 226 let guild_id = parse_guild_id(&args.guild_id)?; 192 227 { ··· 194 229 ensure_guild_scope(&state, guild_id)?; 195 230 } 196 231 let command_id = parse_command_id(&args.command_id)?; 197 - let permissions = http 198 - .edit_guild_command_permissions(guild_id, command_id, &args.permissions) 199 - .await 200 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 232 + let route = format!( 233 + "PUT /guilds/{}/commands/{}/permissions", 234 + guild_id.get(), 235 + command_id.get() 236 + ); 237 + let permissions_payload = args.permissions; 238 + let permissions = rest 239 + .execute(guild_id, route, RestRetry::None, move |http| { 240 + let permissions_payload = permissions_payload.clone(); 241 + async move { 242 + http.edit_guild_command_permissions(guild_id, command_id, &permissions_payload) 243 + .await 244 + } 245 + }) 246 + .await?; 201 247 serde_json::to_value(permissions).map_err(|err| JsErrorBox::generic(err.to_string())) 202 248 } 203 249 ··· 214 260 state: Rc<RefCell<OpState>>, 215 261 #[serde] args: RawGetGuildCommand, 216 262 ) -> Result<serde_json::Value, JsErrorBox> { 217 - let http = { 263 + let rest = { 218 264 let state = state.borrow(); 219 - state.borrow::<Arc<Http>>().clone() 265 + state.borrow::<Arc<DiscordRest>>().clone() 220 266 }; 221 267 let guild_id = parse_guild_id(&args.guild_id)?; 222 268 { ··· 224 270 ensure_guild_scope(&state, guild_id)?; 225 271 } 226 272 let command_id = parse_command_id(&args.command_id)?; 227 - let permissions = http 228 - .get_guild_command_permissions(guild_id, command_id) 229 - .await 230 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 273 + let route = format!( 274 + "GET /guilds/{}/commands/{}/permissions", 275 + guild_id.get(), 276 + command_id.get() 277 + ); 278 + let permissions = rest 279 + .execute( 280 + guild_id, 281 + route, 282 + RestRetry::ReadOnly, 283 + move |http| async move { 284 + http.get_guild_command_permissions(guild_id, command_id) 285 + .await 286 + }, 287 + ) 288 + .await?; 231 289 serde_json::to_value(permissions).map_err(|err| JsErrorBox::generic(err.to_string())) 232 290 } 233 291 ··· 237 295 state: Rc<RefCell<OpState>>, 238 296 #[serde] args: RawGuildId, 239 297 ) -> Result<Vec<serde_json::Value>, JsErrorBox> { 240 - let http = { 298 + let rest = { 241 299 let state = state.borrow(); 242 - state.borrow::<Arc<Http>>().clone() 300 + state.borrow::<Arc<DiscordRest>>().clone() 243 301 }; 244 302 let guild_id = parse_guild_id(&args.guild_id)?; 245 303 { 246 304 let state = state.borrow(); 247 305 ensure_guild_scope(&state, guild_id)?; 248 306 } 249 - let permissions = http 250 - .get_guild_commands_permissions(guild_id) 251 - .await 252 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 307 + let route = format!("GET /guilds/{}/commands/permissions", guild_id.get()); 308 + let permissions = rest 309 + .execute( 310 + guild_id, 311 + route, 312 + RestRetry::ReadOnly, 313 + move |http| async move { http.get_guild_commands_permissions(guild_id).await }, 314 + ) 315 + .await?; 253 316 permissions 254 317 .into_iter() 255 318 .map(|perm| serde_json::to_value(perm).map_err(|err| JsErrorBox::generic(err.to_string()))) 256 319 .collect() 257 320 } 258 321 259 - fn parse_guild_id(value: &str) -> Result<GuildId, JsErrorBox> { 260 - value 261 - .parse::<u64>() 262 - .map(GuildId::new) 263 - .map_err(|_| JsErrorBox::generic("Invalid guild id")) 322 + fn parse_guild_id(value: &str) -> Result<GuildId, FloraError> { 323 + let Ok(id) = value.parse::<u64>() else { 324 + return Err(FloraError::invalid_input("guild_id", "invalid snowflake")); 325 + }; 326 + Ok(GuildId::new(id)) 264 327 } 265 328 266 - fn parse_command_id(value: &str) -> Result<CommandId, JsErrorBox> { 267 - value 268 - .parse::<u64>() 269 - .map(CommandId::new) 270 - .map_err(|_| JsErrorBox::generic("Invalid command id")) 329 + fn parse_command_id(value: &str) -> Result<CommandId, FloraError> { 330 + let Ok(id) = value.parse::<u64>() else { 331 + return Err(FloraError::invalid_input("command_id", "invalid snowflake")); 332 + }; 333 + Ok(CommandId::new(id)) 271 334 } 272 335 273 - fn build_command(cmd: RawSlashCommand) -> Result<CreateCommand<'static>, JsErrorBox> { 274 - let desc = cmd 275 - .description 276 - .ok_or_else(|| JsErrorBox::generic("Slash command must have a description"))?; 336 + fn build_command(cmd: RawSlashCommand) -> Result<CreateCommand<'static>, FloraError> { 337 + let Some(desc) = cmd.description else { 338 + return Err(FloraError::invalid_input( 339 + "command.description", 340 + "slash command must have a description", 341 + )); 342 + }; 277 343 let mut builder = CreateCommand::new(cmd.name).description(desc); 278 344 if let Some(options) = cmd.options { 279 345 for opt in options { ··· 283 349 Ok(builder) 284 350 } 285 351 286 - fn build_option(opt: RawSlashCommandOption) -> Result<CreateCommandOption<'static>, JsErrorBox> { 352 + fn build_option(opt: RawSlashCommandOption) -> Result<CreateCommandOption<'static>, FloraError> { 287 353 let opt_type = match opt.kind.as_deref() { 288 354 Some("integer") => serenity::all::CommandOptionType::Integer, 289 355 Some("number") => serenity::all::CommandOptionType::Number,
+91
apps/runtime/src/ops/errors.rs
··· 1 + use deno_error::{JsError, JsErrorBox}; 2 + use serenity::{Error as SerenityError, http::HttpError}; 3 + 4 + #[derive(Debug, thiserror::Error, JsError)] 5 + #[class(generic)] 6 + pub enum FloraError { 7 + #[property("code" = "DISCORD_RATE_LIMITED")] 8 + #[error("Discord rate limited for {route}")] 9 + RateLimited { 10 + retry_after_ms: u64, 11 + global: bool, 12 + route: String, 13 + }, 14 + #[property("code" = "DISCORD_HTTP_ERROR")] 15 + #[error("Discord HTTP error ({status}): {message}")] 16 + DiscordHttp { 17 + status: u16, 18 + #[property = "discord_code"] 19 + code: i32, 20 + message: String, 21 + }, 22 + #[property("code" = "SCOPE_FORBIDDEN")] 23 + #[error("Forbidden: {reason}")] 24 + ScopeForbidden { reason: String }, 25 + #[property("code" = "INVALID_INPUT")] 26 + #[error("Invalid input for {field}: {reason}")] 27 + InvalidInput { field: String, reason: String }, 28 + } 29 + 30 + impl FloraError { 31 + pub(crate) fn rate_limited( 32 + retry_after_ms: u64, 33 + global: bool, 34 + route: impl Into<String>, 35 + ) -> Self { 36 + Self::RateLimited { 37 + retry_after_ms, 38 + global, 39 + route: route.into(), 40 + } 41 + } 42 + 43 + pub(crate) fn discord_http(status: u16, code: i32, message: impl Into<String>) -> Self { 44 + Self::DiscordHttp { 45 + status, 46 + code, 47 + message: message.into(), 48 + } 49 + } 50 + 51 + pub(crate) fn scope_forbidden(reason: impl Into<String>) -> Self { 52 + Self::ScopeForbidden { 53 + reason: reason.into(), 54 + } 55 + } 56 + 57 + pub(crate) fn invalid_input(field: impl Into<String>, reason: impl Into<String>) -> Self { 58 + Self::InvalidInput { 59 + field: field.into(), 60 + reason: reason.into(), 61 + } 62 + } 63 + 64 + pub(crate) fn from_serenity_error(error: SerenityError, route: Option<&str>) -> Self { 65 + match error { 66 + SerenityError::Http(http_error) => match http_error { 67 + HttpError::UnsuccessfulRequest(response) => { 68 + let status = response.status_code.as_u16(); 69 + let code = response.error.code.0 as i32; 70 + let message = with_context(response.error.message.to_string(), route); 71 + FloraError::discord_http(status, code, message) 72 + } 73 + other => FloraError::discord_http(500, 0, with_context(other.to_string(), route)), 74 + }, 75 + other => FloraError::discord_http(500, 0, with_context(other.to_string(), route)), 76 + } 77 + } 78 + } 79 + 80 + impl From<FloraError> for JsErrorBox { 81 + fn from(err: FloraError) -> Self { 82 + JsErrorBox::from_err(err) 83 + } 84 + } 85 + 86 + fn with_context(message: String, route: Option<&str>) -> String { 87 + let Some(route) = route else { 88 + return message; 89 + }; 90 + format!("{message} (route: {route})") 91 + }
+118 -56
apps/runtime/src/ops/guilds.rs
··· 2 2 use deno_core::{OpState, op2}; 3 3 use deno_error::JsErrorBox; 4 4 use flora_macros::expose_input; 5 - use serenity::{ 6 - http::Http, 7 - model::id::{GuildId, RoleId, UserId}, 8 - }; 5 + use serenity::model::id::{GuildId, RoleId, UserId}; 9 6 use std::{cell::RefCell, rc::Rc, sync::Arc}; 10 7 use t0x::T0x; 8 + 9 + use crate::services::discord_rest::{DiscordRest, RestRetry}; 10 + 11 + use super::FloraError; 11 12 12 13 /// Arguments for operations targeting a user in a guild. 13 14 #[expose_input] ··· 25 26 state: Rc<RefCell<OpState>>, 26 27 #[serde] args: RawGuildUser, 27 28 ) -> Result<(), JsErrorBox> { 28 - let http = { 29 + let rest = { 29 30 let state = state.borrow(); 30 - state.borrow::<Arc<Http>>().clone() 31 + state.borrow::<Arc<DiscordRest>>().clone() 31 32 }; 32 33 let guild_id = parse_guild_id(&args.guild_id)?; 33 34 { ··· 35 36 ensure_guild_scope(&state, guild_id)?; 36 37 } 37 38 let user_id = parse_user_id(&args.user_id)?; 38 - http.kick_member(guild_id, user_id, args.reason.as_deref()) 39 - .await 40 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 39 + let reason = args.reason; 40 + let route = format!( 41 + "DELETE /guilds/{}/members/{}", 42 + guild_id.get(), 43 + user_id.get() 44 + ); 45 + rest.execute(guild_id, route, RestRetry::None, move |http| { 46 + let reason = reason.clone(); 47 + async move { http.kick_member(guild_id, user_id, reason.as_deref()).await } 48 + }) 49 + .await?; 41 50 Ok(()) 42 51 } 43 52 ··· 59 68 state: Rc<RefCell<OpState>>, 60 69 #[serde] args: RawBanMember, 61 70 ) -> Result<(), JsErrorBox> { 62 - let http = { 71 + let rest = { 63 72 let state = state.borrow(); 64 - state.borrow::<Arc<Http>>().clone() 73 + state.borrow::<Arc<DiscordRest>>().clone() 65 74 }; 66 75 let guild_id = parse_guild_id(&args.guild_id)?; 67 76 { ··· 70 79 } 71 80 let user_id = parse_user_id(&args.user_id)?; 72 81 let delete_seconds = args.delete_message_seconds.unwrap_or(0); 73 - http.ban_user(guild_id, user_id, delete_seconds, args.reason.as_deref()) 74 - .await 75 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 82 + let reason = args.reason; 83 + let route = format!("PUT /guilds/{}/bans/{}", guild_id.get(), user_id.get()); 84 + rest.execute(guild_id, route, RestRetry::None, move |http| { 85 + let reason = reason.clone(); 86 + async move { 87 + http.ban_user(guild_id, user_id, delete_seconds, reason.as_deref()) 88 + .await 89 + } 90 + }) 91 + .await?; 76 92 Ok(()) 77 93 } 78 94 ··· 81 97 state: Rc<RefCell<OpState>>, 82 98 #[serde] args: RawGuildUser, 83 99 ) -> Result<(), JsErrorBox> { 84 - let http = { 100 + let rest = { 85 101 let state = state.borrow(); 86 - state.borrow::<Arc<Http>>().clone() 102 + state.borrow::<Arc<DiscordRest>>().clone() 87 103 }; 88 104 let guild_id = parse_guild_id(&args.guild_id)?; 89 105 { ··· 91 107 ensure_guild_scope(&state, guild_id)?; 92 108 } 93 109 let user_id = parse_user_id(&args.user_id)?; 94 - http.remove_ban(guild_id, user_id, args.reason.as_deref()) 95 - .await 96 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 110 + let reason = args.reason; 111 + let route = format!("DELETE /guilds/{}/bans/{}", guild_id.get(), user_id.get()); 112 + rest.execute(guild_id, route, RestRetry::None, move |http| { 113 + let reason = reason.clone(); 114 + async move { http.remove_ban(guild_id, user_id, reason.as_deref()).await } 115 + }) 116 + .await?; 97 117 Ok(()) 98 118 } 99 119 ··· 115 135 state: Rc<RefCell<OpState>>, 116 136 #[serde] args: RawMemberRole, 117 137 ) -> Result<(), JsErrorBox> { 118 - let http = { 138 + let rest = { 119 139 let state = state.borrow(); 120 - state.borrow::<Arc<Http>>().clone() 140 + state.borrow::<Arc<DiscordRest>>().clone() 121 141 }; 122 142 let guild_id = parse_guild_id(&args.guild_id)?; 123 143 { ··· 126 146 } 127 147 let user_id = parse_user_id(&args.user_id)?; 128 148 let role_id = parse_role_id(&args.role_id)?; 129 - http.add_member_role(guild_id, user_id, role_id, args.reason.as_deref()) 130 - .await 131 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 149 + let reason = args.reason; 150 + let route = format!( 151 + "PUT /guilds/{}/members/{}/roles/{}", 152 + guild_id.get(), 153 + user_id.get(), 154 + role_id.get() 155 + ); 156 + rest.execute(guild_id, route, RestRetry::None, move |http| { 157 + let reason = reason.clone(); 158 + async move { 159 + http.add_member_role(guild_id, user_id, role_id, reason.as_deref()) 160 + .await 161 + } 162 + }) 163 + .await?; 132 164 Ok(()) 133 165 } 134 166 ··· 137 169 state: Rc<RefCell<OpState>>, 138 170 #[serde] args: RawMemberRole, 139 171 ) -> Result<(), JsErrorBox> { 140 - let http = { 172 + let rest = { 141 173 let state = state.borrow(); 142 - state.borrow::<Arc<Http>>().clone() 174 + state.borrow::<Arc<DiscordRest>>().clone() 143 175 }; 144 176 let guild_id = parse_guild_id(&args.guild_id)?; 145 177 { ··· 148 180 } 149 181 let user_id = parse_user_id(&args.user_id)?; 150 182 let role_id = parse_role_id(&args.role_id)?; 151 - http.remove_member_role(guild_id, user_id, role_id, args.reason.as_deref()) 152 - .await 153 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 183 + let reason = args.reason; 184 + let route = format!( 185 + "DELETE /guilds/{}/members/{}/roles/{}", 186 + guild_id.get(), 187 + user_id.get(), 188 + role_id.get() 189 + ); 190 + rest.execute(guild_id, route, RestRetry::None, move |http| { 191 + let reason = reason.clone(); 192 + async move { 193 + http.remove_member_role(guild_id, user_id, role_id, reason.as_deref()) 194 + .await 195 + } 196 + }) 197 + .await?; 154 198 Ok(()) 155 199 } 156 200 ··· 173 217 state: Rc<RefCell<OpState>>, 174 218 #[serde] args: RawEditMember, 175 219 ) -> Result<serde_json::Value, JsErrorBox> { 176 - let http = { 220 + let rest = { 177 221 let state = state.borrow(); 178 - state.borrow::<Arc<Http>>().clone() 222 + state.borrow::<Arc<DiscordRest>>().clone() 179 223 }; 180 224 let guild_id = parse_guild_id(&args.guild_id)?; 181 225 { ··· 183 227 ensure_guild_scope(&state, guild_id)?; 184 228 } 185 229 let user_id = parse_user_id(&args.user_id)?; 186 - let member = http 187 - .edit_member(guild_id, user_id, &args.payload, args.reason.as_deref()) 188 - .await 189 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 230 + let payload = args.payload; 231 + let reason = args.reason; 232 + let route = format!("PATCH /guilds/{}/members/{}", guild_id.get(), user_id.get()); 233 + let member = rest 234 + .execute(guild_id, route, RestRetry::None, move |http| { 235 + let payload = payload.clone(); 236 + let reason = reason.clone(); 237 + async move { 238 + http.edit_member(guild_id, user_id, &payload, reason.as_deref()) 239 + .await 240 + } 241 + }) 242 + .await?; 190 243 serde_json::to_value(member).map_err(|err| JsErrorBox::generic(err.to_string())) 191 244 } 192 245 ··· 207 260 state: Rc<RefCell<OpState>>, 208 261 #[serde] args: RawEditCurrentMember, 209 262 ) -> Result<serde_json::Value, JsErrorBox> { 210 - let http = { 263 + let rest = { 211 264 let state = state.borrow(); 212 - state.borrow::<Arc<Http>>().clone() 265 + state.borrow::<Arc<DiscordRest>>().clone() 213 266 }; 214 267 let guild_id = parse_guild_id(&args.guild_id)?; 215 268 { 216 269 let state = state.borrow(); 217 270 ensure_guild_scope(&state, guild_id)?; 218 271 } 219 - let member = http 220 - .edit_member_me(guild_id, &args.payload, args.reason.as_deref()) 221 - .await 222 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 272 + let payload = args.payload; 273 + let reason = args.reason; 274 + let route = format!("PATCH /guilds/{}/members/@me", guild_id.get()); 275 + let member = rest 276 + .execute(guild_id, route, RestRetry::None, move |http| { 277 + let payload = payload.clone(); 278 + let reason = reason.clone(); 279 + async move { 280 + http.edit_member_me(guild_id, &payload, reason.as_deref()) 281 + .await 282 + } 283 + }) 284 + .await?; 223 285 serde_json::to_value(member).map_err(|err| JsErrorBox::generic(err.to_string())) 224 286 } 225 287 226 - fn parse_guild_id(value: &str) -> Result<GuildId, JsErrorBox> { 227 - value 228 - .parse::<u64>() 229 - .map(GuildId::new) 230 - .map_err(|_| JsErrorBox::generic("Invalid guild id")) 288 + fn parse_guild_id(value: &str) -> Result<GuildId, FloraError> { 289 + let Ok(id) = value.parse::<u64>() else { 290 + return Err(FloraError::invalid_input("guild_id", "invalid snowflake")); 291 + }; 292 + Ok(GuildId::new(id)) 231 293 } 232 294 233 - fn parse_user_id(value: &str) -> Result<UserId, JsErrorBox> { 234 - value 235 - .parse::<u64>() 236 - .map(UserId::new) 237 - .map_err(|_| JsErrorBox::generic("Invalid user id")) 295 + fn parse_user_id(value: &str) -> Result<UserId, FloraError> { 296 + let Ok(id) = value.parse::<u64>() else { 297 + return Err(FloraError::invalid_input("user_id", "invalid snowflake")); 298 + }; 299 + Ok(UserId::new(id)) 238 300 } 239 301 240 - fn parse_role_id(value: &str) -> Result<RoleId, JsErrorBox> { 241 - value 242 - .parse::<u64>() 243 - .map(RoleId::new) 244 - .map_err(|_| JsErrorBox::generic("Invalid role id")) 302 + fn parse_role_id(value: &str) -> Result<RoleId, FloraError> { 303 + let Ok(id) = value.parse::<u64>() else { 304 + return Err(FloraError::invalid_input("role_id", "invalid snowflake")); 305 + }; 306 + Ok(RoleId::new(id)) 245 307 }
+130 -58
apps/runtime/src/ops/interaction.rs
··· 25 25 RawAllowedMentions, RawAttachment, RawEmbed, build_allowed_mentions, build_attachment, 26 26 build_embed, 27 27 }; 28 - use super::{authz::ensure_guild_scope, components::parse_components}; 28 + use super::{ 29 + authz::{ensure_guild_scope, runtime_guild_id_from_state}, 30 + components::parse_components, 31 + }; 32 + use crate::services::discord_rest::{DiscordRest, RestRetry}; 33 + 34 + use super::FloraError; 29 35 30 36 /// Arguments for sending an initial interaction response. 31 37 #[expose_input] ··· 116 122 state: Rc<RefCell<OpState>>, 117 123 #[serde] args: RawInteractionResponse, 118 124 ) -> Result<(), JsErrorBox> { 119 - let http = { 125 + let rest = { 120 126 let state = state.borrow(); 121 - state.borrow::<Arc<Http>>().clone() 127 + state.borrow::<Arc<DiscordRest>>().clone() 122 128 }; 123 - 124 - let interaction_id = args 125 - .interaction_id 126 - .parse::<u64>() 127 - .map_err(|_| JsErrorBox::generic("Invalid interaction id"))?; 129 + let runtime_guild_id = { 130 + let state = state.borrow(); 131 + runtime_guild_id_from_state(&state)? 132 + }; 133 + let interaction_id = parse_interaction_id(&args.interaction_id)?; 128 134 129 - let built = build_interaction_response(&http, args).await?; 135 + let built = build_interaction_response(rest.http(), args).await?; 130 136 131 137 let response = CreateInteractionResponse::Message(built.message); 132 - http.create_interaction_response( 133 - InteractionId::new(interaction_id), 134 - &built.token, 135 - &response, 136 - built.files, 137 - ) 138 - .await 139 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 138 + let token = built.token; 139 + let files = built.files; 140 + let route = format!("POST /interactions/{}/callback", interaction_id); 141 + rest.execute(runtime_guild_id, route, RestRetry::None, move |http| { 142 + let response = response.clone(); 143 + let token = token.clone(); 144 + let files = files.clone(); 145 + async move { 146 + http.create_interaction_response( 147 + InteractionId::new(interaction_id), 148 + &token, 149 + &response, 150 + files, 151 + ) 152 + .await 153 + } 154 + }) 155 + .await?; 140 156 141 157 Ok(()) 142 158 } ··· 157 173 state: Rc<RefCell<OpState>>, 158 174 #[serde] args: RawDeferInteractionResponse, 159 175 ) -> Result<(), JsErrorBox> { 160 - let http = { 176 + let rest = { 161 177 let state = state.borrow(); 162 - state.borrow::<Arc<Http>>().clone() 178 + state.borrow::<Arc<DiscordRest>>().clone() 163 179 }; 164 - let interaction_id = args 165 - .interaction_id 166 - .parse::<u64>() 167 - .map_err(|_| JsErrorBox::generic("Invalid interaction id"))?; 180 + let runtime_guild_id = { 181 + let state = state.borrow(); 182 + runtime_guild_id_from_state(&state)? 183 + }; 184 + 185 + let interaction_id = parse_interaction_id(&args.interaction_id)?; 168 186 let mut message = CreateInteractionResponseMessage::new(); 169 187 if let Some(ephemeral) = args.ephemeral { 170 188 message = message.ephemeral(ephemeral); 171 189 } 172 190 let response = CreateInteractionResponse::Defer(message); 173 - http.create_interaction_response( 174 - InteractionId::new(interaction_id), 175 - &args.token, 176 - &response, 177 - Vec::new(), 178 - ) 179 - .await 180 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 191 + let token = args.token; 192 + let route = format!("POST /interactions/{interaction_id}/callback"); 193 + rest.execute(runtime_guild_id, route, RestRetry::None, move |http| { 194 + let token = token.clone(); 195 + let response = response.clone(); 196 + async move { 197 + http.create_interaction_response( 198 + InteractionId::new(interaction_id), 199 + &token, 200 + &response, 201 + Vec::new(), 202 + ) 203 + .await 204 + } 205 + }) 206 + .await?; 207 + 181 208 Ok(()) 182 209 } 183 210 ··· 209 236 state: Rc<RefCell<OpState>>, 210 237 #[serde] args: RawUpdateInteractionResponse, 211 238 ) -> Result<(), JsErrorBox> { 212 - let http = { 239 + let rest = { 213 240 let state = state.borrow(); 214 - state.borrow::<Arc<Http>>().clone() 241 + state.borrow::<Arc<DiscordRest>>().clone() 215 242 }; 216 - let interaction_id = args 217 - .interaction_id 218 - .parse::<u64>() 219 - .map_err(|_| JsErrorBox::generic("Invalid interaction id"))?; 243 + let runtime_guild_id = { 244 + let state = state.borrow(); 245 + runtime_guild_id_from_state(&state)? 246 + }; 220 247 221 - let built = build_interaction_update(&http, args).await?; 248 + let interaction_id = parse_interaction_id(&args.interaction_id)?; 249 + let built = build_interaction_update(rest.http(), args).await?; 222 250 let response = CreateInteractionResponse::UpdateMessage(built.message); 223 - http.create_interaction_response( 224 - InteractionId::new(interaction_id), 225 - &built.token, 226 - &response, 227 - built.files, 228 - ) 229 - .await 230 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 251 + let token = built.token; 252 + let files = built.files; 253 + let route = format!("POST /interactions/{interaction_id}/callback"); 254 + rest.execute(runtime_guild_id, route, RestRetry::None, move |http| { 255 + let response = response.clone(); 256 + let token = token.clone(); 257 + let files = files.clone(); 258 + async move { 259 + http.create_interaction_response( 260 + InteractionId::new(interaction_id), 261 + &token, 262 + &response, 263 + files, 264 + ) 265 + .await 266 + } 267 + }) 268 + .await?; 269 + 231 270 Ok(()) 232 271 } 233 272 ··· 256 295 state: Rc<RefCell<OpState>>, 257 296 #[serde] args: RawEditInteractionResponse, 258 297 ) -> Result<serde_json::Value, JsErrorBox> { 259 - let http = { 298 + let rest = { 260 299 let state = state.borrow(); 261 - state.borrow::<Arc<Http>>().clone() 300 + state.borrow::<Arc<DiscordRest>>().clone() 262 301 }; 263 - let built = build_edit_interaction_response(&http, args).await?; 264 - let message = http 265 - .edit_original_interaction_response(&built.token, &built.message, built.files) 266 - .await 267 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 302 + let runtime_guild_id = { 303 + let state = state.borrow(); 304 + runtime_guild_id_from_state(&state)? 305 + }; 306 + 307 + let built = build_edit_interaction_response(rest.http(), args).await?; 308 + let token = built.token; 309 + let message_payload = built.message; 310 + let files = built.files; 311 + let route = "PATCH /webhooks/@original".to_string(); 312 + let message = rest 313 + .execute(runtime_guild_id, route, RestRetry::None, move |http| { 314 + let token = token.clone(); 315 + let message_payload = message_payload.clone(); 316 + let files = files.clone(); 317 + async move { 318 + http.edit_original_interaction_response(&token, &message_payload, files) 319 + .await 320 + } 321 + }) 322 + .await?; 323 + 268 324 serde_json::to_value(message).map_err(|err| JsErrorBox::generic(err.to_string())) 269 325 } 270 326 ··· 280 336 state: Rc<RefCell<OpState>>, 281 337 #[serde] args: RawDeleteInteractionResponse, 282 338 ) -> Result<(), JsErrorBox> { 283 - let http = { 339 + let rest = { 284 340 let state = state.borrow(); 285 - state.borrow::<Arc<Http>>().clone() 341 + state.borrow::<Arc<DiscordRest>>().clone() 286 342 }; 287 - http.delete_original_interaction_response(&args.token) 288 - .await 289 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 343 + let runtime_guild_id = { 344 + let state = state.borrow(); 345 + runtime_guild_id_from_state(&state)? 346 + }; 347 + 348 + let token = args.token; 349 + let route = "DELETE /webhooks/@original".to_string(); 350 + rest.execute(runtime_guild_id, route, RestRetry::None, move |http| { 351 + let token = token.clone(); 352 + async move { http.delete_original_interaction_response(&token).await } 353 + }) 354 + .await?; 355 + 290 356 Ok(()) 291 357 } 292 358 ··· 492 558 let mut hasher = DefaultHasher::new(); 493 559 value.hash(&mut hasher); 494 560 hasher.finish() 561 + } 562 + 563 + fn parse_interaction_id(value: &str) -> Result<u64, FloraError> { 564 + value 565 + .parse::<u64>() 566 + .map_err(|_| FloraError::invalid_input("interaction_id", "invalid snowflake")) 495 567 } 496 568 497 569 /// Build the response payload and attachments for an interaction reply.
+329 -191
apps/runtime/src/ops/message.rs
··· 20 20 use tracing::info; 21 21 use url::Url; 22 22 23 + use crate::services::discord_rest::{DiscordRest, RestRetry}; 24 + 23 25 use super::{ 26 + FloraError, 24 27 authz::{ensure_channel_scope, runtime_guild_id_from_state}, 25 28 components::parse_components, 26 29 }; ··· 203 206 } 204 207 205 208 #[op2(async)] 209 + #[serde] 206 210 pub async fn op_send_message( 207 211 state: Rc<RefCell<OpState>>, 208 212 #[serde] args: RawSendMessage, 209 - ) -> Result<(), JsErrorBox> { 210 - let http = { 213 + ) -> Result<serde_json::Value, JsErrorBox> { 214 + let rest = { 211 215 let state = state.borrow(); 212 - state.borrow::<Arc<Http>>().clone() 216 + state.borrow::<Arc<DiscordRest>>().clone() 213 217 }; 214 218 215 - let channel_id_num = args 216 - .channel_id 217 - .parse::<u64>() 218 - .map_err(|_| JsErrorBox::generic("Invalid channel id"))?; 219 - let channel_id = ChannelId::new(channel_id_num); 219 + let channel_id = parse_channel_id(&args.channel_id)?; 220 220 let runtime_guild_id = { 221 221 let state = state.borrow(); 222 222 runtime_guild_id_from_state(&state)? 223 223 }; 224 - ensure_channel_scope(runtime_guild_id, &http, channel_id).await?; 224 + ensure_channel_scope(runtime_guild_id, rest.scope_cache(), channel_id).await?; 225 + 225 226 let reply_to = args.reply_to.or(args.message_id); 226 - tracing::info!( 227 - target: "flora:ops", 228 - "op_send_message channel={} reply_to={:?}", 229 - channel_id, 230 - reply_to 231 - ); 227 + info!(target: "flora:ops", "op_send_message channel={} reply_to={:?}", channel_id, reply_to); 232 228 233 229 let mut message = CreateMessage::new(); 234 230 let mut has_content = false; ··· 259 255 } 260 256 261 257 if let Some(components) = args.components { 262 - let components = parse_components(components)?; 258 + let components = parse_components(components) 259 + .map_err(|err| FloraError::invalid_input("components", err.to_string()))?; 263 260 has_components = !components.is_empty(); 264 261 message = message.components(components); 265 262 } ··· 268 265 message = message.flags(MessageFlags::from_bits_truncate(flags)); 269 266 } 270 267 271 - if let Some(message_id_str) = reply_to { 272 - let message_id = message_id_str 273 - .parse::<u64>() 274 - .map_err(|_| JsErrorBox::generic("Invalid message id"))?; 268 + if let Some(message_id) = reply_to { 269 + let message_id = parse_message_id(&message_id)?; 275 270 let reference = MessageReference::new(MessageReferenceKind::Default, channel_id.widen()) 276 - .message_id(MessageId::new(message_id)); 271 + .message_id(message_id); 277 272 message = message.reference_message(reference); 278 273 } 279 274 280 275 if let Some(attachments) = args.attachments { 281 276 let mut files = Vec::with_capacity(attachments.len()); 282 277 for attachment in attachments { 283 - files.push(build_attachment(&http, attachment).await?); 278 + files.push(build_attachment(rest.http(), attachment).await?); 284 279 } 285 280 has_attachments = !files.is_empty(); 286 281 message = message.add_files(files); 287 282 } 288 283 289 - // Fail early if we ended up with an empty payload. 290 284 if !has_content && !has_embeds && !has_attachments && !has_components { 291 - return Err(JsErrorBox::generic( 292 - "Message must include content, embeds, attachments, or components", 293 - )); 285 + return Err(FloraError::invalid_input( 286 + "payload", 287 + "message must include content, embeds, attachments, or components", 288 + ) 289 + .into()); 294 290 } 295 291 296 - channel_id 297 - .widen() 298 - .send_message(&http, message) 299 - .await 300 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 301 - Ok(()) 292 + let route = format!("POST /channels/{}/messages", channel_id.get()); 293 + let created = rest 294 + .execute(runtime_guild_id, route, RestRetry::None, move |http| { 295 + let message = message.clone(); 296 + async move { channel_id.widen().send_message(&http, message).await } 297 + }) 298 + .await?; 299 + 300 + to_json_value(created).map_err(Into::into) 302 301 } 303 302 304 303 #[op2(async)] ··· 306 305 state: Rc<RefCell<OpState>>, 307 306 #[serde] args: RawEditMessage, 308 307 ) -> Result<(), JsErrorBox> { 309 - let http = { 308 + let rest = { 310 309 let state = state.borrow(); 311 - state.borrow::<Arc<Http>>().clone() 310 + state.borrow::<Arc<DiscordRest>>().clone() 312 311 }; 313 312 314 - let channel_id_num = args 315 - .channel_id 316 - .parse::<u64>() 317 - .map_err(|_| JsErrorBox::generic("Invalid channel id"))?; 318 - let message_id_num = args 319 - .message_id 320 - .parse::<u64>() 321 - .map_err(|_| JsErrorBox::generic("Invalid message id"))?; 322 - let channel_id = ChannelId::new(channel_id_num); 313 + let channel_id = parse_channel_id(&args.channel_id)?; 314 + let message_id = parse_message_id(&args.message_id)?; 323 315 let runtime_guild_id = { 324 316 let state = state.borrow(); 325 317 runtime_guild_id_from_state(&state)? 326 318 }; 327 - ensure_channel_scope(runtime_guild_id, &http, channel_id).await?; 328 - let message_id = MessageId::new(message_id_num); 319 + ensure_channel_scope(runtime_guild_id, rest.scope_cache(), channel_id).await?; 329 320 330 321 let mut message = serenity::builder::EditMessage::new(); 331 322 let mut has_payload = false; ··· 345 336 } 346 337 347 338 if let Some(components) = args.components { 348 - let components = parse_components(components)?; 339 + let components = parse_components(components) 340 + .map_err(|err| FloraError::invalid_input("components", err.to_string()))?; 349 341 message = message.components(components); 350 342 has_payload = true; 351 343 } ··· 361 353 } 362 354 363 355 if !has_payload { 364 - return Err(JsErrorBox::generic( 365 - "Message edit must include content, embeds, flags, or allowed mentions", 366 - )); 356 + return Err(FloraError::invalid_input( 357 + "payload", 358 + "message edit must include content, embeds, flags, or allowed mentions", 359 + ) 360 + .into()); 367 361 } 368 362 369 - channel_id 370 - .widen() 371 - .edit_message(&http, message_id, message) 372 - .await 373 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 363 + let route = format!( 364 + "PATCH /channels/{}/messages/{}", 365 + channel_id.get(), 366 + message_id.get() 367 + ); 368 + rest.execute(runtime_guild_id, route, RestRetry::None, move |http| { 369 + let message = message.clone(); 370 + async move { 371 + channel_id 372 + .widen() 373 + .edit_message(&http, message_id, message) 374 + .await 375 + } 376 + }) 377 + .await?; 378 + 374 379 Ok(()) 375 380 } 376 381 ··· 388 393 state: Rc<RefCell<OpState>>, 389 394 #[serde] args: RawDeleteMessage, 390 395 ) -> Result<(), JsErrorBox> { 391 - let http = { 396 + let rest = { 392 397 let state = state.borrow(); 393 - state.borrow::<Arc<Http>>().clone() 398 + state.borrow::<Arc<DiscordRest>>().clone() 394 399 }; 395 400 let channel_id = parse_channel_id(&args.channel_id)?; 401 + let message_id = parse_message_id(&args.message_id)?; 396 402 let runtime_guild_id = { 397 403 let state = state.borrow(); 398 404 runtime_guild_id_from_state(&state)? 399 405 }; 400 - ensure_channel_scope(runtime_guild_id, &http, channel_id).await?; 401 - let message_id = parse_message_id(&args.message_id)?; 402 - channel_id 403 - .widen() 404 - .delete_message(&http, message_id, None) 405 - .await 406 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 406 + ensure_channel_scope(runtime_guild_id, rest.scope_cache(), channel_id).await?; 407 + 408 + let route = format!( 409 + "DELETE /channels/{}/messages/{}", 410 + channel_id.get(), 411 + message_id.get() 412 + ); 413 + rest.execute( 414 + runtime_guild_id, 415 + route, 416 + RestRetry::None, 417 + move |http| async move { 418 + channel_id 419 + .widen() 420 + .delete_message(&http, message_id, None) 421 + .await 422 + }, 423 + ) 424 + .await?; 425 + 407 426 Ok(()) 408 427 } 409 428 ··· 421 440 state: Rc<RefCell<OpState>>, 422 441 #[serde] args: RawBulkDeleteMessages, 423 442 ) -> Result<(), JsErrorBox> { 424 - let http = { 443 + let rest = { 425 444 let state = state.borrow(); 426 - state.borrow::<Arc<Http>>().clone() 445 + state.borrow::<Arc<DiscordRest>>().clone() 427 446 }; 428 447 let channel_id = parse_channel_id(&args.channel_id)?; 429 448 let runtime_guild_id = { 430 449 let state = state.borrow(); 431 450 runtime_guild_id_from_state(&state)? 432 451 }; 433 - ensure_channel_scope(runtime_guild_id, &http, channel_id).await?; 434 - let message_ids = args 435 - .message_ids 436 - .into_iter() 437 - .map(|id| parse_message_id(&id)) 438 - .collect::<Result<Vec<_>, _>>()?; 439 - channel_id 440 - .widen() 441 - .delete_messages(&http, &message_ids, None) 442 - .await 443 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 452 + ensure_channel_scope(runtime_guild_id, rest.scope_cache(), channel_id).await?; 453 + 454 + let mut message_ids = Vec::with_capacity(args.message_ids.len()); 455 + for id in args.message_ids { 456 + message_ids.push(parse_message_id(&id)?); 457 + } 458 + 459 + let route = format!("POST /channels/{}/messages/bulk-delete", channel_id.get()); 460 + rest.execute(runtime_guild_id, route, RestRetry::None, move |http| { 461 + let message_ids = message_ids.clone(); 462 + async move { 463 + channel_id 464 + .widen() 465 + .delete_messages(&http, &message_ids, None) 466 + .await 467 + } 468 + }) 469 + .await?; 470 + 444 471 Ok(()) 445 472 } 446 473 ··· 458 485 state: Rc<RefCell<OpState>>, 459 486 #[serde] args: RawPinMessage, 460 487 ) -> Result<(), JsErrorBox> { 461 - let http = { 488 + let rest = { 462 489 let state = state.borrow(); 463 - state.borrow::<Arc<Http>>().clone() 490 + state.borrow::<Arc<DiscordRest>>().clone() 464 491 }; 465 492 let channel_id = parse_channel_id(&args.channel_id)?; 493 + let message_id = parse_message_id(&args.message_id)?; 466 494 let runtime_guild_id = { 467 495 let state = state.borrow(); 468 496 runtime_guild_id_from_state(&state)? 469 497 }; 470 - ensure_channel_scope(runtime_guild_id, &http, channel_id).await?; 471 - let message_id = parse_message_id(&args.message_id)?; 472 - channel_id 473 - .widen() 474 - .pin(&http, message_id, None) 475 - .await 476 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 498 + ensure_channel_scope(runtime_guild_id, rest.scope_cache(), channel_id).await?; 499 + 500 + let route = format!( 501 + "PUT /channels/{}/pins/{}", 502 + channel_id.get(), 503 + message_id.get() 504 + ); 505 + rest.execute( 506 + runtime_guild_id, 507 + route, 508 + RestRetry::None, 509 + move |http| async move { channel_id.widen().pin(&http, message_id, None).await }, 510 + ) 511 + .await?; 512 + 477 513 Ok(()) 478 514 } 479 515 ··· 482 518 state: Rc<RefCell<OpState>>, 483 519 #[serde] args: RawPinMessage, 484 520 ) -> Result<(), JsErrorBox> { 485 - let http = { 521 + let rest = { 486 522 let state = state.borrow(); 487 - state.borrow::<Arc<Http>>().clone() 523 + state.borrow::<Arc<DiscordRest>>().clone() 488 524 }; 489 525 let channel_id = parse_channel_id(&args.channel_id)?; 526 + let message_id = parse_message_id(&args.message_id)?; 490 527 let runtime_guild_id = { 491 528 let state = state.borrow(); 492 529 runtime_guild_id_from_state(&state)? 493 530 }; 494 - ensure_channel_scope(runtime_guild_id, &http, channel_id).await?; 495 - let message_id = parse_message_id(&args.message_id)?; 496 - channel_id 497 - .widen() 498 - .unpin(&http, message_id, None) 499 - .await 500 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 531 + ensure_channel_scope(runtime_guild_id, rest.scope_cache(), channel_id).await?; 532 + 533 + let route = format!( 534 + "DELETE /channels/{}/pins/{}", 535 + channel_id.get(), 536 + message_id.get() 537 + ); 538 + rest.execute( 539 + runtime_guild_id, 540 + route, 541 + RestRetry::None, 542 + move |http| async move { channel_id.widen().unpin(&http, message_id, None).await }, 543 + ) 544 + .await?; 545 + 501 546 Ok(()) 502 547 } 503 548 ··· 516 561 state: Rc<RefCell<OpState>>, 517 562 #[serde] args: RawCrosspostMessage, 518 563 ) -> Result<serde_json::Value, JsErrorBox> { 519 - let http = { 564 + let rest = { 520 565 let state = state.borrow(); 521 - state.borrow::<Arc<Http>>().clone() 566 + state.borrow::<Arc<DiscordRest>>().clone() 522 567 }; 523 568 let channel_id = parse_channel_id(&args.channel_id)?; 569 + let message_id = parse_message_id(&args.message_id)?; 524 570 let runtime_guild_id = { 525 571 let state = state.borrow(); 526 572 runtime_guild_id_from_state(&state)? 527 573 }; 528 - ensure_channel_scope(runtime_guild_id, &http, channel_id).await?; 529 - let message_id = parse_message_id(&args.message_id)?; 530 - let message = channel_id 531 - .crosspost(&http, message_id) 532 - .await 533 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 534 - serde_json::to_value(message).map_err(|err| JsErrorBox::generic(err.to_string())) 574 + ensure_channel_scope(runtime_guild_id, rest.scope_cache(), channel_id).await?; 575 + 576 + let route = format!( 577 + "POST /channels/{}/messages/{}/crosspost", 578 + channel_id.get(), 579 + message_id.get() 580 + ); 581 + let message = rest 582 + .execute( 583 + runtime_guild_id, 584 + route, 585 + RestRetry::None, 586 + move |http| async move { channel_id.crosspost(&http, message_id).await }, 587 + ) 588 + .await?; 589 + 590 + to_json_value(message).map_err(Into::into) 535 591 } 536 592 537 593 /// Arguments for fetching a single message. ··· 549 605 state: Rc<RefCell<OpState>>, 550 606 #[serde] args: RawFetchMessage, 551 607 ) -> Result<serde_json::Value, JsErrorBox> { 552 - let http = { 608 + let rest = { 553 609 let state = state.borrow(); 554 - state.borrow::<Arc<Http>>().clone() 610 + state.borrow::<Arc<DiscordRest>>().clone() 555 611 }; 556 612 let channel_id = parse_channel_id(&args.channel_id)?; 613 + let message_id = parse_message_id(&args.message_id)?; 557 614 let runtime_guild_id = { 558 615 let state = state.borrow(); 559 616 runtime_guild_id_from_state(&state)? 560 617 }; 561 - ensure_channel_scope(runtime_guild_id, &http, channel_id).await?; 562 - let message_id = parse_message_id(&args.message_id)?; 563 - let message = http 564 - .get_message(channel_id.widen(), message_id) 565 - .await 566 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 567 - serde_json::to_value(message).map_err(|err| JsErrorBox::generic(err.to_string())) 618 + ensure_channel_scope(runtime_guild_id, rest.scope_cache(), channel_id).await?; 619 + 620 + let route = format!( 621 + "GET /channels/{}/messages/{}", 622 + channel_id.get(), 623 + message_id.get() 624 + ); 625 + let message = rest 626 + .execute( 627 + runtime_guild_id, 628 + route, 629 + RestRetry::ReadOnly, 630 + move |http| async move { http.get_message(channel_id.widen(), message_id).await }, 631 + ) 632 + .await?; 633 + 634 + to_json_value(message).map_err(Into::into) 568 635 } 569 636 570 637 /// Arguments for fetching multiple messages from a channel. ··· 588 655 state: Rc<RefCell<OpState>>, 589 656 #[serde] args: RawFetchMessages, 590 657 ) -> Result<Vec<serde_json::Value>, JsErrorBox> { 591 - let http = { 658 + let rest = { 592 659 let state = state.borrow(); 593 - state.borrow::<Arc<Http>>().clone() 660 + state.borrow::<Arc<DiscordRest>>().clone() 594 661 }; 595 662 let channel_id = parse_channel_id(&args.channel_id)?; 596 663 let runtime_guild_id = { 597 664 let state = state.borrow(); 598 665 runtime_guild_id_from_state(&state)? 599 666 }; 600 - ensure_channel_scope(runtime_guild_id, &http, channel_id).await?; 667 + ensure_channel_scope(runtime_guild_id, rest.scope_cache(), channel_id).await?; 668 + 601 669 let mut builder = GetMessages::new(); 602 670 if let Some(limit) = args.limit { 603 671 builder = builder.limit(limit); ··· 611 679 if let Some(around) = args.around { 612 680 builder = builder.around(parse_message_id(&around)?); 613 681 } 614 - let messages = channel_id 615 - .widen() 616 - .messages(&http, builder) 617 - .await 618 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 619 - messages 620 - .into_iter() 621 - .map(|msg| serde_json::to_value(msg).map_err(|err| JsErrorBox::generic(err.to_string()))) 622 - .collect() 682 + 683 + let route = format!("GET /channels/{}/messages", channel_id.get()); 684 + let messages = rest 685 + .execute( 686 + runtime_guild_id, 687 + route, 688 + RestRetry::ReadOnly, 689 + move |http| async move { channel_id.widen().messages(&http, builder).await }, 690 + ) 691 + .await?; 692 + 693 + to_json_values(messages).map_err(Into::into) 623 694 } 624 695 625 696 /// Arguments for adding or removing a reaction. ··· 640 711 state: Rc<RefCell<OpState>>, 641 712 #[serde] args: RawReaction, 642 713 ) -> Result<(), JsErrorBox> { 643 - let http = { 714 + let rest = { 644 715 let state = state.borrow(); 645 - state.borrow::<Arc<Http>>().clone() 716 + state.borrow::<Arc<DiscordRest>>().clone() 646 717 }; 647 718 let channel_id = parse_channel_id(&args.channel_id)?; 719 + let message_id = parse_message_id(&args.message_id)?; 720 + let reaction = parse_reaction(&args.emoji)?; 648 721 let runtime_guild_id = { 649 722 let state = state.borrow(); 650 723 runtime_guild_id_from_state(&state)? 651 724 }; 652 - ensure_channel_scope(runtime_guild_id, &http, channel_id).await?; 653 - let message_id = parse_message_id(&args.message_id)?; 654 - let reaction = parse_reaction(&args.emoji)?; 655 - channel_id 656 - .widen() 657 - .create_reaction(&http, message_id, reaction) 658 - .await 659 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 725 + ensure_channel_scope(runtime_guild_id, rest.scope_cache(), channel_id).await?; 726 + 727 + let route = format!( 728 + "PUT /channels/{}/messages/{}/reactions/@me", 729 + channel_id.get(), 730 + message_id.get() 731 + ); 732 + rest.execute(runtime_guild_id, route, RestRetry::None, move |http| { 733 + let reaction = reaction.clone(); 734 + async move { 735 + channel_id 736 + .widen() 737 + .create_reaction(&http, message_id, reaction) 738 + .await 739 + } 740 + }) 741 + .await?; 742 + 660 743 Ok(()) 661 744 } 662 745 ··· 665 748 state: Rc<RefCell<OpState>>, 666 749 #[serde] args: RawReaction, 667 750 ) -> Result<(), JsErrorBox> { 668 - let http = { 751 + let rest = { 669 752 let state = state.borrow(); 670 - state.borrow::<Arc<Http>>().clone() 753 + state.borrow::<Arc<DiscordRest>>().clone() 671 754 }; 672 755 let channel_id = parse_channel_id(&args.channel_id)?; 673 - let runtime_guild_id = { 674 - let state = state.borrow(); 675 - runtime_guild_id_from_state(&state)? 676 - }; 677 - ensure_channel_scope(runtime_guild_id, &http, channel_id).await?; 678 756 let message_id = parse_message_id(&args.message_id)?; 679 757 let reaction = parse_reaction(&args.emoji)?; 680 - let user_id = if let Some(id) = args.user_id { 681 - Some(UserId::new( 682 - id.parse::<u64>() 683 - .map_err(|_| JsErrorBox::generic("Invalid user id"))?, 684 - )) 758 + let user_id = if let Some(user_id) = args.user_id { 759 + Some(parse_user_id(&user_id)?) 685 760 } else { 686 761 None 687 762 }; 688 - channel_id 689 - .widen() 690 - .delete_reaction(&http, message_id, user_id, reaction) 691 - .await 692 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 763 + let runtime_guild_id = { 764 + let state = state.borrow(); 765 + runtime_guild_id_from_state(&state)? 766 + }; 767 + ensure_channel_scope(runtime_guild_id, rest.scope_cache(), channel_id).await?; 768 + 769 + let route = format!( 770 + "DELETE /channels/{}/messages/{}/reactions", 771 + channel_id.get(), 772 + message_id.get() 773 + ); 774 + rest.execute(runtime_guild_id, route, RestRetry::None, move |http| { 775 + let reaction = reaction.clone(); 776 + async move { 777 + channel_id 778 + .widen() 779 + .delete_reaction(&http, message_id, user_id, reaction) 780 + .await 781 + } 782 + }) 783 + .await?; 784 + 693 785 Ok(()) 694 786 } 695 787 ··· 709 801 state: Rc<RefCell<OpState>>, 710 802 #[serde] args: RawClearReactions, 711 803 ) -> Result<(), JsErrorBox> { 712 - let http = { 804 + let rest = { 713 805 let state = state.borrow(); 714 - state.borrow::<Arc<Http>>().clone() 806 + state.borrow::<Arc<DiscordRest>>().clone() 715 807 }; 716 808 let channel_id = parse_channel_id(&args.channel_id)?; 809 + let message_id = parse_message_id(&args.message_id)?; 717 810 let runtime_guild_id = { 718 811 let state = state.borrow(); 719 812 runtime_guild_id_from_state(&state)? 720 813 }; 721 - ensure_channel_scope(runtime_guild_id, &http, channel_id).await?; 722 - let message_id = parse_message_id(&args.message_id)?; 814 + ensure_channel_scope(runtime_guild_id, rest.scope_cache(), channel_id).await?; 815 + 723 816 if let Some(emoji) = args.emoji { 724 817 let reaction = parse_reaction(&emoji)?; 725 - channel_id 726 - .widen() 727 - .delete_reaction_emoji(&http, message_id, reaction) 728 - .await 729 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 818 + let route = format!( 819 + "DELETE /channels/{}/messages/{}/reactions/emoji", 820 + channel_id.get(), 821 + message_id.get() 822 + ); 823 + rest.execute(runtime_guild_id, route, RestRetry::None, move |http| { 824 + let reaction = reaction.clone(); 825 + async move { 826 + channel_id 827 + .widen() 828 + .delete_reaction_emoji(&http, message_id, reaction) 829 + .await 830 + } 831 + }) 832 + .await?; 730 833 } else { 731 - channel_id 732 - .widen() 733 - .delete_reactions(&http, message_id) 734 - .await 735 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 834 + let route = format!( 835 + "DELETE /channels/{}/messages/{}/reactions", 836 + channel_id.get(), 837 + message_id.get() 838 + ); 839 + rest.execute( 840 + runtime_guild_id, 841 + route, 842 + RestRetry::None, 843 + move |http| async move { channel_id.widen().delete_reactions(&http, message_id).await }, 844 + ) 845 + .await?; 736 846 } 847 + 737 848 Ok(()) 738 849 } 739 850 ··· 776 887 allowed 777 888 } 778 889 779 - pub(crate) fn build_embed(input: RawEmbed) -> Result<CreateEmbed<'static>, JsErrorBox> { 890 + pub(crate) fn build_embed(input: RawEmbed) -> Result<CreateEmbed<'static>, FloraError> { 780 891 let mut embed = CreateEmbed::new(); 781 892 782 893 if let Some(title) = input.title { ··· 798 909 if let Some(timestamp) = input.timestamp { 799 910 let parsed = timestamp 800 911 .parse::<Timestamp>() 801 - .map_err(|_| JsErrorBox::generic("Invalid embed timestamp"))?; 912 + .map_err(|_| FloraError::invalid_input("embeds[].timestamp", "invalid timestamp"))?; 802 913 embed = embed.timestamp(parsed); 803 914 } 804 915 ··· 849 960 pub(crate) async fn build_attachment( 850 961 http: &Arc<Http>, 851 962 attachment: RawAttachment, 852 - ) -> Result<CreateAttachment<'static>, JsErrorBox> { 963 + ) -> Result<CreateAttachment<'static>, FloraError> { 853 964 match attachment { 854 965 RawAttachment::Url { 855 966 url, ··· 867 978 .filter(|name| !name.is_empty()) 868 979 .unwrap_or_else(|| "attachment".to_string()) 869 980 }); 870 - let mut att = serenity::builder::CreateAttachment::url(http, &url, resolved_name) 871 - .await 872 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 981 + let mut attachment = 982 + serenity::builder::CreateAttachment::url(http, &url, resolved_name) 983 + .await 984 + .map_err(|err| FloraError::invalid_input("attachments", err.to_string()))?; 873 985 874 - if let Some(name) = filename { 875 - att.filename = name.into(); 986 + if let Some(filename) = filename { 987 + attachment.filename = filename.into(); 876 988 } 877 - if let Some(desc) = description { 878 - att = att.description(desc); 989 + if let Some(description) = description { 990 + attachment = attachment.description(description); 879 991 } 880 - Ok(att) 992 + 993 + Ok(attachment) 881 994 } 882 995 RawAttachment::Base64 { 883 996 data, ··· 886 999 } => { 887 1000 let bytes = base64::engine::general_purpose::STANDARD 888 1001 .decode(data) 889 - .map_err(|_| JsErrorBox::generic("Invalid base64 attachment data"))?; 890 - let mut att = CreateAttachment::bytes(bytes, filename); 891 - if let Some(desc) = description { 892 - att = att.description(desc); 1002 + .map_err(|_| { 1003 + FloraError::invalid_input("attachments[].data", "invalid base64 data") 1004 + })?; 1005 + let mut attachment = CreateAttachment::bytes(bytes, filename); 1006 + if let Some(description) = description { 1007 + attachment = attachment.description(description); 893 1008 } 894 - Ok(att) 1009 + Ok(attachment) 895 1010 } 896 1011 } 897 1012 } 898 1013 899 - fn parse_channel_id(value: &str) -> Result<ChannelId, JsErrorBox> { 900 - let id = value 901 - .parse::<u64>() 902 - .map_err(|_| JsErrorBox::generic("Invalid channel id"))?; 1014 + fn parse_channel_id(value: &str) -> Result<ChannelId, FloraError> { 1015 + let Ok(id) = value.parse::<u64>() else { 1016 + return Err(FloraError::invalid_input("channel_id", "invalid snowflake")); 1017 + }; 903 1018 Ok(ChannelId::new(id)) 904 1019 } 905 1020 906 - fn parse_message_id(value: &str) -> Result<MessageId, JsErrorBox> { 907 - let id = value 908 - .parse::<u64>() 909 - .map_err(|_| JsErrorBox::generic("Invalid message id"))?; 1021 + fn parse_message_id(value: &str) -> Result<MessageId, FloraError> { 1022 + let Ok(id) = value.parse::<u64>() else { 1023 + return Err(FloraError::invalid_input("message_id", "invalid snowflake")); 1024 + }; 910 1025 Ok(MessageId::new(id)) 911 1026 } 912 1027 913 - fn parse_reaction(value: &str) -> Result<serenity::model::channel::ReactionType, JsErrorBox> { 1028 + fn parse_user_id(value: &str) -> Result<UserId, FloraError> { 1029 + let Ok(id) = value.parse::<u64>() else { 1030 + return Err(FloraError::invalid_input("user_id", "invalid snowflake")); 1031 + }; 1032 + Ok(UserId::new(id)) 1033 + } 1034 + 1035 + fn parse_reaction(value: &str) -> Result<serenity::model::channel::ReactionType, FloraError> { 914 1036 serenity::model::channel::ReactionType::try_from(value) 915 - .map_err(|_| JsErrorBox::generic("Invalid reaction emoji")) 1037 + .map_err(|_| FloraError::invalid_input("emoji", "invalid reaction emoji")) 1038 + } 1039 + 1040 + fn to_json_value<T: serde::Serialize>(value: T) -> Result<serde_json::Value, FloraError> { 1041 + serde_json::to_value(value).map_err(|err| { 1042 + FloraError::discord_http(500, 0, format!("failed to serialize response: {err}")) 1043 + }) 1044 + } 1045 + 1046 + fn to_json_values<T: serde::Serialize>( 1047 + values: Vec<T>, 1048 + ) -> Result<Vec<serde_json::Value>, FloraError> { 1049 + let mut json_values = Vec::with_capacity(values.len()); 1050 + for value in values { 1051 + json_values.push(to_json_value(value)?); 1052 + } 1053 + Ok(json_values) 916 1054 }
+8 -6
apps/runtime/src/ops/mod.rs
··· 1 - use crate::services::kv::KvService; 2 - use serenity::http::Http; 1 + use crate::services::{discord_rest::DiscordRest, kv::KvService}; 3 2 use std::sync::Arc; 4 3 5 4 mod authz; ··· 7 6 pub mod commands; 8 7 pub mod components; 9 8 pub mod cron; 9 + pub mod errors; 10 10 pub mod guilds; 11 11 pub mod interaction; 12 12 pub mod kv; ··· 15 15 mod tls; 16 16 pub mod webhooks; 17 17 pub use cron::{CronRegistry, SharedCronRegistry}; 18 + pub(crate) use errors::FloraError; 18 19 19 20 deno_core::extension!( 20 21 flora_ops, ··· 79 80 secrets::op_secret_placeholder, 80 81 ], 81 82 options = { 82 - http: Arc<Http>, 83 + rest: Arc<DiscordRest>, 83 84 kv: KvService, 84 85 cron_registry: SharedCronRegistry, 85 86 }, 86 87 state = |state, options| { 87 - state.put(options.http.clone()); 88 + state.put(options.rest.clone()); 89 + state.put(options.rest.http().clone()); 88 90 state.put(options.kv.clone()); 89 91 state.put(options.cron_registry.clone()); 90 92 } 91 93 ); 92 94 93 95 pub fn extension( 94 - http: Arc<Http>, 96 + rest: Arc<DiscordRest>, 95 97 kv: KvService, 96 98 cron_registry: SharedCronRegistry, 97 99 ) -> deno_core::Extension { 98 - flora_ops::init(http, kv, cron_registry) 100 + flora_ops::init(rest, kv, cron_registry) 99 101 }
+93 -56
apps/runtime/src/ops/webhooks.rs
··· 6 6 authz::{ensure_thread_scope, ensure_webhook_scope, runtime_guild_id_from_state}, 7 7 components::parse_components, 8 8 }; 9 + use crate::services::discord_rest::{DiscordRest, RestRetry}; 9 10 use deno_core::{OpState, op2}; 10 11 use deno_error::JsErrorBox; 11 12 use flora_macros::expose_input; 12 13 use serenity::{ 13 14 builder::ExecuteWebhook, 14 - http::Http, 15 15 model::id::{ThreadId, WebhookId}, 16 16 }; 17 17 use std::{cell::RefCell, rc::Rc, sync::Arc}; 18 18 use t0x::T0x; 19 + 20 + use super::FloraError; 19 21 20 22 /// Arguments for executing a webhook. 21 23 #[expose_input] ··· 58 60 state: Rc<RefCell<OpState>>, 59 61 #[serde] args: RawExecuteWebhook, 60 62 ) -> Result<Option<serde_json::Value>, JsErrorBox> { 61 - let http = { 63 + let rest = { 62 64 let state = state.borrow(); 63 - state.borrow::<Arc<Http>>().clone() 65 + state.borrow::<Arc<DiscordRest>>().clone() 64 66 }; 65 67 let webhook_id = parse_webhook_id(&args.webhook_id)?; 66 68 let runtime_guild_id = { 67 69 let state = state.borrow(); 68 70 runtime_guild_id_from_state(&state)? 69 71 }; 70 - ensure_webhook_scope(runtime_guild_id, &http, webhook_id).await?; 72 + ensure_webhook_scope(runtime_guild_id, rest.scope_cache(), webhook_id).await?; 71 73 let thread_id = match &args.thread_id { 72 74 Some(id) => Some(parse_thread_id(id)?), 73 75 None => None, 74 76 }; 75 77 if let Some(thread_id) = thread_id { 76 - ensure_thread_scope(runtime_guild_id, &http, thread_id).await?; 78 + ensure_thread_scope(runtime_guild_id, rest.scope_cache(), thread_id).await?; 77 79 } 78 80 let wait = args.wait.unwrap_or(false); 79 81 let with_components = args.with_components.unwrap_or(false); ··· 106 108 message = message.embeds(embeds); 107 109 } 108 110 if let Some(components) = args.components { 109 - let components = parse_components(components)?; 111 + let components = parse_components(components) 112 + .map_err(|err| FloraError::invalid_input("components", err.to_string()))?; 110 113 has_components = !components.is_empty(); 111 114 message = message.components(components); 112 115 } ··· 125 128 let mut files = Vec::new(); 126 129 if let Some(attachments) = args.attachments { 127 130 for attachment in attachments { 128 - files.push(build_attachment(&http, attachment).await?); 131 + files.push(build_attachment(rest.http(), attachment).await?); 129 132 } 130 133 has_attachments = !files.is_empty(); 131 134 message = message.files(files.clone()); 132 135 } 133 136 134 137 if !has_content && !has_embeds && !has_attachments && !has_components { 135 - return Err(JsErrorBox::generic( 136 - "Webhook must include content, embeds, attachments, or components", 137 - )); 138 + return Err(FloraError::invalid_input( 139 + "payload", 140 + "webhook must include content, embeds, attachments, or components", 141 + ) 142 + .into()); 138 143 } 139 144 140 - let result = if with_components { 141 - http.execute_webhook_with_components( 142 - webhook_id, 143 - thread_id, 144 - &args.token, 145 - wait, 146 - files, 147 - &message, 148 - ) 149 - .await 150 - } else { 151 - http.execute_webhook(webhook_id, thread_id, &args.token, wait, files, &message) 152 - .await 153 - } 154 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 145 + let token = args.token; 146 + let route = format!("POST /webhooks/{}", webhook_id.get()); 147 + let result = rest 148 + .execute(runtime_guild_id, route, RestRetry::None, move |http| { 149 + let token = token.clone(); 150 + let files = files.clone(); 151 + let message = message.clone(); 152 + async move { 153 + if with_components { 154 + http.execute_webhook_with_components( 155 + webhook_id, thread_id, &token, wait, files, &message, 156 + ) 157 + .await 158 + } else { 159 + http.execute_webhook(webhook_id, thread_id, &token, wait, files, &message) 160 + .await 161 + } 162 + } 163 + }) 164 + .await?; 155 165 156 166 match result { 157 167 Some(message) => Ok(Some( ··· 180 190 state: Rc<RefCell<OpState>>, 181 191 #[serde] args: RawEditWebhook, 182 192 ) -> Result<serde_json::Value, JsErrorBox> { 183 - let http = { 193 + let rest = { 184 194 let state = state.borrow(); 185 - state.borrow::<Arc<Http>>().clone() 195 + state.borrow::<Arc<DiscordRest>>().clone() 186 196 }; 187 197 let webhook_id = parse_webhook_id(&args.webhook_id)?; 188 198 let runtime_guild_id = { 189 199 let state = state.borrow(); 190 200 runtime_guild_id_from_state(&state)? 191 201 }; 192 - ensure_webhook_scope(runtime_guild_id, &http, webhook_id).await?; 193 - let webhook = if let Some(token) = args.token { 194 - http.edit_webhook_with_token(webhook_id, &token, &args.payload, args.reason.as_deref()) 195 - .await 202 + ensure_webhook_scope(runtime_guild_id, rest.scope_cache(), webhook_id).await?; 203 + let token = args.token; 204 + let payload = args.payload; 205 + let reason = args.reason; 206 + let route = if token.is_some() { 207 + format!("PATCH /webhooks/{}/token", webhook_id.get()) 196 208 } else { 197 - http.edit_webhook(webhook_id, &args.payload, args.reason.as_deref()) 198 - .await 199 - } 200 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 209 + format!("PATCH /webhooks/{}", webhook_id.get()) 210 + }; 211 + let webhook = rest 212 + .execute(runtime_guild_id, route, RestRetry::None, move |http| { 213 + let token = token.clone(); 214 + let payload = payload.clone(); 215 + let reason = reason.clone(); 216 + async move { 217 + if let Some(token) = token { 218 + http.edit_webhook_with_token(webhook_id, &token, &payload, reason.as_deref()) 219 + .await 220 + } else { 221 + http.edit_webhook(webhook_id, &payload, reason.as_deref()) 222 + .await 223 + } 224 + } 225 + }) 226 + .await?; 201 227 serde_json::to_value(webhook).map_err(|err| JsErrorBox::generic(err.to_string())) 202 228 } 203 229 ··· 217 243 state: Rc<RefCell<OpState>>, 218 244 #[serde] args: RawDeleteWebhook, 219 245 ) -> Result<(), JsErrorBox> { 220 - let http = { 246 + let rest = { 221 247 let state = state.borrow(); 222 - state.borrow::<Arc<Http>>().clone() 248 + state.borrow::<Arc<DiscordRest>>().clone() 223 249 }; 224 250 let webhook_id = parse_webhook_id(&args.webhook_id)?; 225 251 let runtime_guild_id = { 226 252 let state = state.borrow(); 227 253 runtime_guild_id_from_state(&state)? 228 254 }; 229 - ensure_webhook_scope(runtime_guild_id, &http, webhook_id).await?; 230 - if let Some(token) = args.token { 231 - http.delete_webhook_with_token(webhook_id, &token, args.reason.as_deref()) 232 - .await 233 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 255 + ensure_webhook_scope(runtime_guild_id, rest.scope_cache(), webhook_id).await?; 256 + let token = args.token; 257 + let reason = args.reason; 258 + let route = if token.is_some() { 259 + format!("DELETE /webhooks/{}/token", webhook_id.get()) 234 260 } else { 235 - http.delete_webhook(webhook_id, args.reason.as_deref()) 236 - .await 237 - .map_err(|err| JsErrorBox::generic(err.to_string()))?; 238 - } 261 + format!("DELETE /webhooks/{}", webhook_id.get()) 262 + }; 263 + rest.execute(runtime_guild_id, route, RestRetry::None, move |http| { 264 + let token = token.clone(); 265 + let reason = reason.clone(); 266 + async move { 267 + if let Some(token) = token { 268 + http.delete_webhook_with_token(webhook_id, &token, reason.as_deref()) 269 + .await 270 + } else { 271 + http.delete_webhook(webhook_id, reason.as_deref()).await 272 + } 273 + } 274 + }) 275 + .await?; 239 276 Ok(()) 240 277 } 241 278 242 - fn parse_webhook_id(value: &str) -> Result<WebhookId, JsErrorBox> { 243 - value 244 - .parse::<u64>() 245 - .map(WebhookId::new) 246 - .map_err(|_| JsErrorBox::generic("Invalid webhook id")) 279 + fn parse_webhook_id(value: &str) -> Result<WebhookId, FloraError> { 280 + let Ok(id) = value.parse::<u64>() else { 281 + return Err(FloraError::invalid_input("webhook_id", "invalid snowflake")); 282 + }; 283 + Ok(WebhookId::new(id)) 247 284 } 248 285 249 - fn parse_thread_id(value: &str) -> Result<ThreadId, JsErrorBox> { 250 - value 251 - .parse::<u64>() 252 - .map(ThreadId::new) 253 - .map_err(|_| JsErrorBox::generic("Invalid thread id")) 286 + fn parse_thread_id(value: &str) -> Result<ThreadId, FloraError> { 287 + let Ok(id) = value.parse::<u64>() else { 288 + return Err(FloraError::invalid_input("thread_id", "invalid snowflake")); 289 + }; 290 + Ok(ThreadId::new(id)) 254 291 }
+3 -4
apps/runtime/src/runtime/js.rs
··· 8 8 metrics::metrics, 9 9 ops, 10 10 ops::{SharedCronRegistry, interaction::CommandHashCache}, 11 - services::{kv::KvService, secrets::SecretsRuntimeData}, 11 + services::{discord_rest::DiscordRest, kv::KvService, secrets::SecretsRuntimeData}, 12 12 }; 13 13 use deno_core::{ 14 14 Extension, ExtensionFileSource, FastString, FsModuleLoader, JsRuntime, ModuleName, ··· 19 19 use deno_permissions::{ 20 20 Permissions, PermissionsContainer, PermissionsOptions, RuntimePermissionDescriptorParser, 21 21 }; 22 - use serenity::http::Http; 23 22 use std::{borrow::Cow, future::Future, path::PathBuf, rc::Rc, sync::Arc, time::Duration}; 24 23 use sys_traits::impls::RealSys; 25 24 use tokio::time::timeout; ··· 205 204 } 206 205 207 206 pub(super) fn new_js_runtime( 208 - http: Arc<Http>, 207 + rest: Arc<DiscordRest>, 209 208 kv: KvService, 210 209 secrets: Arc<SecretsRuntimeData>, 211 210 guild_id: Option<String>, ··· 240 239 deno_net::deno_net::init(None, None), 241 240 deno_tls::deno_tls::init(), 242 241 bootstrap_extension(), 243 - ops::extension(http, kv.clone(), cron_registry), 242 + ops::extension(rest, kv.clone(), cron_registry), 244 243 ], 245 244 extension_transpiler: Some(Rc::new(|specifier, source| { 246 245 match crate::transpile::transpile_if_typescript(&specifier, source.as_str())? {
+25 -2
apps/runtime/src/runtime/mod.rs
··· 10 10 11 11 use crate::{ 12 12 metrics::metrics, 13 - services::{deployments::Deployment, kv::KvService, secrets::SecretService}, 13 + services::{ 14 + deployments::Deployment, 15 + discord_rest::{DiscordRest, RestConfig}, 16 + kv::KvService, 17 + scope_cache::ScopeCache, 18 + secrets::SecretService, 19 + }, 14 20 }; 15 21 use constants::{DROPPABLE_EVENTS, MAX_DROPPABLE_BACKLOG, MAX_WORKERS_LIMIT}; 16 22 use deno_core::error::AnyError; ··· 23 29 hash::{Hash, Hasher}, 24 30 path::PathBuf, 25 31 sync::{Arc, atomic::Ordering}, 32 + time::Duration, 26 33 }; 27 34 use tokio::{runtime::Builder, sync::Mutex}; 28 35 use tracing::{error, info}; ··· 34 41 pub struct BotRuntime { 35 42 workers: Vec<Worker>, 36 43 num_workers: usize, 44 + rest: Arc<DiscordRest>, 37 45 secrets: Arc<SecretService>, 38 46 guild_routes: Arc<parking_lot::Mutex<HashMap<String, usize>>>, 39 47 migration_queues: Arc<Mutex<HashMap<String, Vec<QueuedGuildEvent>>>>, ··· 46 54 kv: KvService, 47 55 secrets: SecretService, 48 56 config: RuntimeConfig, 57 + cache_pool: fred::prelude::Pool, 49 58 ) -> Self { 50 59 let num_workers = config.max_workers.clamp(1, MAX_WORKERS_LIMIT); 51 60 let queue_capacity = config.worker_queue_capacity.max(1); 52 61 info!(target: "flora:runtime", num_workers, queue_capacity, "spawning worker pool"); 53 62 let limits = RuntimeLimits::from_config(&config); 63 + let scope_cache = ScopeCache::new(http.clone(), cache_pool); 64 + let rest = Arc::new(DiscordRest::new( 65 + http, 66 + scope_cache, 67 + RestConfig { 68 + max_wait: Duration::from_millis(config.rest_timeout_ms), 69 + guild_concurrency: config.guild_concurrency.max(1), 70 + }, 71 + )); 54 72 55 73 let workers: Vec<Worker> = (0..num_workers) 56 74 .map(|id| { 57 75 spawn_worker( 58 76 id, 59 - http.clone(), 77 + rest.clone(), 60 78 kv.clone(), 61 79 secrets.clone(), 62 80 limits, ··· 68 86 Self { 69 87 workers, 70 88 num_workers, 89 + rest, 71 90 secrets: Arc::new(secrets), 72 91 guild_routes: Arc::new(parking_lot::Mutex::new(HashMap::new())), 73 92 migration_queues: Arc::new(Mutex::new(HashMap::new())), 74 93 } 94 + } 95 + 96 + pub fn discord_rest(&self) -> Arc<DiscordRest> { 97 + self.rest.clone() 75 98 } 76 99 77 100 /// Initialize all workers (creates default runtimes).
+20 -2
apps/runtime/src/runtime/tests.rs
··· 5 5 }; 6 6 use crate::{ 7 7 ops::CronRegistry, 8 - services::{deployments::Deployment, kv::KvService, secrets::SecretService}, 8 + services::{ 9 + deployments::Deployment, 10 + discord_rest::{DiscordRest, RestConfig}, 11 + kv::KvService, 12 + scope_cache::ScopeCache, 13 + secrets::SecretService, 14 + }, 9 15 }; 10 16 use chrono::Utc; 17 + use fred::prelude::Builder; 11 18 use parking_lot::Mutex; 12 19 use serde_json::json; 13 20 use serenity::{http::Http, secrets::Token}; ··· 113 120 KvService::new(pool, kv_path) 114 121 } 115 122 123 + fn test_discord_rest(http: Arc<Http>) -> Arc<DiscordRest> { 124 + let cache_config = 125 + fred::types::config::Config::from_url("redis://localhost:5434").expect("parse cache url"); 126 + let cache_pool = Builder::from_config(cache_config) 127 + .build_pool(1) 128 + .expect("create cache pool"); 129 + let scope_cache = ScopeCache::new(http.clone(), cache_pool); 130 + Arc::new(DiscordRest::new(http, scope_cache, RestConfig::default())) 131 + } 132 + 116 133 fn make_deployment(iteration: usize) -> Deployment { 117 134 let bundle = format!( 118 135 "globalThis.__floraDispatch = (event, payload) => {{ globalThis.__last = payload; return payload; }};\n// iteration {iteration}" ··· 149 166 let http = Arc::new(Http::new( 150 167 Token::try_from("Bot stress.test.token").expect("token"), 151 168 )); 169 + let rest = test_discord_rest(http); 152 170 let kv = test_kv_service(&database_url).await; 153 171 let secrets = SecretService::new_for_tests(&database_url).await; 154 172 let limits = test_limits(); ··· 159 177 let deployment = make_deployment(iteration); 160 178 deploy_guild_to_worker( 161 179 &mut guild_runtimes, 162 - &http, 180 + &rest, 163 181 &kv, 164 182 &secrets, 165 183 deployment,
+5 -5
apps/runtime/src/runtime/worker.rs
··· 16 16 ops::{CronRegistry, SharedCronRegistry}, 17 17 services::{ 18 18 deployments::Deployment, 19 + discord_rest::DiscordRest, 19 20 kv::KvService, 20 21 secrets::{SecretService, SecretsRuntimeData}, 21 22 }, ··· 27 28 v8::{self, Global}, 28 29 }; 29 30 use serde_json::Value; 30 - use serenity::http::Http; 31 31 use std::{ 32 32 collections::HashMap, 33 33 path::PathBuf, ··· 43 43 44 44 pub(super) fn spawn_worker( 45 45 id: usize, 46 - http: Arc<Http>, 46 + http: Arc<DiscordRest>, 47 47 kv: KvService, 48 48 secrets: SecretService, 49 49 limits: RuntimeLimits, ··· 86 86 fn worker_thread( 87 87 worker_id: usize, 88 88 mut receiver: mpsc::Receiver<WorkerCommand>, 89 - http: Arc<Http>, 89 + http: Arc<DiscordRest>, 90 90 kv: KvService, 91 91 secrets: SecretService, 92 92 limits: RuntimeLimits, ··· 438 438 439 439 async fn initialize_worker_default( 440 440 default_runtime: &mut Option<JsRuntimeState>, 441 - http: &Arc<Http>, 441 + http: &Arc<DiscordRest>, 442 442 kv: &KvService, 443 443 secrets: Arc<SecretsRuntimeData>, 444 444 worker_id: usize, ··· 474 474 #[allow(clippy::too_many_arguments)] 475 475 pub(super) async fn deploy_guild_to_worker( 476 476 guild_runtimes: &mut HashMap<String, JsRuntimeState>, 477 - http: &Arc<Http>, 477 + http: &Arc<DiscordRest>, 478 478 kv: &KvService, 479 479 secrets: &SecretService, 480 480 deployment: Deployment,
+186
apps/runtime/src/services/discord_rest.rs
··· 1 + use dashmap::{DashMap, mapref::entry::Entry}; 2 + use rand::Rng; 3 + use serenity::{Error as SerenityError, http::Http, model::id::GuildId}; 4 + use std::{ 5 + future::Future, 6 + sync::Arc, 7 + time::{Duration, Instant}, 8 + }; 9 + use tokio::{ 10 + sync::Semaphore, 11 + time::{sleep, timeout}, 12 + }; 13 + use tracing::{debug, info}; 14 + 15 + use crate::{ops::FloraError, services::scope_cache::ScopeCache}; 16 + 17 + pub struct DiscordRest { 18 + http: Arc<Http>, 19 + scope_cache: ScopeCache, 20 + guild_semaphores: DashMap<GuildId, Arc<Semaphore>>, 21 + config: RestConfig, 22 + } 23 + 24 + #[derive(Clone, Copy)] 25 + pub(crate) struct RestConfig { 26 + pub max_wait: Duration, 27 + pub guild_concurrency: usize, 28 + } 29 + 30 + impl Default for RestConfig { 31 + fn default() -> Self { 32 + Self { 33 + max_wait: Duration::from_secs(8), 34 + guild_concurrency: 4, 35 + } 36 + } 37 + } 38 + 39 + #[derive(Clone, Copy)] 40 + pub(crate) enum RestRetry { 41 + None, 42 + ReadOnly, 43 + } 44 + 45 + impl RestRetry { 46 + fn max_retries(self) -> usize { 47 + match self { 48 + Self::None => 0, 49 + Self::ReadOnly => 2, 50 + } 51 + } 52 + } 53 + 54 + impl DiscordRest { 55 + pub(crate) fn new(http: Arc<Http>, scope_cache: ScopeCache, config: RestConfig) -> Self { 56 + Self { 57 + http, 58 + scope_cache, 59 + guild_semaphores: DashMap::new(), 60 + config, 61 + } 62 + } 63 + 64 + pub(crate) fn scope_cache(&self) -> &ScopeCache { 65 + &self.scope_cache 66 + } 67 + 68 + pub(crate) fn http(&self) -> &Arc<Http> { 69 + &self.http 70 + } 71 + 72 + pub(crate) async fn execute<T, Fut, F>( 73 + &self, 74 + guild_id: GuildId, 75 + route: impl Into<String>, 76 + retry: RestRetry, 77 + request: F, 78 + ) -> Result<T, FloraError> 79 + where 80 + F: Fn(Arc<Http>) -> Fut, 81 + Fut: Future<Output = Result<T, SerenityError>>, 82 + { 83 + let route = route.into(); 84 + let semaphore = self.semaphore_for_guild(guild_id); 85 + let available = semaphore.available_permits(); 86 + if available == 0 { 87 + debug!(target: "flora:rest", event = "rejected", guild_id = guild_id.get(), route = route.as_str()); 88 + } else { 89 + debug!(target: "flora:rest", event = "admitted", guild_id = guild_id.get(), route = route.as_str(), available); 90 + } 91 + 92 + let acquire_started = Instant::now(); 93 + let permit = semaphore 94 + .acquire_owned() 95 + .await 96 + .map_err(|_| FloraError::discord_http(503, 0, "rest concurrency semaphore closed"))?; 97 + let wait_ms = acquire_started.elapsed().as_millis() as u64; 98 + debug!(target: "flora:rest", event = "acquired", guild_id = guild_id.get(), route = route.as_str(), wait_ms); 99 + let _permit = permit; 100 + 101 + let mut retries = 0usize; 102 + loop { 103 + let outcome = timeout(self.config.max_wait, request(self.http.clone())).await; 104 + let outcome = match outcome { 105 + Ok(result) => result, 106 + Err(_) => { 107 + info!(target: "flora:rest", event = "timeout", guild_id = guild_id.get(), route = route.as_str()); 108 + return Err(FloraError::rate_limited( 109 + self.config.max_wait.as_millis() as u64, 110 + false, 111 + route, 112 + )); 113 + } 114 + }; 115 + 116 + match outcome { 117 + Ok(value) => return Ok(value), 118 + Err(err) => { 119 + if !should_retry(&err, retry) { 120 + return Err(FloraError::from_serenity_error(err, Some(&route))); 121 + } 122 + if retries >= retry.max_retries() { 123 + return Err(FloraError::from_serenity_error(err, Some(&route))); 124 + } 125 + let delay = backoff_delay(retries); 126 + let attempt = retries + 1; 127 + info!( 128 + target: "flora:rest", 129 + event = "retry", 130 + guild_id = guild_id.get(), 131 + route = route.as_str(), 132 + attempt, 133 + delay_ms = delay.as_millis() as u64, 134 + ?err 135 + ); 136 + retries += 1; 137 + sleep(delay).await; 138 + } 139 + } 140 + } 141 + } 142 + 143 + pub(crate) fn semaphore_for_guild(&self, guild_id: GuildId) -> Arc<Semaphore> { 144 + match self.guild_semaphores.entry(guild_id) { 145 + Entry::Occupied(entry) => entry.get().clone(), 146 + Entry::Vacant(entry) => entry 147 + .insert(Arc::new(Semaphore::new(self.config.guild_concurrency))) 148 + .clone(), 149 + } 150 + } 151 + } 152 + 153 + fn should_retry(error: &SerenityError, retry: RestRetry) -> bool { 154 + match retry { 155 + RestRetry::None => false, 156 + RestRetry::ReadOnly => match error { 157 + SerenityError::Http(http_error) => match http_error { 158 + serenity::http::HttpError::UnsuccessfulRequest(response) => { 159 + response.status_code.is_server_error() 160 + } 161 + serenity::http::HttpError::Request(_) => true, 162 + serenity::http::HttpError::RateLimitI64F64 => false, 163 + serenity::http::HttpError::RateLimitUtf8 => false, 164 + serenity::http::HttpError::InvalidWebhook => false, 165 + serenity::http::HttpError::InvalidHeader(_) => false, 166 + serenity::http::HttpError::ApplicationIdMissing => false, 167 + _ => false, 168 + }, 169 + SerenityError::Io(_) => true, 170 + SerenityError::Json(_) => false, 171 + SerenityError::Model(_) => false, 172 + SerenityError::Token(_) => false, 173 + SerenityError::Url(_) => false, 174 + _ => false, 175 + }, 176 + } 177 + } 178 + 179 + fn backoff_delay(retries: usize) -> Duration { 180 + let base_ms = 250u64; 181 + let cap_ms = 2_000u64; 182 + let scale = 1u64.checked_shl(retries as u32).unwrap_or(u64::MAX); 183 + let max_ms = base_ms.saturating_mul(scale).min(cap_ms); 184 + let jitter = rand::thread_rng().gen_range(0..=max_ms); 185 + Duration::from_millis(jitter) 186 + }
+2
apps/runtime/src/services/mod.rs
··· 1 1 pub mod auth; 2 2 pub mod build; 3 3 pub mod deployments; 4 + pub mod discord_rest; 4 5 pub mod kv; 6 + pub mod scope_cache; 5 7 pub mod secrets; 6 8 pub mod tokens;
+273
apps/runtime/src/services/scope_cache.rs
··· 1 + use dashmap::DashMap; 2 + use fred::{prelude::*, types::Expiration}; 3 + use serenity::{ 4 + http::Http, 5 + model::{ 6 + channel::Channel, 7 + id::{ChannelId, GuildId, ThreadId, WebhookId}, 8 + }, 9 + }; 10 + use std::{ 11 + sync::Arc, 12 + time::{Duration, Instant}, 13 + }; 14 + use tracing::{info, warn}; 15 + 16 + use crate::ops::FloraError; 17 + 18 + const L1_TTL: Duration = Duration::from_secs(300); 19 + const L1_CAPACITY: usize = 10_000; 20 + const L1_EXPIRE_SCAN_LIMIT: usize = 256; 21 + const L2_TTL_SECS: i64 = 21_600; 22 + 23 + pub(crate) struct ScopeCache { 24 + l1: DashMap<u64, CacheEntry>, 25 + valkey: Pool, 26 + http: Arc<Http>, 27 + l1_ttl: Duration, 28 + l1_capacity: usize, 29 + } 30 + 31 + #[derive(Clone, Copy)] 32 + struct CacheEntry { 33 + guild_id: GuildId, 34 + expires_at: Instant, 35 + } 36 + 37 + impl ScopeCache { 38 + pub(crate) fn new(http: Arc<Http>, valkey: Pool) -> Self { 39 + Self { 40 + l1: DashMap::new(), 41 + valkey, 42 + http, 43 + l1_ttl: L1_TTL, 44 + l1_capacity: L1_CAPACITY, 45 + } 46 + } 47 + 48 + pub(crate) async fn resolve_channel( 49 + &self, 50 + channel_id: ChannelId, 51 + ) -> Result<Option<GuildId>, FloraError> { 52 + if let Some(guild_id) = self.l1_lookup(channel_id) { 53 + return Ok(Some(guild_id)); 54 + } 55 + 56 + match self.l2_lookup_channel(channel_id).await { 57 + Ok(Some(guild_id)) => { 58 + self.insert_l1(channel_id, guild_id); 59 + return Ok(Some(guild_id)); 60 + } 61 + Ok(None) => {} 62 + Err(err) => { 63 + warn!(target: "flora:scope_cache", channel_id = channel_id.get(), ?err, "failed to read channel scope from valkey"); 64 + } 65 + } 66 + 67 + let channel = self 68 + .http 69 + .get_channel(channel_id.widen()) 70 + .await 71 + .map_err(|err| FloraError::from_serenity_error(err, None))?; 72 + let guild_id = channel_guild_id(channel); 73 + let Some(guild_id) = guild_id else { 74 + return Ok(None); 75 + }; 76 + self.warm_channel(channel_id, guild_id).await; 77 + Ok(Some(guild_id)) 78 + } 79 + 80 + pub(crate) async fn resolve_webhook( 81 + &self, 82 + webhook_id: WebhookId, 83 + ) -> Result<Option<GuildId>, FloraError> { 84 + match self.l2_lookup_webhook(webhook_id).await { 85 + Ok(Some(guild_id)) => return Ok(Some(guild_id)), 86 + Ok(None) => {} 87 + Err(err) => { 88 + warn!(target: "flora:scope_cache", webhook_id = webhook_id.get(), ?err, "failed to read webhook scope from valkey"); 89 + } 90 + } 91 + 92 + let webhook = self 93 + .http 94 + .get_webhook(webhook_id) 95 + .await 96 + .map_err(|err| FloraError::from_serenity_error(err, None))?; 97 + let guild_id = webhook.guild_id; 98 + let Some(guild_id) = guild_id else { 99 + return Ok(None); 100 + }; 101 + self.store_webhook(webhook_id, guild_id).await; 102 + Ok(Some(guild_id)) 103 + } 104 + 105 + pub(crate) async fn warm_channel(&self, channel_id: ChannelId, guild_id: GuildId) { 106 + self.insert_l1(channel_id, guild_id); 107 + self.store_channel(channel_id, guild_id).await; 108 + } 109 + 110 + pub(crate) async fn invalidate_channel(&self, channel_id: ChannelId) { 111 + self.l1.remove(&channel_id.get()); 112 + let key = channel_key(channel_id); 113 + let result: Result<i64, _> = self.valkey.del(key).await; 114 + if let Err(err) = result { 115 + warn!(target: "flora:scope_cache", channel_id = channel_id.get(), ?err, "failed to invalidate channel scope in valkey"); 116 + } 117 + } 118 + 119 + pub(crate) async fn warm_thread(&self, thread_id: ThreadId, guild_id: GuildId) { 120 + self.warm_channel(ChannelId::new(thread_id.get()), guild_id) 121 + .await; 122 + } 123 + 124 + pub(crate) async fn invalidate_thread(&self, thread_id: ThreadId) { 125 + self.invalidate_channel(ChannelId::new(thread_id.get())) 126 + .await; 127 + } 128 + 129 + fn l1_lookup(&self, channel_id: ChannelId) -> Option<GuildId> { 130 + let entry = self.l1.get(&channel_id.get()); 131 + let Some(entry) = entry else { 132 + info!(target: "flora:scope_cache", layer = "l1", result = "miss", channel_id = channel_id.get()); 133 + return None; 134 + }; 135 + 136 + if entry.expires_at <= Instant::now() { 137 + drop(entry); 138 + self.l1.remove(&channel_id.get()); 139 + info!(target: "flora:scope_cache", layer = "l1", result = "miss", channel_id = channel_id.get()); 140 + return None; 141 + } 142 + 143 + let guild_id = entry.guild_id; 144 + info!(target: "flora:scope_cache", layer = "l1", result = "hit", channel_id = channel_id.get(), guild_id = guild_id.get()); 145 + Some(guild_id) 146 + } 147 + 148 + async fn l2_lookup_channel( 149 + &self, 150 + channel_id: ChannelId, 151 + ) -> Result<Option<GuildId>, fred::error::Error> { 152 + let key = channel_key(channel_id); 153 + let value: Option<String> = self.valkey.get(key).await?; 154 + let Some(value) = value else { 155 + info!(target: "flora:scope_cache", layer = "l2", result = "miss", channel_id = channel_id.get()); 156 + return Ok(None); 157 + }; 158 + let Ok(raw_id) = value.parse::<u64>() else { 159 + warn!(target: "flora:scope_cache", channel_id = channel_id.get(), value, "invalid guild id in valkey scope cache"); 160 + return Ok(None); 161 + }; 162 + let guild_id = GuildId::new(raw_id); 163 + info!(target: "flora:scope_cache", layer = "l2", result = "hit", channel_id = channel_id.get(), guild_id = guild_id.get()); 164 + Ok(Some(guild_id)) 165 + } 166 + 167 + async fn l2_lookup_webhook( 168 + &self, 169 + webhook_id: WebhookId, 170 + ) -> Result<Option<GuildId>, fred::error::Error> { 171 + let key = webhook_key(webhook_id); 172 + let value: Option<String> = self.valkey.get(key).await?; 173 + let Some(value) = value else { 174 + info!(target: "flora:scope_cache", layer = "l2", result = "miss", webhook_id = webhook_id.get()); 175 + return Ok(None); 176 + }; 177 + let Ok(raw_id) = value.parse::<u64>() else { 178 + warn!(target: "flora:scope_cache", webhook_id = webhook_id.get(), value, "invalid guild id in valkey scope cache"); 179 + return Ok(None); 180 + }; 181 + let guild_id = GuildId::new(raw_id); 182 + info!(target: "flora:scope_cache", layer = "l2", result = "hit", webhook_id = webhook_id.get(), guild_id = guild_id.get()); 183 + Ok(Some(guild_id)) 184 + } 185 + 186 + fn insert_l1(&self, channel_id: ChannelId, guild_id: GuildId) { 187 + let entry = CacheEntry { 188 + guild_id, 189 + expires_at: Instant::now() + self.l1_ttl, 190 + }; 191 + self.l1.insert(channel_id.get(), entry); 192 + self.enforce_l1_cap(); 193 + } 194 + 195 + fn enforce_l1_cap(&self) { 196 + let mut len = self.l1.len(); 197 + if len <= self.l1_capacity { 198 + return; 199 + } 200 + 201 + let now = Instant::now(); 202 + let mut expired_keys = Vec::new(); 203 + let mut scanned = 0usize; 204 + for entry in self.l1.iter() { 205 + if scanned >= L1_EXPIRE_SCAN_LIMIT { 206 + break; 207 + } 208 + scanned += 1; 209 + if entry.value().expires_at <= now { 210 + expired_keys.push(*entry.key()); 211 + } 212 + } 213 + 214 + for key in expired_keys { 215 + self.l1.remove(&key); 216 + } 217 + 218 + len = self.l1.len(); 219 + if len <= self.l1_capacity { 220 + return; 221 + } 222 + 223 + let mut to_remove = Vec::new(); 224 + let mut excess = len - self.l1_capacity; 225 + for entry in self.l1.iter() { 226 + to_remove.push(*entry.key()); 227 + excess -= 1; 228 + if excess == 0 { 229 + break; 230 + } 231 + } 232 + 233 + for key in to_remove { 234 + self.l1.remove(&key); 235 + } 236 + } 237 + 238 + async fn store_channel(&self, channel_id: ChannelId, guild_id: GuildId) { 239 + let key = channel_key(channel_id); 240 + let value = guild_id.get().to_string(); 241 + let result = self 242 + .valkey 243 + .set::<(), _, _>(key, value, Some(Expiration::EX(L2_TTL_SECS)), None, false) 244 + .await; 245 + if let Err(err) = result { 246 + warn!(target: "flora:scope_cache", channel_id = channel_id.get(), guild_id = guild_id.get(), ?err, "failed to write channel scope to valkey"); 247 + } 248 + } 249 + 250 + async fn store_webhook(&self, webhook_id: WebhookId, guild_id: GuildId) { 251 + let key = webhook_key(webhook_id); 252 + let value = guild_id.get().to_string(); 253 + let result = self 254 + .valkey 255 + .set::<(), _, _>(key, value, Some(Expiration::EX(L2_TTL_SECS)), None, false) 256 + .await; 257 + if let Err(err) = result { 258 + warn!(target: "flora:scope_cache", webhook_id = webhook_id.get(), guild_id = guild_id.get(), ?err, "failed to write webhook scope to valkey"); 259 + } 260 + } 261 + } 262 + 263 + fn channel_key(channel_id: ChannelId) -> String { 264 + format!("flora:scope:ch:{}", channel_id.get()) 265 + } 266 + 267 + fn webhook_key(webhook_id: WebhookId) -> String { 268 + format!("flora:scope:wh:{}", webhook_id.get()) 269 + } 270 + 271 + fn channel_guild_id(channel: Channel) -> Option<GuildId> { 272 + channel.guild_id() 273 + }
+16
config.template.toml
··· 119 119 # Default value: 3 120 120 # dispatch_timeout_secs = 3 121 121 122 + # Timeout in milliseconds for Discord REST requests. 123 + # Default: 8000 124 + # 125 + # Can also be specified via environment variable `RUNTIME_REST_TIMEOUT_MS`. 126 + # 127 + # Default value: 8000 128 + # rest_timeout_ms = 8000 129 + 130 + # Maximum number of concurrent Discord REST requests per guild. 131 + # Default: 4 132 + # 133 + # Can also be specified via environment variable `RUNTIME_GUILD_CONCURRENCY`. 134 + # 135 + # Default value: 4 136 + # guild_concurrency = 4 137 + 122 138 # Max script size in bytes (SDK + deployment). Default: 8MB. 123 139 # 124 140 # Can also be specified via environment variable `RUNTIME_MAX_SCRIPT_BYTES`.
+8
crates/flora_config/src/lib.rs
··· 97 97 /// Timeout in seconds for per-event dispatch (0 disables). 98 98 #[config(env = "RUNTIME_DISPATCH_TIMEOUT_SECS", default = 3)] 99 99 pub dispatch_timeout_secs: u64, 100 + /// Timeout in milliseconds for Discord REST requests. 101 + /// Default: 8000 102 + #[config(env = "RUNTIME_REST_TIMEOUT_MS", default = 8_000)] 103 + pub rest_timeout_ms: u64, 104 + /// Maximum number of concurrent Discord REST requests per guild. 105 + /// Default: 4 106 + #[config(env = "RUNTIME_GUILD_CONCURRENCY", default = 4)] 107 + pub guild_concurrency: usize, 100 108 /// Max script size in bytes (SDK + deployment). Default: 8MB. 101 109 #[config(env = "RUNTIME_MAX_SCRIPT_BYTES", default = 8_388_608)] 102 110 pub max_script_bytes: usize,
+1
crates/flora_typegen/src/main.rs
··· 78 78 RawEmbedField, 79 79 RawEmbed, 80 80 RawAllowedMentions, 81 + // RawSendMessage emits SendMessageOptions via #[t0x(as_name = "...")]. 81 82 RawSendMessage, 82 83 RawEditMessage, 83 84 RawDeleteMessage,
+11 -29
runtime-dist/runtime_prelude.js
··· 7 7 } 8 8 } 9 9 globalThis.on = function on(event, handler) { 10 - if (!globalThis.__floraHandlers[event]) { 11 - globalThis.__floraHandlers[event] = [] 12 - } 10 + if (!globalThis.__floraHandlers[event]) globalThis.__floraHandlers[event] = [] 13 11 globalThis.__floraHandlers[event].push(handler) 14 12 } 15 13 globalThis.__floraDispatch = async function __floraDispatch(event, payload) { 16 14 const handlers = globalThis.__floraHandlers[event] || [] 17 15 for (const handler of handlers) { 18 - const context = { 16 + await handler({ 19 17 msg: payload, 20 18 reply(message) { 21 19 const options = normalizeReply(message, payload) ··· 28 26 const options = normalizeEdit(message, payload) 29 27 return core.ops.op_edit_message(options) 30 28 } 31 - } 32 - await handler(context) 29 + }) 33 30 } 34 31 } 35 32 globalThis.console = { log: (...args) => core.ops.op_log(args) } ··· 48 45 if (typeof cronExpr !== 'string' || !cronExpr.length) { 49 46 throw new TypeError('cron expression must be a non-empty string') 50 47 } 51 - if (typeof handler !== 'function') { 52 - throw new TypeError('cron handler must be a function') 53 - } 48 + if (typeof handler !== 'function') throw new TypeError('cron handler must be a function') 54 49 const eventName = CRON_EVENT_PREFIX + name 55 - if (!globalThis.__floraHandlers[eventName]) { 56 - globalThis.__floraHandlers[eventName] = [] 57 - } 50 + if (!globalThis.__floraHandlers[eventName]) globalThis.__floraHandlers[eventName] = [] 58 51 globalThis.__floraHandlers[eventName].push(handler) 59 52 core.ops.op_register_cron({ 60 53 name, ··· 63 56 }) 64 57 } 65 58 function normalizeReply(message, payload) { 66 - if (payload?.interactionToken) { 67 - return normalizeInteractionReply(message, payload) 68 - } 59 + if (payload?.interactionToken) return normalizeInteractionReply(message, payload) 69 60 const base = { channelId: payload.channelId } 70 61 const replyId = payload.id ?? payload.messageId 71 62 if (typeof message === 'string') { ··· 81 72 ...message 82 73 } 83 74 const explicitReplyTo = message.replyTo ?? message.replyTo 84 - if (explicitReplyTo === null) { 85 - delete normalized.messageId 86 - } else if (explicitReplyTo !== undefined) { 87 - normalized.messageId = explicitReplyTo 88 - } else if (replyId) { 89 - normalized.messageId = replyId 90 - } 75 + if (explicitReplyTo === null) delete normalized.messageId 76 + else if (explicitReplyTo !== void 0) normalized.messageId = explicitReplyTo 77 + else if (replyId) normalized.messageId = replyId 91 78 delete normalized.replyTo 92 79 delete normalized.reply_to 93 80 return normalized ··· 100 87 } 101 88 function normalizeEdit(message, payload) { 102 89 const messageId = payload.id ?? payload.messageId 103 - if (!messageId || !payload?.channelId) { 104 - throw new Error('Message edit requires a message payload') 105 - } 90 + if (!messageId || !payload?.channelId) throw new Error('Message edit requires a message payload') 106 91 const base = { 107 92 channelId: payload.channelId, 108 93 messageId ··· 140 125 ...base, 141 126 ...message 142 127 } 143 - if (message.ephemeral !== undefined) { 144 - normalized.ephemeral = message.ephemeral 145 - } 128 + if (message.ephemeral !== void 0) normalized.ephemeral = message.ephemeral 146 129 return normalized 147 130 } 148 131 return { ··· 150 133 content: String(message) 151 134 } 152 135 } 153 - 154 136 // #endregion
+38 -41
runtime-dist/runtime_sdk_bundle.js
··· 1 1 var flora = (function(exports) { 2 2 Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }) 3 - 4 3 // #region src/sdk/commands.ts 5 4 function prefix(command) { 6 5 return command ··· 8 7 function slash(command) { 9 8 return command 10 9 } 10 + function getCreateBotState() { 11 + const state = globalThis.__floraCreateBotState 12 + if (state) return state 13 + const initialState = { initialized: false } 14 + globalThis.__floraCreateBotState = initialState 15 + return initialState 16 + } 11 17 function createBot(options) { 18 + const state = getCreateBotState() 19 + if (state.initialized) { 20 + console.log('[flora/sdk] createBot called multiple times; skipping duplicate registration') 21 + return 22 + } 23 + state.initialized = true 12 24 const prefix = options.prefix ?? '!' 13 25 const commands = options.commands ?? options.prefixCommands ?? [] 14 26 const slashCommands = options.slashCommands ?? [] ··· 17 29 if (ctx.msg.author?.bot) return 18 30 const content = ctx.msg.content.trim() 19 31 if (!content.startsWith(prefix)) return 20 - const body = content.slice(prefix.length).trim() 21 - const [commandName, ...args] = body.split(/\s+/) 32 + const [commandName, ...args] = content.slice(prefix.length).trim().split(/\s+/) 22 33 const command = commands.find((cmd) => cmd.name === commandName) 23 34 if (!command) return 24 35 await command.run({ ··· 76 87 } 77 88 async function handleSubcommand(ctx, command) { 78 89 const rawData = ctx.msg.data 79 - if (!rawData?.options || !Array.isArray(rawData.options)) { 80 - return 81 - } 90 + if (!rawData?.options || !Array.isArray(rawData.options)) return 82 91 const firstOption = rawData.options[0] 83 92 if (!firstOption) return 84 93 const subcommandName = firstOption.name ··· 86 95 if (!subcommandMap) return 87 96 const subcommandHandler = subcommandMap[subcommandName] 88 97 if (!subcommandHandler) return 89 - const subcommandOptions = firstOption.options || [] 90 - const flatOptions = flattenInteractionOptions(subcommandOptions) 91 - const enrichedCtx = { 98 + const flatOptions = flattenInteractionOptions(firstOption.options || []) 99 + await subcommandHandler({ 92 100 ...ctx, 93 101 options: flatOptions 94 - } 95 - await subcommandHandler(enrichedCtx) 102 + }) 96 103 } 97 104 function flattenInteractionOptions(options) { 98 105 const result = {} 99 106 for (const opt of options) { 100 107 if (opt.type === 1 || opt.type === 2) { 101 108 Object.assign(result, flattenInteractionOptions(opt.options || [])) 102 - } else { 103 - result[opt.name] = opt.value 104 - } 109 + } else result[opt.name] = opt.value 105 110 } 106 111 return result 107 112 } 108 - 109 113 // #endregion 110 114 // #region src/generated.ts 111 115 const ButtonStyle = { ··· 151 155 IS_VOICE_MESSAGE: 8192, 152 156 IS_COMPONENTS_V2: 32768 153 157 } 154 - 155 158 // #endregion 156 159 // #region src/sdk/components.ts 157 160 const isBuilder = (value) => typeof value?.toJSON === 'function' ··· 518 521 type: ComponentType.Container, 519 522 components: this.#components.map(resolveComponent) 520 523 } 521 - if (this.#accentColor !== undefined) data.accent_color = this.#accentColor 522 - if (this.#spoiler !== undefined) data.spoiler = this.#spoiler 524 + if (this.#accentColor !== void 0) data.accent_color = this.#accentColor 525 + if (this.#spoiler !== void 0) data.spoiler = this.#spoiler 523 526 return data 524 527 } 525 528 } ··· 548 551 label: this.#label, 549 552 component: this.#component ? resolveComponent(this.#component) : null 550 553 } 551 - if (this.#description !== undefined) data.description = this.#description 554 + if (this.#description !== void 0) data.description = this.#description 552 555 return data 553 556 } 554 557 } ··· 599 602 const fileUpload = (customId) => new FileUploadBuilder(customId) 600 603 const ButtonStyles = ButtonStyle 601 604 const InputTextStyles = InputTextStyle 602 - 603 605 // #endregion 604 606 // #region src/sdk/embed.ts 605 607 var EmbedBuilder = class { ··· 673 675 function embed(initial) { 674 676 return new EmbedBuilder(initial) 675 677 } 676 - 677 678 // #endregion 678 679 // #region src/sdk/helpers.ts 679 680 function hasRole(ctx, roleId) { ··· 681 682 } 682 683 function getSubcommand(ctx) { 683 684 const rawData = ctx.msg.data 684 - if (!rawData?.options || !Array.isArray(rawData.options)) return undefined 685 + if (!rawData?.options || !Array.isArray(rawData.options)) return void 0 685 686 return rawData.options[0]?.name 686 687 } 687 688 function getSubcommandGroup(ctx) { 688 689 const rawData = ctx.msg.data 689 - if (!rawData?.options || !Array.isArray(rawData.options)) return undefined 690 + if (!rawData?.options || !Array.isArray(rawData.options)) return void 0 690 691 const firstOption = rawData.options[0] 691 - if (!firstOption) return undefined 692 - const type = firstOption.type 693 - if (type === 2) { 694 - return firstOption.name 695 - } 696 - return undefined 692 + if (!firstOption) return void 0 693 + if (firstOption.type === 2) return firstOption.name 697 694 } 698 - 699 695 // #endregion 700 696 // #region src/sdk/kv.ts 701 697 var KvStore = class { ··· 720 716 */ 721 717 async getWithMetadata(key) { 722 718 const result = await Deno.core.ops.op_kv_get_with_metadata(this.#storeName, key) 723 - if (result === null) { 724 - return { value: null } 725 - } 719 + if (result === null) return { value: null } 726 720 const [value, metadata] = result 727 721 return { 728 722 value, ··· 740 734 */ 741 735 async set(key, value, options) { 742 736 await Deno.core.ops.op_kv_set(this.#storeName, key, value, { 743 - expiration: options?.expiration ?? undefined, 744 - metadata: options?.metadata ?? undefined 737 + expiration: options?.expiration ?? void 0, 738 + metadata: options?.metadata ?? void 0 745 739 }) 746 740 } 747 741 /** ··· 751 745 * @param metadata - The metadata to set, or null to remove metadata 752 746 */ 753 747 async updateMetadata(key, metadata) { 754 - await Deno.core.ops.op_kv_update_metadata(this.#storeName, key, metadata ?? undefined) 748 + await Deno.core.ops.op_kv_update_metadata(this.#storeName, key, metadata ?? void 0) 755 749 } 756 750 /** 757 751 * Delete a key from the store. ··· 769 763 */ 770 764 async list(options) { 771 765 return await Deno.core.ops.op_kv_list_keys({ 772 - prefix: options?.prefix ?? undefined, 773 - limit: options?.limit ?? undefined, 774 - cursor: options?.cursor ?? undefined 766 + prefix: options?.prefix ?? void 0, 767 + limit: options?.limit ?? void 0, 768 + cursor: options?.cursor ?? void 0 775 769 }, this.#storeName) 776 770 } 777 771 } ··· 779 773 return new KvStore(name) 780 774 } 781 775 const kv = { store } 782 - 783 776 // #endregion 784 777 // #region src/sdk/rest.ts 785 778 const ops = Deno.core.ops 779 + /** 780 + * Lightweight REST bindings over core ops. 781 + * Errors include a `code` field (e.g. DISCORD_RATE_LIMITED). 782 + */ 786 783 const rest = { 787 784 sendMessage: (args) => ops.op_send_message(args), 788 785 editMessage: (args) => ops.op_edit_message(args), ··· 819 816 addMemberRole: (args) => ops.op_add_member_role(args), 820 817 removeMemberRole: (args) => ops.op_remove_member_role(args), 821 818 editMember: (args) => ops.op_edit_member(args), 819 + editCurrentMember: (args) => ops.op_edit_current_member(args), 822 820 createChannel: (args) => ops.op_create_channel(args), 823 821 editChannel: (args) => ops.op_edit_channel(args), 824 822 deleteChannel: (args) => ops.op_delete_channel(args), ··· 832 830 editWebhook: (args) => ops.op_edit_webhook(args), 833 831 deleteWebhook: (args) => ops.op_delete_webhook(args) 834 832 } 835 - 836 833 // #endregion 837 834 exports.ActionRowBuilder = ActionRowBuilder 838 835 exports.ButtonBuilder = ButtonBuilder
+39 -1
sdk/global-types.d.ts
··· 525 525 const kv: { store: (name: string) => KvStore } 526 526 527 527 const rest: { 528 - sendMessage: (args: RawSendMessage) => Promise<void> 528 + sendMessage: (args: RawSendMessage) => Promise<JsonValue> 529 529 editMessage: (args: RawEditMessage) => Promise<void> 530 530 deleteMessage: (args: RawDeleteMessage) => Promise<void> 531 531 bulkDeleteMessages: (args: RawBulkDeleteMessages) => Promise<void> ··· 955 955 replyTo?: string 956 956 } 957 957 958 + type SendMessageOptions = { 959 + content?: string | undefined 960 + embeds?: { 961 + title?: string 962 + description?: string 963 + url?: string 964 + color?: number 965 + timestamp?: string 966 + footer?: { text?: string; iconUrl?: string } 967 + image?: { url?: string } 968 + thumbnail?: { url?: string } 969 + author?: { name?: string; url?: string; iconUrl?: string } 970 + fields?: { name: string; value: string; inline: boolean }[] 971 + }[] | undefined 972 + attachments?: { url: { url: string; filename?: string; description?: string } } | { 973 + base64: { data: string; filename: string; description?: string } 974 + }[] | undefined 975 + components?: 976 + | (number | string | boolean | Array<JsonValue> | { [key in string]?: JsonValue } | null[]) 977 + | undefined 978 + tts?: boolean | undefined 979 + allowedMentions?: { 980 + parse?: string[] 981 + users?: string[] 982 + roles?: string[] 983 + repliedUser?: boolean 984 + } | undefined 985 + flags?: bigint | undefined 986 + messageId?: string | undefined 987 + replyTo?: string | undefined 988 + } 989 + 958 990 type RawEditMessage = { 959 991 channelId: string 960 992 messageId: string ··· 1256 1288 type RawEditMember = { 1257 1289 guildId: string 1258 1290 userId: string 1291 + payload: number | string | boolean | Array<JsonValue> | { [key in string]?: JsonValue } | null 1292 + reason?: string 1293 + } 1294 + 1295 + type RawEditCurrentMember = { 1296 + guildId: string 1259 1297 payload: number | string | boolean | Array<JsonValue> | { [key in string]?: JsonValue } | null 1260 1298 reason?: string 1261 1299 }
+3
sdk/src/generated.ts
··· 823 823 replyTo?: string 824 824 } 825 825 826 + /** Message options without routing fields. */ 827 + export type SendMessageOptions = Omit<RawSendMessage, 'channelId'> 828 + 826 829 /** Arguments for editing a message. */ 827 830 export type RawEditMessage = { 828 831 /** The channel containing the message. */
-1
sdk/src/index.register.test.ts
··· 3 3 4 4 describe('createBot slash registration', () => { 5 5 beforeEach(() => { 6 - // @ts-expect-error test-only reset 7 6 globalThis.__floraCreateBotState = undefined 8 7 }) 9 8
-1
sdk/src/index.test.ts
··· 4 4 5 5 describe('createBot slash commands', () => { 6 6 beforeEach(() => { 7 - // @ts-expect-error test-only reset 8 7 globalThis.__floraCreateBotState = undefined 9 8 }) 10 9
+5 -4
sdk/src/sdk/rest.ts
··· 43 43 RawUpsertGuildCommands 44 44 } from '../generated' 45 45 46 - // Lightweight REST bindings over core ops. 47 - // These map 1:1 to Rust ops and mostly accept the Raw* payloads. 48 - 49 46 declare const Deno: { 50 47 core: { 51 48 ops: any ··· 54 51 55 52 const ops = Deno.core.ops as any 56 53 54 + /** 55 + * Lightweight REST bindings over core ops. 56 + * Errors include a `code` field (e.g. DISCORD_RATE_LIMITED). 57 + */ 57 58 export const rest = { 58 - sendMessage: (args: RawSendMessage): Promise<void> => ops.op_send_message(args), 59 + sendMessage: (args: RawSendMessage): Promise<JsonValue> => ops.op_send_message(args), 59 60 editMessage: (args: RawEditMessage): Promise<void> => ops.op_edit_message(args), 60 61 deleteMessage: (args: RawDeleteMessage): Promise<void> => ops.op_delete_message(args), 61 62 bulkDeleteMessages: (args: RawBulkDeleteMessages): Promise<void> =>