diff --git a/cruciverb-server/src/activitypub/outbox.rs b/cruciverb-server/src/activitypub/outbox.rs new file mode 100644 index 0000000..1bc6191 --- /dev/null +++ b/cruciverb-server/src/activitypub/outbox.rs @@ -0,0 +1,651 @@ +use std::sync::Arc; +use axum::Json; +use axum::extract::State; +use axum::response::{IntoResponse, Response}; + +use crate::state::AppState; +use super::types::*; + +pub const TAGLINES: &[&str] = &[ + "Five grids, zero excuses.", + "Your morning routine just got more demanding.", + "Somewhere between coffee and regret, there's a crossword.", + "Today's grids are freshly generated and completely indifferent to your plans.", + "Another day, another chance to stare at 1-across for way too long.", + "The clues aren't getting easier. You're getting better. Probably.", + "Five puzzles, five difficulty levels, one inevitable rabbit hole.", + "Generated overnight while you were sleeping. You're welcome.", + "Hot off the generator, ready to ruin your productivity.", + "Five ways to avoid doing what you're supposed to be doing.", + "The grids don't care about your schedule.", + "New day, new grids, same questionable life choices.", + "Freshly baked. Zero calories. Maximum frustration.", + "Today's puzzles were generated with love and a complete lack of mercy.", + "Your daily reminder that 3-letter words are harder than they look.", + "The generator worked all night so you could suffer all day.", + "Five puzzles walk into a bar. You never leave.", + "Solve responsibly. Or don't. We're not your parents.", + "Every answer is a real word. We checked. Mostly.", + "The daily dose of squares you didn't know you needed.", + "Fresh grids, fresh frustration, fresh start.", + "Today's assortment of carefully arranged suffering.", + "Five new reasons to feel either very smart or very humble.", + "The crosswords are calling. They don't accept voicemail.", + "A new batch of grids that think they're smarter than you.", + "Ready when you are. The grids have been waiting since midnight.", + "Your daily appointment with the dictionary.", + "Five puzzles, zero judgment. Well, maybe a little.", + "The only decision harder than 7-down is which puzzle to start with.", + "Today's puzzles come with a free sense of accomplishment.", + "Rise, shine, and wonder what a 4-letter word for \"regret\" is.", + "The generator promises these are solvable. The generator might be lying.", + "Five grids that believe in you even when 12-across doesn't.", + "New puzzles, same you. That's either reassuring or terrifying.", + "Brought to you by the letter E and the number 7.", + "Start easy, build confidence, lose it all at the Codex.", + "Guaranteed to make your commute feel shorter. Or longer. Depends.", + "The internet's most productive way to procrastinate.", + "Five puzzles, one goal: fill every single square.", + "Today's grids have been pre-tested by absolutely nobody.", + "Fresh from the oven. Handle with care. Or don't.", + "Somewhere out there, 1-across is waiting for you.", + "The daily grids are here and they brought friends.", + "Five chances to prove you know what \"esne\" means.", + "Your brain asked for this. Your brain might regret it.", + "Another day of pretending you don't need the hint button.", + "The grids are ready. The question is: are you?", + "Five puzzles to make the waiting room feel like a study hall.", + "Generated fresh, served immediately, regretted eventually.", + "Today's crosswords: now with 100% more crosswords.", +]; + +pub const SIGNOFFS: &[&str] = &[ + "The Grimoire is feeling particularly hostile today.", + "Good luck with the Codex. You'll need it.", + "The Glyph is right there if you need a warm-up. No shame.", + "Start with the Rune. Build confidence. Then try the rest.", + "We believe in you. The Grimoire does not.", + "Don't skip the Scroll. It's the sweet spot today.", + "The Codex sends its regards.", + "May your crossings be fair and your clues be merciful.", + "Remember: there's no shame in starting with the Glyph.", + "The Grimoire is optional. Your pride is not.", + "Pro tip: the Scroll is always a good place to start.", + "Today's Codex was generated with a slight grudge.", + "Solve one, solve all, solve none. All valid options.", + "The Glyph takes two minutes. The Grimoire takes your dignity.", + "Don't let the Rune fool you. It has opinions.", + "If the Grimoire defeats you, the Glyph is always there. Waiting.", + "The puzzles don't get easier. You get more stubborn.", + "Start wherever you want. End wherever the grid lets you.", + "The Scroll is calling. It says you owe it a solve.", + "Today's Grimoire was specially crafted for maximum humility.", + "One does not simply solve the Codex on the first try.", + "The Rune looks easy. The Rune is a liar.", + "Your streak depends on it. No pressure.", + "Good luck. The grids show no mercy.", + "Today's puzzles rate themselves 5/5. Humble, clearly.", + "Finish the Glyph in under a minute and you earn bragging rights.", + "The Codex respects your time. The Grimoire does not.", + "Approach the Grimoire with caution and a dictionary.", + "All five are solvable. That's the nicest thing we'll say today.", + "The Scroll is the Goldilocks puzzle. Just right. Usually.", + "Let the Rune lull you into false confidence. Classic move.", + "If you solve the Grimoire, tell no one. Keep the mystery alive.", + "Fair warning: the Codex has been feeling ambitious lately.", + "Five puzzles, five moods. Pick the one that matches yours.", + "Today's Rune is suspiciously reasonable. Proceed with caution.", + "The Glyph: perfect for coffee breaks and ego maintenance.", + "Solve order recommendation: Glyph, Rune, Scroll, deep breath, Codex, existential crisis, Grimoire.", + "Fun fact: the Grimoire was generated with malice aforethought.", + "The Scroll doesn't judge. The Grimoire absolutely does.", + "Today's puzzles promise nothing except a good time. Probably.", + "Your streak is watching. Don't let it down.", + "The Codex dares you. The Grimoire double-dares you.", + "If at first you don't succeed, the Glyph is still there.", + "Today's Rune: small but mighty. Like a crossword espresso.", + "Tackle them in order or pick randomly. We don't make the rules. Actually we do.", + "The Scroll: nine rows, nine columns, ninety minutes of your life.", + "Don't overthink 1-across. Or do. It's your solve.", + "The Grimoire awaits. It has all day. Unlike you.", + "Remember: every empty cell is just a letter you haven't found yet.", + "Good puzzling. See you tomorrow. Same time, same grids. Well, different grids.", +]; + +pub const COMMUNITY_MIDDLES: &[&str] = &[ + "Some of them might even be solvable.", + "All free to play, no account needed. The clues owe you nothing.", + "Fresh grids from people who think filling squares is an art form. They might be right.", + "Hand-crafted, locally sourced, artisanal word grids. Organic? Debatable.", + "Every one of them free, every one of them convinced their clues are fair.", + "Give them a solve and leave a rating. Constructors thrive on validation.", + "Free to play. Free to judge. Free to wonder what 4-down was supposed to mean.", + "Each one someone's labor of love disguised as a grid full of squares.", + "Homemade puzzles. Side effects may include opinions about clue quality.", + "Built by cruciverbalists for cruciverbalists. That's a real word, look it up.", + "The clues are creative. The grids are ambitious. The solvers are warned.", + "Community-built, community-tested, community-approved. Mostly.", + "Every puzzle is someone's masterpiece. Treat it accordingly. Or don't.", + "The constructors did their best. Now it's your turn.", + "Fresh from the puzzle editor. Handle with appropriate skepticism.", + "Puzzles made by real people with real dictionaries and real ambition.", + "No two constructors think alike. Today's grids prove it.", + "Quality may vary. Enthusiasm is guaranteed.", + "Every grid tells a story. Some stories are more confusing than others.", + "Straight from the editor to your screen. No middleman, no filter.", + "Community puzzles: where creativity meets the dictionary.", + "Rate them fairly. The constructors can see the ratings.", + "Some are easy, some are devious, all are free.", + "The community has been busy. Your afternoon just got more interesting.", + "Today's batch includes at least one puzzle that will surprise you.", + "Made with love, frustration, and an unreasonable number of three-letter words.", + "The constructors stayed up late for these. The least you can do is solve one.", + "From the community, for the community. With love and mild sadness.", + "Every puzzle started as a blank grid and a dream. Here are the results.", + "New puzzles from people who voluntarily spend their free time making crosswords.", + "Solve one, rate one, maybe submit your own. The cycle continues.", + "Fresh community puzzles. No two grids are alike. Most clues are fair.", + "Today's community output: ambitious, creative, and occasionally baffling.", + "Puzzles by people who think constructing is fun. And honestly, they're right.", + "The community delivered. Time to see what they came up with.", + "Warning: may contain clever themes, questionable fills, and at least one answer you'll argue about.", + "New grids, new constructors, new reasons to question your vocabulary.", + "The puzzle editor was used today. The results are in.", + "Every puzzle was someone's idea of a good time. Give it a try.", + "Community crosswords: where the grids are made up and the ratings matter.", + "Built by humans, for humans. The generator had the day off.", + "The community speaks in crossword form. Here's what they said today.", + "Puzzles that exist because someone thought \"I could make a better one.\" And they tried.", + "New grids from the community. Your feedback shapes the future ones.", + "Today's puzzles come with no warranty, express or implied.", + "Fresh off the press. The community press, that is.", + "Constructors put their hearts into these grids. Show some love.", + "Another day, another batch of community puzzles to discover.", + "The constructors have spoken. The puzzles are ready. Your move.", + "New community puzzles just dropped. We take no responsibility for 14-across.", +]; + +pub fn pick_line<'a>(date: &str, builtins: &'a [&str], custom: &'a [String], salt: &str) -> &'a str { + let total = builtins.len() + custom.len(); + if total == 0 { return ""; } + let hash = { + use sha2::{Sha256, Digest}; + let mut h = Sha256::new(); + h.update(date.as_bytes()); + h.update(salt.as_bytes()); + let result = h.finalize(); + u64::from_le_bytes(result[..8].try_into().unwrap()) + }; + let idx = (hash % total as u64) as usize; + if idx < builtins.len() { + builtins[idx] + } else { + &custom[idx - builtins.len()] + } +} + +fn format_date(date: &str) -> String { + if let Ok(d) = chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d") { + d.format("%A, %B %-d, %Y").to_string() + } else { + date.to_string() + } +} + +pub fn load_custom_lines(db: &rusqlite::Connection, key: &str) -> Vec { + crate::db::get_server_config(db, key) + .and_then(|json| serde_json::from_str::>(&json).ok()) + .unwrap_or_default() +} + +fn collect_peer_inboxes(db: &rusqlite::Connection) -> Vec { + let mut inboxes: Vec = Vec::new(); + if let Ok(mut stmt) = db.prepare("SELECT inbox_uri FROM ap_peers") { + if let Ok(rows) = stmt.query_map([], |row| row.get(0)) { + for r in rows.flatten() { inboxes.push(r); } + } + } + inboxes +} + +fn collect_all_inboxes(db: &rusqlite::Connection) -> Vec { + let mut inboxes: Vec = Vec::new(); + if let Ok(mut stmt) = db.prepare("SELECT inbox_uri FROM ap_followers WHERE accepted = 1") { + if let Ok(rows) = stmt.query_map([], |row| row.get(0)) { + for r in rows.flatten() { inboxes.push(r); } + } + } + if let Ok(mut stmt) = db.prepare("SELECT inbox_uri FROM ap_peers") { + if let Ok(rows) = stmt.query_map([], |row| row.get(0)) { + for r in rows.flatten() { if !inboxes.contains(&r) { inboxes.push(r); } } + } + } + inboxes +} + +fn enqueue_delivery(db: &rusqlite::Connection, inboxes: &[String], payload: &str) { + let retry = chrono::Utc::now().to_rfc3339(); + for inbox in inboxes { + db.execute( + "INSERT INTO ap_delivery_queue (inbox_uri, payload, next_retry, created_at) VALUES (?1, ?2, ?3, ?3)", + rusqlite::params![inbox, payload, retry], + ).ok(); + } +} + +pub async fn outbox( + State(state): State>, +) -> Result, Response> { + let ap = state.ap.as_ref().ok_or_else(|| { + let status = axum::http::StatusCode::NOT_FOUND; + (status, "not found").into_response() + })?; + let limit: i64 = std::env::var("AP_OUTBOX_LIMIT") + .ok().and_then(|v| v.parse().ok()) + .unwrap_or(20); + if limit == 0 { + return Ok(Json(OrderedCollection { + context: CONTEXT.into(), + id: format!("{}/outbox", ap.actor_uri), + collection_type: "OrderedCollection".into(), + total_items: 0, + ordered_items: vec![], + })); + } + + let dbi = state.db.lock().await; + let mut stmt = dbi.db.prepare( + "SELECT activity FROM ap_outbox ORDER BY published DESC LIMIT ?1" + ).unwrap(); + let items: Vec = stmt.query_map([limit], |row| { + let json: String = row.get(0)?; + Ok(serde_json::from_str(&json).unwrap_or_default()) + }).unwrap().filter_map(|r| r.ok()).collect(); + + Ok(Json(OrderedCollection { + context: CONTEXT.into(), + id: format!("{}/outbox", ap.actor_uri), + collection_type: "OrderedCollection".into(), + total_items: items.len(), + ordered_items: items, + })) +} + +pub async fn publish_daily_puzzle(state: &Arc, date: &str) { + let ap = match state.ap.as_ref() { + Some(ap) => ap, + None => return, + }; + + let formatted_date = format_date(date); + + let (tagline, signoff, custom_taglines, custom_signoffs) = { + let dbi = state.db.lock().await; + let ct = load_custom_lines(&dbi.db, "ap_daily_taglines"); + let cs = load_custom_lines(&dbi.db, "ap_daily_signoffs"); + let t = pick_line(date, TAGLINES, &ct, "tagline").to_string(); + let s = pick_line(date, SIGNOFFS, &cs, "signoff").to_string(); + (t, s, ct, cs) + }; + let _ = (custom_taglines, custom_signoffs); // used by pick_line above + + let domain = &ap.domain; + + let post_url = format!("https://{}/post/{}", domain, date); + let html_text = format!( + "Fresh puzzles for {}. {}

\ + - Glyph (5x5, very easy) - https://{}/daily/glyph
\ + - Rune (5x5, easy) - https://{}/daily/rune
\ + - Scroll (9x9, medium) - https://{}/daily/scroll
\ + - Codex (15x15, hard) - https://{}/daily/codex
\ + - Grimoire (21x21, expert) - https://{}/daily/grimoire

\ + {}

\ + #crossword \ + #puzzle \ + #cruciverbDaily", + formatted_date, tagline, + domain, domain, domain, domain, domain, domain, domain, domain, domain, domain, + signoff, + domain, domain, domain, + ); + let note_id = format!("https://{}/ap/daily/{}/{}", domain, date, uuid::Uuid::new_v4()); + let now = chrono::Utc::now().to_rfc3339(); + + let attachments = vec![serde_json::json!({ + "type": "Document", + "mediaType": "image/jpeg", + "url": format!("https://{}/daily-image", domain), + "name": "Five crossword puzzle grids side by side representing today's daily puzzles from very easy to expert." + })]; + + let note = Note { + note_type: "Note".into(), + id: note_id.clone(), + attributed_to: ap.actor_uri.clone(), + content: format!("

{}

", html_text), + published: now.clone(), + url: Some(post_url.clone()), + to: vec![PUBLIC.into()], + cc: vec![format!("{}/followers", ap.actor_uri)], + tag: Some(vec![ + serde_json::json!({ "type": "Hashtag", "name": "#crossword", "href": format!("https://{}/tags/crossword", domain) }), + serde_json::json!({ "type": "Hashtag", "name": "#puzzle", "href": format!("https://{}/tags/puzzle", domain) }), + serde_json::json!({ "type": "Hashtag", "name": "#cruciverbDaily", "href": format!("https://{}/tags/cruciverbDaily", domain) }), + ]), + attachment: Some(attachments), + }; + + let activity_id = format!("{}/activity", note_id); + let activity = Activity { + context: Some(serde_json::json!([CONTEXT, SECURITY_CONTEXT])), + id: activity_id.clone(), + activity_type: "Create".into(), + actor: ap.actor_uri.clone(), + published: Some(now.clone()), + to: Some(vec![PUBLIC.into()]), + cc: Some(vec![format!("{}/followers", ap.actor_uri)]), + object: Some(serde_json::to_value(¬e).unwrap()), + }; + + let activity_json = serde_json::to_string(&activity).unwrap(); + + let dbi = state.db.lock().await; + dbi.db.execute( + "INSERT OR REPLACE INTO ap_outbox (id, activity, published) VALUES (?1, ?2, ?3)", + rusqlite::params![activity_id, activity_json, now], + ).ok(); + + let mut inboxes: Vec = Vec::new(); + if let Ok(mut stmt) = dbi.db.prepare("SELECT inbox_uri FROM ap_followers WHERE accepted = 1") { + if let Ok(rows) = stmt.query_map([], |row| row.get(0)) { + for r in rows.flatten() { inboxes.push(r); } + } + } + if let Ok(mut stmt) = dbi.db.prepare("SELECT inbox_uri FROM ap_peers") { + if let Ok(rows) = stmt.query_map([], |row| row.get(0)) { + for r in rows.flatten() { if !inboxes.contains(&r) { inboxes.push(r); } } + } + } + + enqueue_delivery(&dbi.db, &inboxes, &activity_json); + tracing::info!("published daily puzzle post for {} to {} recipients", date, inboxes.len()); +} + +pub async fn publish_community_digest(state: &Arc, date: &str) { + let ap = match state.ap.as_ref() { + Some(ap) => ap, + None => return, + }; + + let (count, middle) = { + let dbi = state.db.lock().await; + let c: i64 = dbi.db.query_row( + "SELECT COUNT(*) FROM user_puzzles WHERE status = 'published' AND date(created_at) = ?1", + [date], |row| row.get(0), + ).unwrap_or(0); + if c == 0 { return; } + let cm = load_custom_lines(&dbi.db, "ap_community_middles"); + let m = pick_line(date, COMMUNITY_MIDDLES, &cm, "community").to_string(); + (c, m) + }; + + let s = if count == 1 { "" } else { "s" }; + let domain = &ap.domain; + + let text = format!( + "{} new community puzzle{} landed today. {}\n\n\ + Browse today's submissions: https://{}/community/today\n\n\ + #crossword #community #cruciverbCommunity", + count, s, middle, domain, + ); + + let html_text = text.replace('\n', "
"); + let note_id = format!("https://{}/ap/community-digest/{}", domain, date); + let now = chrono::Utc::now().to_rfc3339(); + + let note = Note { + note_type: "Note".into(), + id: note_id.clone(), + attributed_to: ap.actor_uri.clone(), + content: format!("

{}

", html_text), + published: now.clone(), + url: Some(format!("https://{}/community/today", domain)), + to: vec![PUBLIC.into()], + cc: vec![format!("{}/followers", ap.actor_uri)], + tag: Some(vec![ + serde_json::json!({ "type": "Hashtag", "name": "#crossword" }), + serde_json::json!({ "type": "Hashtag", "name": "#community" }), + serde_json::json!({ "type": "Hashtag", "name": "#cruciverbCommunity" }), + ]), + attachment: None, + }; + + let activity_id = format!("{}/activity", note_id); + let activity = Activity { + context: Some(serde_json::json!([CONTEXT, SECURITY_CONTEXT])), + id: activity_id.clone(), + activity_type: "Create".into(), + actor: ap.actor_uri.clone(), + published: Some(now.clone()), + to: Some(vec![PUBLIC.into()]), + cc: Some(vec![format!("{}/followers", ap.actor_uri)]), + object: Some(serde_json::to_value(¬e).unwrap()), + }; + + let activity_json = serde_json::to_string(&activity).unwrap(); + + let dbi = state.db.lock().await; + dbi.db.execute( + "INSERT OR REPLACE INTO ap_outbox (id, activity, published) VALUES (?1, ?2, ?3)", + rusqlite::params![activity_id, activity_json, now], + ).ok(); + + let inboxes = collect_all_inboxes(&dbi.db); + enqueue_delivery(&dbi.db, &inboxes, &activity_json); + tracing::info!("published community digest for {} ({} puzzles, {} recipients)", date, count, inboxes.len()); +} + +pub async fn publish_delete(state: &Arc, object_uri: &str) { + let ap = match state.ap.as_ref() { Some(ap) => ap, None => return }; + let now = chrono::Utc::now().to_rfc3339(); + let activity = serde_json::json!({ + "@context": [CONTEXT, SECURITY_CONTEXT], + "id": format!("{}/delete/{}", ap.actor_uri, uuid::Uuid::new_v4()), + "type": "Delete", + "actor": ap.actor_uri, + "object": object_uri, + "published": now, + "to": [PUBLIC], + "cc": [format!("{}/followers", ap.actor_uri)], + }); + let payload = serde_json::to_string(&activity).unwrap(); + let dbi = state.db.lock().await; + dbi.db.execute("DELETE FROM ap_outbox WHERE id LIKE ?1 || '%'", [object_uri]).ok(); + // drop interaction orphans that reference the deleted object + dbi.db.execute("DELETE FROM ap_replies WHERE outbox_id LIKE ?1 || '%'", [object_uri]).ok(); + dbi.db.execute("DELETE FROM ap_boosts WHERE outbox_id LIKE ?1 || '%'", [object_uri]).ok(); + let inboxes = collect_all_inboxes(&dbi.db); + enqueue_delivery(&dbi.db, &inboxes, &payload); +} + +pub async fn publish_leaderboard( + state: &Arc, + date: &str, + preset: &str, + entries: Vec<(String, i64)>, +) { + let ap = match state.ap.as_ref() { Some(ap) => ap, None => return }; + if entries.is_empty() { return; } + + let top10: Vec = entries.iter().take(10).map(|(name, time)| { + serde_json::json!({ "name": name, "time": time }) + }).collect(); + + let payload = serde_json::json!({ + "origin_domain": ap.domain, + "puzzle_date": date, + "preset": preset, + "entries": top10, + }); + let payload_str = serde_json::to_string(&payload).unwrap(); + + let peers: Vec = { + let dbi = state.db.lock().await; + let mut stmt = dbi.db.prepare("SELECT domain FROM ap_peers").unwrap(); + stmt.query_map([], |row| row.get(0)).unwrap() + .filter_map(|r| r.ok()).collect() + }; + + if peers.is_empty() { return; } + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .unwrap(); + + let key_id = format!("{}#main-key", ap.actor_uri); + + for domain in &peers { + let url = format!("https://{}/api/federation/leaderboard", domain); + let path = "/api/federation/leaderboard"; + + let sig_headers = super::signatures::sign_request( + &ap.keys.private_key, &key_id, "POST", path, domain, + Some(payload_str.as_bytes()), + ); + + let mut req = client.post(&url) + .header("Content-Type", "application/json"); + for (name, value) in &sig_headers { + req = req.header(name.as_str(), value.as_str()); + } + + match req.body(payload_str.clone()).send().await { + Ok(resp) if resp.status().is_success() => { + tracing::debug!("sent leaderboard to {}", domain); + } + Ok(resp) => { + tracing::warn!("leaderboard to {} failed: {}", domain, resp.status()); + } + Err(e) => { + tracing::warn!("leaderboard to {} error: {}", domain, e); + } + } + } + + tracing::info!("published leaderboard {} {} to {} peers", preset, date, peers.len()); +} + +pub async fn publish_blocklist_update(state: &Arc, domain: &str, action: &str) { + let ap = match state.ap.as_ref() { Some(ap) => ap, None => return }; + let now = chrono::Utc::now().to_rfc3339(); + let id = format!("https://{}/ap/blocklist/{}/{}", ap.domain, action, uuid::Uuid::new_v4()); + + let activity = serde_json::json!({ + "@context": [CONTEXT, SECURITY_CONTEXT], + "id": id, + "type": "Announce", + "actor": ap.actor_uri, + "published": now, + "to": [PUBLIC], + "tag": [{ "type": "Hashtag", "name": "#cruciverb-blocklist" }], + "attachment": [ + { "type": "PropertyValue", "name": "cruciverb:blocklist-domain", "value": domain }, + { "type": "PropertyValue", "name": "cruciverb:blocklist-action", "value": action }, + ], + }); + + let payload = serde_json::to_string(&activity).unwrap(); + let dbi = state.db.lock().await; + let inboxes = collect_peer_inboxes(&dbi.db); + enqueue_delivery(&dbi.db, &inboxes, &payload); + tracing::info!("published blocklist {} for {}", action, domain); +} + +pub async fn publish_move(state: &Arc, target_domain: &str) { + let ap = match state.ap.as_ref() { Some(ap) => ap, None => return }; + let now = chrono::Utc::now().to_rfc3339(); + let target_actor = format!("https://{}/@cruciverb", target_domain); + + let activity = serde_json::json!({ + "@context": [CONTEXT, SECURITY_CONTEXT], + "id": format!("{}/move/{}", ap.actor_uri, uuid::Uuid::new_v4()), + "type": "Move", + "actor": ap.actor_uri, + "target": target_actor, + "object": ap.actor_uri, + "published": now, + "to": [PUBLIC], + }); + + let payload = serde_json::to_string(&activity).unwrap(); + let dbi = state.db.lock().await; + let inboxes = collect_all_inboxes(&dbi.db); + enqueue_delivery(&dbi.db, &inboxes, &payload); + tracing::info!("published Move to {} ({} recipients)", target_domain, inboxes.len()); +} + +pub async fn publish_actor_update(state: &Arc) { + let ap = match state.ap.as_ref() { + Some(ap) => ap, + None => return, + }; + + let now = chrono::Utc::now().to_rfc3339(); + + let default_bio = "Daily crossword puzzles in five sizes, four game modes, cryptic clues, multiplayer rooms, party games, a built-in puzzle editor for constructors, and a clue trainer for the stubborn."; + + let (display_name, bio, avatar_path, banner_path) = { + let dbi = state.db.lock().await; + let dn = crate::db::get_server_config(&dbi.db, "ap_actor_name") + .unwrap_or_else(|| "crossword".into()); + let b = crate::db::get_server_config(&dbi.db, "ap_bio") + .unwrap_or_else(|| default_bio.into()); + let av = crate::db::get_server_config(&dbi.db, "ap_avatar_path"); + let bn = crate::db::get_server_config(&dbi.db, "ap_banner_path"); + (dn, b, av, bn) + }; + + let mut actor_doc = serde_json::json!({ + "@context": [CONTEXT, SECURITY_CONTEXT], + "id": ap.actor_uri, + "type": "Application", + "preferredUsername": ap.actor_name, + "name": display_name, + "summary": bio, + "inbox": format!("{}/inbox", ap.actor_uri), + "outbox": format!("{}/outbox", ap.actor_uri), + "followers": format!("{}/followers", ap.actor_uri), + "publicKey": { + "id": format!("{}#main-key", ap.actor_uri), + "owner": ap.actor_uri, + "publicKeyPem": ap.keys.public_key_pem, + }, + }); + + if avatar_path.is_some() { + actor_doc["icon"] = serde_json::json!({ "type": "Image", "url": format!("https://{}/avatar", ap.domain) }); + } + if banner_path.is_some() { + actor_doc["image"] = serde_json::json!({ "type": "Image", "url": format!("https://{}/banner", ap.domain) }); + } + + let activity_id = format!("{}/update/{}", ap.actor_uri, uuid::Uuid::new_v4()); + let activity = serde_json::json!({ + "@context": [CONTEXT, SECURITY_CONTEXT], + "id": activity_id, + "type": "Update", + "actor": ap.actor_uri, + "published": now, + "to": [PUBLIC], + "cc": [format!("{}/followers", ap.actor_uri)], + "object": actor_doc, + }); + + let payload = serde_json::to_string(&activity).unwrap(); + let dbi = state.db.lock().await; + let inboxes = collect_all_inboxes(&dbi.db); + enqueue_delivery(&dbi.db, &inboxes, &payload); + tracing::info!("published actor Update to {} recipients", inboxes.len()); +} +