this repo has no description
1
fork

Configure Feed

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

feat: relationshipdb

+1024 -27
+1
.gitignore
··· 10 10 packages.db 11 11 nixpkgs.hash 12 12 /starboard.db 13 + /relationship.db
+2 -1
src/commands/fun/genz.rs
··· 163 163 .await 164 164 .map_err(|e| eyre!("Failed to parse Kagi Translate response: {e}"))?; 165 165 166 - ctx.say(format!("> {}\n\"{}\"", input, body.translation)).await?; 166 + ctx.say(format!("> {}\n\"{}\"", input, body.translation)) 167 + .await?; 167 168 Ok(()) 168 169 }
+1
src/commands/user/mod.rs
··· 1 1 pub mod avatar; 2 2 pub mod color_me; 3 + pub mod relationship; 3 4 pub mod whois;
+79
src/commands/user/relationship/db.rs
··· 1 + use rusqlite::Connection; 2 + use std::sync::{LazyLock, Mutex}; 3 + 4 + pub static RELATIONSHIP_DB: LazyLock<Mutex<Connection>> = LazyLock::new(|| { 5 + let db_path = crate::utils::get_data_dir().join("relationship.db"); 6 + let conn = Connection::open(db_path).expect("Failed to open relationship database"); 7 + 8 + conn.execute( 9 + "CREATE TABLE IF NOT EXISTS relationships ( 10 + id INTEGER PRIMARY KEY AUTOINCREMENT, 11 + guild_id INTEGER NOT NULL, 12 + relationship_type TEXT NOT NULL, 13 + status TEXT NOT NULL CHECK(status IN ('active', 'ended')) DEFAULT 'active', 14 + created_by INTEGER NOT NULL, 15 + created_at INTEGER NOT NULL, 16 + ended_at INTEGER 17 + )", 18 + [], 19 + ) 20 + .expect("Failed to create relationships table"); 21 + 22 + conn.execute( 23 + "CREATE TABLE IF NOT EXISTS relationship_members ( 24 + relationship_id INTEGER NOT NULL, 25 + user_id INTEGER NOT NULL, 26 + joined_at INTEGER NOT NULL, 27 + left_at INTEGER, 28 + PRIMARY KEY (relationship_id, user_id), 29 + FOREIGN KEY (relationship_id) REFERENCES relationships(id) 30 + )", 31 + [], 32 + ) 33 + .expect("Failed to create relationship_members table"); 34 + 35 + conn.execute( 36 + "CREATE TABLE IF NOT EXISTS relationship_invites ( 37 + id INTEGER PRIMARY KEY AUTOINCREMENT, 38 + relationship_id INTEGER NOT NULL, 39 + inviter_id INTEGER NOT NULL, 40 + invitee_id INTEGER NOT NULL, 41 + status TEXT NOT NULL CHECK(status IN ('pending', 'accepted', 'declined', 'cancelled')) DEFAULT 'pending', 42 + created_at INTEGER NOT NULL, 43 + responded_at INTEGER, 44 + FOREIGN KEY (relationship_id) REFERENCES relationships(id) 45 + )", 46 + [], 47 + ) 48 + .expect("Failed to create relationship_invites table"); 49 + 50 + conn.execute( 51 + "CREATE INDEX IF NOT EXISTS idx_relationships_guild_type_status 52 + ON relationships (guild_id, relationship_type, status)", 53 + [], 54 + ) 55 + .expect("Failed to create relationships index"); 56 + 57 + conn.execute( 58 + "CREATE INDEX IF NOT EXISTS idx_members_user_active 59 + ON relationship_members (user_id, left_at)", 60 + [], 61 + ) 62 + .expect("Failed to create members index"); 63 + 64 + conn.execute( 65 + "CREATE INDEX IF NOT EXISTS idx_invites_invitee_status 66 + ON relationship_invites (invitee_id, status)", 67 + [], 68 + ) 69 + .expect("Failed to create invites invitee index"); 70 + 71 + conn.execute( 72 + "CREATE INDEX IF NOT EXISTS idx_invites_relationship_status 73 + ON relationship_invites (relationship_id, status)", 74 + [], 75 + ) 76 + .expect("Failed to create invites relationship index"); 77 + 78 + Mutex::new(conn) 79 + });
+467
src/commands/user/relationship/logic.rs
··· 1 + use color_eyre::eyre::Result; 2 + use rusqlite::{Connection, params}; 3 + 4 + #[derive(Debug)] 5 + pub struct MakeResolution { 6 + pub relationship_id: i64, 7 + pub created_new_group: bool, 8 + } 9 + 10 + pub fn normalize_relationship_type(raw: &str) -> Result<String> { 11 + let trimmed = raw.trim().to_lowercase(); 12 + if trimmed.is_empty() { 13 + return Err(color_eyre::eyre::eyre!( 14 + "Relationship type cannot be empty." 15 + )); 16 + } 17 + 18 + let collapsed = trimmed.split_whitespace().collect::<Vec<_>>().join("-"); 19 + if collapsed.len() < 2 || collapsed.len() > 32 { 20 + return Err(color_eyre::eyre::eyre!( 21 + "Relationship type must be between 2 and 32 characters." 22 + )); 23 + } 24 + 25 + if !collapsed 26 + .chars() 27 + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') 28 + { 29 + return Err(color_eyre::eyre::eyre!( 30 + "Relationship type can only contain letters, numbers, spaces, and dashes." 31 + )); 32 + } 33 + 34 + Ok(collapsed) 35 + } 36 + 37 + pub fn resolve_relationship_for_make( 38 + conn: &Connection, 39 + guild_id: u64, 40 + caller_id: u64, 41 + relationship_type: &str, 42 + explicit_relationship_id: Option<i64>, 43 + ) -> Result<std::result::Result<MakeResolution, String>> { 44 + if let Some(explicit_id) = explicit_relationship_id { 45 + let exists_and_allowed = conn 46 + .query_row( 47 + "SELECT 1 48 + FROM relationships r 49 + INNER JOIN relationship_members m ON m.relationship_id = r.id 50 + WHERE r.id = ? 51 + AND r.guild_id = ? 52 + AND r.relationship_type = ? 53 + AND r.status = 'active' 54 + AND m.user_id = ? 55 + AND m.left_at IS NULL", 56 + params![ 57 + explicit_id, 58 + guild_id.cast_signed(), 59 + relationship_type, 60 + caller_id.cast_signed(), 61 + ], 62 + |_| Ok(()), 63 + ) 64 + .ok() 65 + .is_some(); 66 + 67 + if exists_and_allowed { 68 + return Ok(Ok(MakeResolution { 69 + relationship_id: explicit_id, 70 + created_new_group: false, 71 + })); 72 + } 73 + 74 + return Ok(Err( 75 + "That relationship ID is not an active relationship of this type you are in." 76 + .to_string(), 77 + )); 78 + } 79 + 80 + let existing = 81 + caller_active_relationships_by_type(conn, guild_id, relationship_type, caller_id)?; 82 + 83 + if existing.len() > 1 { 84 + let list = existing 85 + .iter() 86 + .map(ToString::to_string) 87 + .collect::<Vec<_>>() 88 + .join(", "); 89 + return Ok(Err(format!( 90 + "You have multiple `{relationship_type}` relationships: {list}. Please pass `relationship_id` to choose one." 91 + ))); 92 + } 93 + 94 + if let Some(id) = existing.first() { 95 + return Ok(Ok(MakeResolution { 96 + relationship_id: *id, 97 + created_new_group: false, 98 + })); 99 + } 100 + 101 + let new_id = create_relationship(conn, guild_id, caller_id, relationship_type)?; 102 + add_active_member(conn, new_id, caller_id)?; 103 + 104 + Ok(Ok(MakeResolution { 105 + relationship_id: new_id, 106 + created_new_group: true, 107 + })) 108 + } 109 + 110 + pub fn try_create_invite( 111 + conn: &Connection, 112 + relationship_id: i64, 113 + inviter_id: u64, 114 + invitee_id: u64, 115 + ) -> Result<Option<String>> { 116 + let already_member = conn 117 + .query_row( 118 + "SELECT 1 FROM relationship_members 119 + WHERE relationship_id = ? AND user_id = ? AND left_at IS NULL", 120 + params![relationship_id, invitee_id.cast_signed()], 121 + |_| Ok(()), 122 + ) 123 + .ok() 124 + .is_some(); 125 + 126 + if already_member { 127 + return Ok(Some(format!( 128 + "<@{invitee_id}> is already in relationship #{relationship_id}." 129 + ))); 130 + } 131 + 132 + let pending_exists = conn 133 + .query_row( 134 + "SELECT 1 FROM relationship_invites 135 + WHERE relationship_id = ? AND invitee_id = ? AND status = 'pending'", 136 + params![relationship_id, invitee_id.cast_signed()], 137 + |_| Ok(()), 138 + ) 139 + .ok() 140 + .is_some(); 141 + 142 + if pending_exists { 143 + return Ok(Some(format!( 144 + "There is already a pending invite for <@{invitee_id}> in relationship #{relationship_id}." 145 + ))); 146 + } 147 + 148 + conn.execute( 149 + "INSERT INTO relationship_invites (relationship_id, inviter_id, invitee_id, status, created_at) 150 + VALUES (?, ?, ?, 'pending', ?)", 151 + params![ 152 + relationship_id, 153 + inviter_id.cast_signed(), 154 + invitee_id.cast_signed(), 155 + now_ts(), 156 + ], 157 + )?; 158 + 159 + Ok(None) 160 + } 161 + 162 + pub fn has_pending_invite( 163 + conn: &Connection, 164 + relationship_id: i64, 165 + invitee_id: u64, 166 + guild_id: u64, 167 + ) -> bool { 168 + conn.query_row( 169 + "SELECT 1 170 + FROM relationship_invites i 171 + INNER JOIN relationships r ON r.id = i.relationship_id 172 + WHERE i.relationship_id = ? 173 + AND i.invitee_id = ? 174 + AND i.status = 'pending' 175 + AND r.guild_id = ? 176 + AND r.status = 'active'", 177 + params![ 178 + relationship_id, 179 + invitee_id.cast_signed(), 180 + guild_id.cast_signed(), 181 + ], 182 + |_| Ok(()), 183 + ) 184 + .ok() 185 + .is_some() 186 + } 187 + 188 + pub fn accept_invite(conn: &Connection, relationship_id: i64, invitee_id: u64) -> Result<()> { 189 + add_active_member(conn, relationship_id, invitee_id)?; 190 + conn.execute( 191 + "UPDATE relationship_invites 192 + SET status = 'accepted', responded_at = ? 193 + WHERE relationship_id = ? AND invitee_id = ? AND status = 'pending'", 194 + params![now_ts(), relationship_id, invitee_id.cast_signed()], 195 + )?; 196 + Ok(()) 197 + } 198 + 199 + pub fn decline_invite( 200 + conn: &Connection, 201 + relationship_id: i64, 202 + invitee_id: u64, 203 + guild_id: u64, 204 + ) -> Result<usize> { 205 + Ok(conn.execute( 206 + "UPDATE relationship_invites 207 + SET status = 'declined', responded_at = ? 208 + WHERE relationship_id = ? 209 + AND invitee_id = ? 210 + AND status = 'pending' 211 + AND EXISTS ( 212 + SELECT 1 FROM relationships r 213 + WHERE r.id = relationship_invites.relationship_id 214 + AND r.guild_id = ? 215 + )", 216 + params![ 217 + now_ts(), 218 + relationship_id, 219 + invitee_id.cast_signed(), 220 + guild_id.cast_signed(), 221 + ], 222 + )?) 223 + } 224 + 225 + pub fn shared_relationship_ids( 226 + conn: &Connection, 227 + guild_id: u64, 228 + relationship_type: &str, 229 + user_a: u64, 230 + user_b: u64, 231 + ) -> Result<Vec<i64>> { 232 + shared_relationships_by_type(conn, guild_id, relationship_type, user_a, user_b) 233 + } 234 + 235 + pub fn leave_relationship(conn: &Connection, relationship_id: i64, user_id: u64) -> Result<bool> { 236 + let updated = conn.execute( 237 + "UPDATE relationship_members 238 + SET left_at = ? 239 + WHERE relationship_id = ? AND user_id = ? AND left_at IS NULL", 240 + params![now_ts(), relationship_id, user_id.cast_signed()], 241 + )?; 242 + 243 + if updated == 0 { 244 + return Ok(false); 245 + } 246 + 247 + maybe_end_relationship(conn, relationship_id) 248 + } 249 + 250 + pub fn is_active_member( 251 + conn: &Connection, 252 + relationship_id: i64, 253 + guild_id: u64, 254 + user_id: u64, 255 + ) -> bool { 256 + conn.query_row( 257 + "SELECT 1 258 + FROM relationships r 259 + INNER JOIN relationship_members m ON m.relationship_id = r.id 260 + WHERE r.id = ? 261 + AND r.guild_id = ? 262 + AND r.status = 'active' 263 + AND m.user_id = ? 264 + AND m.left_at IS NULL", 265 + params![ 266 + relationship_id, 267 + guild_id.cast_signed(), 268 + user_id.cast_signed() 269 + ], 270 + |_| Ok(()), 271 + ) 272 + .ok() 273 + .is_some() 274 + } 275 + 276 + pub fn list_active_relationships_for_user( 277 + conn: &Connection, 278 + guild_id: u64, 279 + user_id: u64, 280 + ) -> Result<Vec<(i64, String)>> { 281 + let mut stmt = conn.prepare( 282 + "SELECT r.id, r.relationship_type 283 + FROM relationships r 284 + INNER JOIN relationship_members m ON m.relationship_id = r.id 285 + WHERE r.guild_id = ? 286 + AND r.status = 'active' 287 + AND m.user_id = ? 288 + AND m.left_at IS NULL 289 + ORDER BY r.relationship_type ASC, r.id ASC", 290 + )?; 291 + 292 + let rows = stmt.query_map( 293 + params![guild_id.cast_signed(), user_id.cast_signed()], 294 + |row| { 295 + let relationship_id: i64 = row.get(0)?; 296 + let relationship_type: String = row.get(1)?; 297 + Ok((relationship_id, relationship_type)) 298 + }, 299 + )?; 300 + 301 + let mut values = Vec::new(); 302 + for row in rows { 303 + values.push(row?); 304 + } 305 + 306 + Ok(values) 307 + } 308 + 309 + pub fn active_member_ids(conn: &Connection, relationship_id: i64) -> Result<Vec<u64>> { 310 + let mut stmt = conn.prepare( 311 + "SELECT user_id 312 + FROM relationship_members 313 + WHERE relationship_id = ? AND left_at IS NULL 314 + ORDER BY user_id ASC", 315 + )?; 316 + 317 + let rows = stmt.query_map([relationship_id], |row| { 318 + let user_id: i64 = row.get(0)?; 319 + Ok(user_id.cast_unsigned()) 320 + })?; 321 + 322 + let mut members = Vec::new(); 323 + for row in rows { 324 + members.push(row?); 325 + } 326 + 327 + Ok(members) 328 + } 329 + 330 + fn now_ts() -> i64 { 331 + chrono::Utc::now().timestamp() 332 + } 333 + 334 + fn create_relationship( 335 + conn: &Connection, 336 + guild_id: u64, 337 + created_by: u64, 338 + relationship_type: &str, 339 + ) -> Result<i64> { 340 + conn.execute( 341 + "INSERT INTO relationships (guild_id, relationship_type, status, created_by, created_at) 342 + VALUES (?, ?, 'active', ?, ?)", 343 + params![ 344 + guild_id.cast_signed(), 345 + relationship_type, 346 + created_by.cast_signed(), 347 + now_ts(), 348 + ], 349 + )?; 350 + 351 + Ok(conn.last_insert_rowid()) 352 + } 353 + 354 + fn add_active_member(conn: &Connection, relationship_id: i64, user_id: u64) -> Result<()> { 355 + conn.execute( 356 + "INSERT INTO relationship_members (relationship_id, user_id, joined_at, left_at) 357 + VALUES (?, ?, ?, NULL) 358 + ON CONFLICT(relationship_id, user_id) 359 + DO UPDATE SET joined_at = excluded.joined_at, left_at = NULL", 360 + params![relationship_id, user_id.cast_signed(), now_ts()], 361 + )?; 362 + 363 + Ok(()) 364 + } 365 + 366 + fn maybe_end_relationship(conn: &Connection, relationship_id: i64) -> Result<bool> { 367 + let active_members: i64 = conn.query_row( 368 + "SELECT COUNT(*) FROM relationship_members WHERE relationship_id = ? AND left_at IS NULL", 369 + [relationship_id], 370 + |row| row.get(0), 371 + )?; 372 + 373 + if active_members < 2 { 374 + let updated = conn.execute( 375 + "UPDATE relationships 376 + SET status = 'ended', ended_at = ? 377 + WHERE id = ? AND status = 'active'", 378 + params![now_ts(), relationship_id], 379 + )?; 380 + return Ok(updated > 0); 381 + } 382 + 383 + Ok(false) 384 + } 385 + 386 + fn shared_relationships_by_type( 387 + conn: &Connection, 388 + guild_id: u64, 389 + relationship_type: &str, 390 + user_a: u64, 391 + user_b: u64, 392 + ) -> Result<Vec<i64>> { 393 + let mut stmt = conn.prepare( 394 + "SELECT r.id 395 + FROM relationships r 396 + WHERE r.guild_id = ? 397 + AND r.relationship_type = ? 398 + AND r.status = 'active' 399 + AND EXISTS ( 400 + SELECT 1 401 + FROM relationship_members m1 402 + WHERE m1.relationship_id = r.id 403 + AND m1.user_id = ? 404 + AND m1.left_at IS NULL 405 + ) 406 + AND EXISTS ( 407 + SELECT 1 408 + FROM relationship_members m2 409 + WHERE m2.relationship_id = r.id 410 + AND m2.user_id = ? 411 + AND m2.left_at IS NULL 412 + ) 413 + ORDER BY r.id ASC", 414 + )?; 415 + 416 + let rows = stmt.query_map( 417 + params![ 418 + guild_id.cast_signed(), 419 + relationship_type, 420 + user_a.cast_signed(), 421 + user_b.cast_signed(), 422 + ], 423 + |row| row.get(0), 424 + )?; 425 + 426 + let mut ids = Vec::new(); 427 + for row in rows { 428 + ids.push(row?); 429 + } 430 + 431 + Ok(ids) 432 + } 433 + 434 + fn caller_active_relationships_by_type( 435 + conn: &Connection, 436 + guild_id: u64, 437 + relationship_type: &str, 438 + caller_id: u64, 439 + ) -> Result<Vec<i64>> { 440 + let mut stmt = conn.prepare( 441 + "SELECT r.id 442 + FROM relationships r 443 + INNER JOIN relationship_members m ON m.relationship_id = r.id 444 + WHERE r.guild_id = ? 445 + AND r.relationship_type = ? 446 + AND r.status = 'active' 447 + AND m.user_id = ? 448 + AND m.left_at IS NULL 449 + ORDER BY r.id ASC", 450 + )?; 451 + 452 + let rows = stmt.query_map( 453 + params![ 454 + guild_id.cast_signed(), 455 + relationship_type, 456 + caller_id.cast_signed(), 457 + ], 458 + |row| row.get(0), 459 + )?; 460 + 461 + let mut ids = Vec::new(); 462 + for row in rows { 463 + ids.push(row?); 464 + } 465 + 466 + Ok(ids) 467 + }
+433
src/commands/user/relationship/mod.rs
··· 1 + mod db; 2 + mod logic; 3 + mod reply; 4 + 5 + use crate::types::Context; 6 + use color_eyre::eyre::Result; 7 + use poise::serenity_prelude::User; 8 + use std::collections::BTreeMap; 9 + 10 + use db::RELATIONSHIP_DB; 11 + use logic::{ 12 + accept_invite, active_member_ids, decline_invite, has_pending_invite, is_active_member, 13 + leave_relationship, list_active_relationships_for_user, normalize_relationship_type, 14 + resolve_relationship_for_make, shared_relationship_ids, try_create_invite, 15 + }; 16 + use reply::safe_reply; 17 + 18 + #[poise::command( 19 + slash_command, 20 + guild_only, 21 + subcommands("make", "accept", "decline", "end", "leave", "list") 22 + )] 23 + pub async fn relationship(_: Context<'_>) -> Result<()> { 24 + Ok(()) 25 + } 26 + 27 + /// Create a relationship invite for another user. 28 + #[poise::command(slash_command, guild_only)] 29 + pub async fn make( 30 + ctx: Context<'_>, 31 + #[description = "Relationship type (e.g. marriage, friend, adopted-sibling)"] 32 + relationship_type: String, 33 + #[description = "User to invite"] user: User, 34 + #[description = "Existing relationship ID to invite into (optional)"] relationship_id: Option< 35 + i64, 36 + >, 37 + ) -> Result<()> { 38 + let Some(guild_id) = ctx.guild_id() else { 39 + ctx.send( 40 + safe_reply() 41 + .content("This command can only be used in a server.") 42 + .ephemeral(true), 43 + ) 44 + .await?; 45 + return Ok(()); 46 + }; 47 + 48 + let caller_id = ctx.author().id.get(); 49 + let target_id = user.id.get(); 50 + 51 + if target_id == caller_id { 52 + ctx.send( 53 + safe_reply() 54 + .content("You cannot create a relationship with yourself.") 55 + .ephemeral(true), 56 + ) 57 + .await?; 58 + return Ok(()); 59 + } 60 + 61 + if user.bot { 62 + ctx.send( 63 + safe_reply() 64 + .content("You cannot create relationships with bot accounts.") 65 + .ephemeral(true), 66 + ) 67 + .await?; 68 + return Ok(()); 69 + } 70 + 71 + let normalized_type = match normalize_relationship_type(&relationship_type) { 72 + Ok(value) => value, 73 + Err(err) => { 74 + ctx.send(safe_reply().content(err.to_string()).ephemeral(true)) 75 + .await?; 76 + return Ok(()); 77 + } 78 + }; 79 + 80 + let relationship_result = { 81 + let conn = RELATIONSHIP_DB.lock().unwrap(); 82 + resolve_relationship_for_make( 83 + &conn, 84 + guild_id.get(), 85 + caller_id, 86 + &normalized_type, 87 + relationship_id, 88 + )? 89 + }; 90 + 91 + let make_resolution = match relationship_result { 92 + Ok(resolution) => resolution, 93 + Err(message) => { 94 + ctx.send(safe_reply().content(message).ephemeral(true)) 95 + .await?; 96 + return Ok(()); 97 + } 98 + }; 99 + 100 + let invite_error = { 101 + let conn = RELATIONSHIP_DB.lock().unwrap(); 102 + try_create_invite(&conn, make_resolution.relationship_id, caller_id, target_id)? 103 + }; 104 + 105 + if let Some(message) = invite_error { 106 + ctx.send(safe_reply().content(message).ephemeral(true)) 107 + .await?; 108 + return Ok(()); 109 + } 110 + 111 + let preface = if make_resolution.created_new_group { 112 + "Created a new relationship group and sent invite" 113 + } else { 114 + "Sent invite" 115 + }; 116 + 117 + let relationship_id = make_resolution.relationship_id; 118 + ctx.send( 119 + safe_reply() 120 + .content(format!( 121 + "{preface} for `{normalized_type}` to <@{target_id}> in relationship #{relationship_id}.\nThey can run `/relationship accept relationship_id:{relationship_id}` or `/relationship decline relationship_id:{relationship_id}`." 122 + )) 123 + .ephemeral(true), 124 + ) 125 + .await?; 126 + 127 + Ok(()) 128 + } 129 + 130 + /// Accept a pending relationship invite. 131 + #[poise::command(slash_command, guild_only)] 132 + pub async fn accept( 133 + ctx: Context<'_>, 134 + #[description = "Relationship ID to accept"] relationship_id: i64, 135 + ) -> Result<()> { 136 + let Some(guild_id) = ctx.guild_id() else { 137 + ctx.send( 138 + safe_reply() 139 + .content("This command can only be used in a server.") 140 + .ephemeral(true), 141 + ) 142 + .await?; 143 + return Ok(()); 144 + }; 145 + 146 + let caller_id = ctx.author().id.get(); 147 + 148 + let has_pending = { 149 + let conn = RELATIONSHIP_DB.lock().unwrap(); 150 + has_pending_invite(&conn, relationship_id, caller_id, guild_id.get()) 151 + }; 152 + 153 + if !has_pending { 154 + ctx.send( 155 + safe_reply() 156 + .content("No pending invite found for that relationship ID.") 157 + .ephemeral(true), 158 + ) 159 + .await?; 160 + return Ok(()); 161 + } 162 + 163 + { 164 + let conn = RELATIONSHIP_DB.lock().unwrap(); 165 + accept_invite(&conn, relationship_id, caller_id)?; 166 + } 167 + 168 + ctx.send( 169 + safe_reply() 170 + .content(format!( 171 + "Accepted invite for relationship #{relationship_id}." 172 + )) 173 + .ephemeral(true), 174 + ) 175 + .await?; 176 + 177 + Ok(()) 178 + } 179 + 180 + /// Decline a pending relationship invite. 181 + #[poise::command(slash_command, guild_only)] 182 + pub async fn decline( 183 + ctx: Context<'_>, 184 + #[description = "Relationship ID to decline"] relationship_id: i64, 185 + ) -> Result<()> { 186 + let Some(guild_id) = ctx.guild_id() else { 187 + ctx.send( 188 + safe_reply() 189 + .content("This command can only be used in a server.") 190 + .ephemeral(true), 191 + ) 192 + .await?; 193 + return Ok(()); 194 + }; 195 + 196 + let caller_id = ctx.author().id.get(); 197 + 198 + let updated = { 199 + let conn = RELATIONSHIP_DB.lock().unwrap(); 200 + decline_invite(&conn, relationship_id, caller_id, guild_id.get())? 201 + }; 202 + 203 + if updated == 0 { 204 + ctx.send( 205 + safe_reply() 206 + .content("No pending invite found for that relationship ID.") 207 + .ephemeral(true), 208 + ) 209 + .await?; 210 + return Ok(()); 211 + } 212 + 213 + ctx.send( 214 + safe_reply() 215 + .content(format!( 216 + "Declined invite for relationship #{relationship_id}." 217 + )) 218 + .ephemeral(true), 219 + ) 220 + .await?; 221 + 222 + Ok(()) 223 + } 224 + 225 + /// Leave a shared relationship by type with another user. 226 + #[poise::command(slash_command, guild_only)] 227 + pub async fn end( 228 + ctx: Context<'_>, 229 + #[description = "Relationship type (e.g. marriage, friend, adopted-sibling)"] 230 + relationship_type: String, 231 + #[description = "A user in the relationship"] user: User, 232 + ) -> Result<()> { 233 + let Some(guild_id) = ctx.guild_id() else { 234 + ctx.send( 235 + safe_reply() 236 + .content("This command can only be used in a server.") 237 + .ephemeral(true), 238 + ) 239 + .await?; 240 + return Ok(()); 241 + }; 242 + 243 + let caller_id = ctx.author().id.get(); 244 + let target_id = user.id.get(); 245 + 246 + let normalized_type = match normalize_relationship_type(&relationship_type) { 247 + Ok(value) => value, 248 + Err(err) => { 249 + ctx.send(safe_reply().content(err.to_string()).ephemeral(true)) 250 + .await?; 251 + return Ok(()); 252 + } 253 + }; 254 + 255 + let shared_ids = { 256 + let conn = RELATIONSHIP_DB.lock().unwrap(); 257 + shared_relationship_ids( 258 + &conn, 259 + guild_id.get(), 260 + &normalized_type, 261 + caller_id, 262 + target_id, 263 + )? 264 + }; 265 + 266 + if shared_ids.is_empty() { 267 + ctx.send( 268 + safe_reply() 269 + .content(format!( 270 + "No active `{normalized_type}` relationship found that includes both of you." 271 + )) 272 + .ephemeral(true), 273 + ) 274 + .await?; 275 + return Ok(()); 276 + } 277 + 278 + if shared_ids.len() > 1 { 279 + let ids = shared_ids 280 + .iter() 281 + .map(ToString::to_string) 282 + .collect::<Vec<_>>() 283 + .join(", "); 284 + 285 + ctx.send( 286 + safe_reply() 287 + .content(format!( 288 + "Multiple matching relationships found: {ids}. Use `/relationship leave relationship_id:<id>` to choose exactly one." 289 + )) 290 + .ephemeral(true), 291 + ) 292 + .await?; 293 + return Ok(()); 294 + } 295 + 296 + let relationship_id = shared_ids[0]; 297 + let relationship_ended = { 298 + let conn = RELATIONSHIP_DB.lock().unwrap(); 299 + leave_relationship(&conn, relationship_id, caller_id)? 300 + }; 301 + 302 + let suffix = if relationship_ended { 303 + " The relationship was automatically ended because fewer than 2 active members remain." 304 + } else { 305 + "" 306 + }; 307 + 308 + ctx.send( 309 + safe_reply() 310 + .content(format!( 311 + "You left `{normalized_type}` relationship #{relationship_id}.{suffix}" 312 + )) 313 + .ephemeral(true), 314 + ) 315 + .await?; 316 + 317 + Ok(()) 318 + } 319 + 320 + /// Leave a relationship by ID. 321 + #[poise::command(slash_command, guild_only)] 322 + pub async fn leave( 323 + ctx: Context<'_>, 324 + #[description = "Relationship ID to leave"] relationship_id: i64, 325 + ) -> Result<()> { 326 + let Some(guild_id) = ctx.guild_id() else { 327 + ctx.send( 328 + safe_reply() 329 + .content("This command can only be used in a server.") 330 + .ephemeral(true), 331 + ) 332 + .await?; 333 + return Ok(()); 334 + }; 335 + 336 + let caller_id = ctx.author().id.get(); 337 + 338 + let is_member = { 339 + let conn = RELATIONSHIP_DB.lock().unwrap(); 340 + is_active_member(&conn, relationship_id, guild_id.get(), caller_id) 341 + }; 342 + 343 + if !is_member { 344 + ctx.send( 345 + safe_reply() 346 + .content("You are not an active member of that relationship ID.") 347 + .ephemeral(true), 348 + ) 349 + .await?; 350 + return Ok(()); 351 + } 352 + 353 + let relationship_ended = { 354 + let conn = RELATIONSHIP_DB.lock().unwrap(); 355 + leave_relationship(&conn, relationship_id, caller_id)? 356 + }; 357 + 358 + let suffix = if relationship_ended { 359 + " The relationship was automatically ended because fewer than 2 active members remain." 360 + } else { 361 + "" 362 + }; 363 + 364 + ctx.send( 365 + safe_reply() 366 + .content(format!("You left relationship #{relationship_id}.{suffix}")) 367 + .ephemeral(true), 368 + ) 369 + .await?; 370 + 371 + Ok(()) 372 + } 373 + 374 + /// List active relationships for yourself or another user. 375 + #[poise::command(slash_command, guild_only)] 376 + pub async fn list( 377 + ctx: Context<'_>, 378 + #[description = "User to inspect (defaults to you)"] user: Option<User>, 379 + ) -> Result<()> { 380 + let Some(guild_id) = ctx.guild_id() else { 381 + ctx.send( 382 + safe_reply() 383 + .content("This command can only be used in a server.") 384 + .ephemeral(true), 385 + ) 386 + .await?; 387 + return Ok(()); 388 + }; 389 + 390 + let target = user.as_ref().unwrap_or(ctx.author()); 391 + let target_id = target.id.get(); 392 + 393 + let relationships = { 394 + let conn = RELATIONSHIP_DB.lock().unwrap(); 395 + list_active_relationships_for_user(&conn, guild_id.get(), target_id)? 396 + }; 397 + 398 + if relationships.is_empty() { 399 + ctx.send(safe_reply().content(format!("<@{target_id}> has no active relationships."))) 400 + .await?; 401 + return Ok(()); 402 + } 403 + 404 + let mut grouped: BTreeMap<String, Vec<i64>> = BTreeMap::new(); 405 + for (relationship_id, relationship_type) in &relationships { 406 + grouped 407 + .entry(relationship_type.clone()) 408 + .or_default() 409 + .push(*relationship_id); 410 + } 411 + 412 + let mut lines = vec![format!("**Active relationships for <@{target_id}>**")]; 413 + 414 + { 415 + let conn = RELATIONSHIP_DB.lock().unwrap(); 416 + for (relationship_type, ids) in grouped { 417 + lines.push(format!("\n`{relationship_type}`")); 418 + for relationship_id in ids { 419 + let members = active_member_ids(&conn, relationship_id)?; 420 + let member_mentions = members 421 + .iter() 422 + .map(|id| format!("<@{id}>")) 423 + .collect::<Vec<_>>() 424 + .join(", "); 425 + 426 + lines.push(format!("- #{relationship_id}: {member_mentions}")); 427 + } 428 + } 429 + } 430 + 431 + ctx.send(safe_reply().content(lines.join("\n"))).await?; 432 + Ok(()) 433 + }
+15
src/commands/user/relationship/reply.rs
··· 1 + use poise::CreateReply; 2 + 3 + fn no_mentions() -> poise::serenity_prelude::CreateAllowedMentions { 4 + poise::serenity_prelude::CreateAllowedMentions::new() 5 + .everyone(false) 6 + .all_users(false) 7 + .all_roles(false) 8 + .replied_user(false) 9 + .empty_users() 10 + .empty_roles() 11 + } 12 + 13 + pub fn safe_reply() -> CreateReply { 14 + CreateReply::default().allowed_mentions(no_mentions()) 15 + }
+25 -26
src/event_handler/starboard.rs
··· 339 339 if let Some(message_reference) = &message.message_reference 340 340 && let Some(reference_message_id) = message_reference.message_id 341 341 { 342 - let reference_details = if let Some(reference_message) = 343 - message.referenced_message.as_deref() 344 - { 345 - Some(( 346 - reference_message.author.name.clone(), 347 - summarized_message_content( 348 - &reference_message.content, 349 - !reference_message.attachments.is_empty(), 350 - ), 351 - )) 352 - } else { 353 - let reference_channel_id = message_reference.channel_id; 354 - reference_channel_id 355 - .message(ctx, reference_message_id) 356 - .await 357 - .ok() 358 - .map(|reference_message| { 359 - ( 360 - reference_message.author.name, 361 - summarized_message_content( 362 - &reference_message.content, 363 - !reference_message.attachments.is_empty(), 364 - ), 365 - ) 366 - }) 367 - }; 342 + let reference_details = 343 + if let Some(reference_message) = message.referenced_message.as_deref() { 344 + Some(( 345 + reference_message.author.name.clone(), 346 + summarized_message_content( 347 + &reference_message.content, 348 + !reference_message.attachments.is_empty(), 349 + ), 350 + )) 351 + } else { 352 + let reference_channel_id = message_reference.channel_id; 353 + reference_channel_id 354 + .message(ctx, reference_message_id) 355 + .await 356 + .ok() 357 + .map(|reference_message| { 358 + ( 359 + reference_message.author.name, 360 + summarized_message_content( 361 + &reference_message.content, 362 + !reference_message.attachments.is_empty(), 363 + ), 364 + ) 365 + }) 366 + }; 368 367 369 368 if let Some((reference_author_name, reference_preview)) = reference_details { 370 369 let reference_channel_id = message_reference.channel_id;
+1
src/main.rs
··· 37 37 commands::user::whois::whois(), 38 38 commands::user::avatar::avatar(), 39 39 commands::user::color_me::color_me(), 40 + commands::user::relationship::relationship(), 40 41 // bot commands 41 42 commands::bot::ping::ping(), 42 43 commands::bot::bot::botinfo(),