ap outbox
This commit is contained in:
@@ -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<String> {
|
||||||
|
crate::db::get_server_config(db, key)
|
||||||
|
.and_then(|json| serde_json::from_str::<Vec<String>>(&json).ok())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_peer_inboxes(db: &rusqlite::Connection) -> Vec<String> {
|
||||||
|
let mut inboxes: Vec<String> = 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<String> {
|
||||||
|
let mut inboxes: Vec<String> = 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<Arc<AppState>>,
|
||||||
|
) -> Result<Json<OrderedCollection>, 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<serde_json::Value> = 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<AppState>, 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 {}. {}<br/><br/>\
|
||||||
|
- Glyph (5x5, very easy) - <a href=\"https://{}/daily/glyph\">https://{}/daily/glyph</a><br/>\
|
||||||
|
- Rune (5x5, easy) - <a href=\"https://{}/daily/rune\">https://{}/daily/rune</a><br/>\
|
||||||
|
- Scroll (9x9, medium) - <a href=\"https://{}/daily/scroll\">https://{}/daily/scroll</a><br/>\
|
||||||
|
- Codex (15x15, hard) - <a href=\"https://{}/daily/codex\">https://{}/daily/codex</a><br/>\
|
||||||
|
- Grimoire (21x21, expert) - <a href=\"https://{}/daily/grimoire\">https://{}/daily/grimoire</a><br/><br/>\
|
||||||
|
{}<br/><br/>\
|
||||||
|
<a href=\"https://{}/tags/crossword\" class=\"mention hashtag\" rel=\"tag\">#<span>crossword</span></a> \
|
||||||
|
<a href=\"https://{}/tags/puzzle\" class=\"mention hashtag\" rel=\"tag\">#<span>puzzle</span></a> \
|
||||||
|
<a href=\"https://{}/tags/cruciverbDaily\" class=\"mention hashtag\" rel=\"tag\">#<span>cruciverbDaily</span></a>",
|
||||||
|
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!("<p>{}</p>", 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<String> = 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<AppState>, 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', "<br/>");
|
||||||
|
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!("<p>{}</p>", 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<AppState>, 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<AppState>,
|
||||||
|
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<serde_json::Value> = 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<String> = {
|
||||||
|
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<AppState>, 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<AppState>, 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<AppState>) {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user