Skip to main content

concordia/
concordia.rs

1//! **Concordia** — a multi-purpose Concord (v2) community bot.
2//!
3//! Joins communities by invite link, answers a command console covering every
4//! SDK send-path (message, threaded reply, reaction, edit, delete, typing,
5//! encrypted file), and reports community diagnostics (members, channels,
6//! roles, capabilities). Non-command chatter is logged but never replied to,
7//! so it can sit quietly in busy channels.
8//!
9//! ```sh
10//! # First run: join a community and (optionally) set the profile in one go.
11//! CONCORDIA_AVATAR=~/Downloads/concord-icon.webp \
12//!   cargo run -p vector_sdk --example concordia -- "https://…/invite/naddr1…#token"
13//!
14//! # Later runs: identity + memberships persist, no arguments needed.
15//! cargo run -p vector_sdk --example concordia
16//! ```
17//!
18//! Identity lives at `<data_dir>/identity.nsec` (created on first run; back it
19//! up — that file IS the bot). `VECTOR_NSEC` overrides it. Set
20//! `CONCORDIA_AVATAR=<image path>` on any run to (re)publish the bot profile
21//! with that avatar.
22
23use std::time::{SystemTime, UNIX_EPOCH};
24use vector_sdk::{BotEvent, VectorBot};
25
26const NAME: &str = "Concordia";
27const ABOUT: &str = "A multi-purpose Concord community bot. Type / (or !help) for commands.";
28
29const HELP: &str = "\
30Concordia — a multi-purpose Concord bot (type / in a modern client for the picker):
31  /help              — this menu
32  /ping              — pong (send round-trip)
33  /roll [sides]      — roll a die
34  /announce <t> <b>  — format a two-part announcement
35  /typetest …        — echo back every param type, parsed (the full gauntlet)
36  /greet <who> <style> [times] — greet a member (user + choice + int)
37  /calc <a> <op> <b> — arithmetic (number + choice + number)
38  /reply             — a threaded reply to your message (reply context)
39  /react [emoji]     — react to your message
40  /edit              — send a message, then edit it
41  /delete            — send a message, then delete it
42  /typing            — emit a typing indicator
43  /file              — send a small text attachment (encrypt → Blossom → imeta)
44  /members           — the folded member list
45  /channels          — the channels I can see
46  /caps              — my capabilities here (roles engine)
47  /roles             — the community roster
48  /info              — community id, protocol version, owner, channel count
49  /whoami            — my npub + this channel id
50  /reconnect         — bounce my relay sockets (reconnect drill)
51  (legacy !commands still work; non-command messages are ignored)";
52
53#[tokio::main]
54async fn main() -> vector_sdk::Result<()> {
55    // Identity: VECTOR_NSEC override, else the persisted <data_dir>/identity.nsec
56    // (created on first run by the builder). Public invite policy: any member can
57    // pull the bot into their community with a direct invite (v1 or v2).
58    let mut builder = VectorBot::builder().public();
59    if let Ok(nsec) = std::env::var("VECTOR_NSEC") {
60        builder = builder.nsec(nsec);
61    }
62    let bot = builder.build().await?;
63    println!("── {NAME} online as {}", bot.npub());
64
65    // Optional one-shot profile publish: upload the avatar plainly (avatars are
66    // public) and publish the kind-0 with the bot flag. Existing fields are
67    // carried forward by the profile pipeline, so this never clobbers. Deferred
68    // until after listen() has connected the community relays — a kind-0 that
69    // only reaches the login relays is invisible to community peers' clients.
70    if let Ok(avatar_path) = std::env::var("CONCORDIA_AVATAR") {
71        let bot = bot.clone();
72        tokio::spawn(async move {
73            tokio::time::sleep(std::time::Duration::from_secs(10)).await;
74            let avatar_path = shellexpand_home(&avatar_path);
75            println!("── uploading avatar {avatar_path}…");
76            match bot.core().upload_public_image(&avatar_path).await {
77                Ok(url) => {
78                    let ok = bot.core().update_bot_profile(NAME, &url, "", ABOUT).await;
79                    println!("── profile publish {}  avatar={url}", if ok { "✅" } else { "FAILED" });
80                    if ok {
81                        push_profile_to_communities().await;
82                    }
83                }
84                Err(e) => eprintln!("!! avatar upload failed: {e}"),
85            }
86        });
87    }
88
89    // Optional invite link: join on first run; later runs come up with the
90    // persisted memberships and need no arguments.
91    if let Some(invite) = std::env::args().nth(1).or_else(|| std::env::var("VECTOR_INVITE").ok()) {
92        println!("── joining via link…");
93        match bot.core().join_community(&invite).await {
94            Ok(summary) => {
95                let name = summary.get("name").and_then(|v| v.as_str()).unwrap_or("?");
96                let ver = summary.get("version").and_then(|v| v.as_u64()).unwrap_or(0);
97                println!("── joined \"{name}\"  protocol=v{ver}");
98            }
99            Err(e) => {
100                eprintln!("!! join failed: {e}");
101                return Err(e);
102            }
103        }
104    }
105
106    // Slash commands (Bot Interface Phase 1): registered here, published as the
107    // kind-10304 manifest at listen start, invoked by PLAIN TEXT from any client.
108    // Vector's `/` picker renders exactly this registry with argument hints; a
109    // matched invocation is consumed before the legacy !bang console below.
110    bot.command("help", "List everything Concordia can do").run(|ctx| async move {
111        let _ = ctx.reply(HELP).await;
112    });
113    bot.command("ping", "Round-trip latency check").run(|ctx| async move {
114        let _ = ctx.reply(format!("pong 🏓 ({} ms)", now_ms())).await;
115    });
116    bot.command("roll", "Roll a die")
117        .int("sides", "How many sides (default 6)", false)
118        .run(|ctx| async move {
119            let sides = ctx.int("sides").unwrap_or(6).clamp(2, 1_000_000);
120            let roll = (now_ms() as i64 % sides) + 1;
121            let _ = ctx.reply(format!("🎲 rolled {roll} on a d{sides}")).await;
122        });
123    bot.command("announce", "Format a two-part announcement")
124        .string("title", "Announcement title", true)
125        .string("body", "Announcement body", true)
126        .run(|ctx| async move {
127            let title = ctx.str("title").unwrap_or("(untitled)").to_string();
128            let body = ctx.str("body").unwrap_or_default().to_string();
129            let _ = ctx.reply(format!("📣 {title}\n{body}")).await;
130        });
131    // ── Param-type test battery: every ArgType, solo and mixed ──────────────
132    bot.command("typetest", "Echo back every param type, parsed")
133        .string("text", "Any free text", true)
134        .int("count", "A whole number", true)
135        .number("ratio", "A decimal number", true)
136        .flag("loud", "true or false", true)
137        .user("who", "Any user (npub)", true)
138        .choice("color", "Pick a color", ["red", "green", "blue"], true)
139        .run(|ctx| async move {
140            let report = format!(
141                "typetest received:\n\
142                 text   (String) = {:?}\n\
143                 count  (Int)    = {}\n\
144                 ratio  (Number) = {}\n\
145                 loud   (Bool)   = {}\n\
146                 who    (User)   = {}\n\
147                 color  (Choice) = {}",
148                ctx.str("text").unwrap_or_default(),
149                ctx.int("count").unwrap_or_default(),
150                ctx.number("ratio").unwrap_or_default(),
151                ctx.flag("loud").unwrap_or_default(),
152                ctx.str("who").unwrap_or_default(),
153                ctx.str("color").unwrap_or_default(),
154            );
155            let _ = ctx.reply(report).await;
156        });
157    bot.command("greet", "Greet a member in a chosen style")
158        .user("who", "Who to greet", true)
159        .choice("style", "Greeting style", ["formal", "casual", "pirate"], true)
160        .int("times", "Repeat 1-5 times (default 1)", false)
161        .run(|ctx| async move {
162            let who = ctx.str("who").unwrap_or_default().to_string();
163            let line = match ctx.str("style").unwrap_or("casual") {
164                "formal" => format!("Good day to you, {who}."),
165                "pirate" => format!("Ahoy, {who}! 🏴‍☠️"),
166                _ => format!("yo {who} 👋"),
167            };
168            let times = ctx.int("times").unwrap_or(1).clamp(1, 5) as usize;
169            let _ = ctx.reply(vec![line; times].join("\n")).await;
170        });
171    bot.command("calc", "Do some arithmetic")
172        .number("a", "First operand", true)
173        .choice("op", "Operation", ["add", "sub", "mul", "div"], true)
174        .number("b", "Second operand", true)
175        .run(|ctx| async move {
176            let a = ctx.number("a").unwrap_or_default();
177            let b = ctx.number("b").unwrap_or_default();
178            let answer = match ctx.str("op").unwrap_or_default() {
179                "add" => Some(a + b),
180                "sub" => Some(a - b),
181                "mul" => Some(a * b),
182                "div" if b != 0.0 => Some(a / b),
183                _ => None,
184            };
185            let _ = ctx
186                .reply(match answer {
187                    Some(v) => format!("🧮 {v}"),
188                    None => "cannot divide by zero".to_string(),
189                })
190                .await;
191        });
192
193    bot.command("reply", "Get a threaded reply to your message").run(|ctx| async move {
194        let _ = ctx.reply("this is a threaded reply ✅ (I quoted your message)").await;
195    });
196    bot.command("react", "React to your message")
197        .choice("emoji", "Which reaction", ["🔥", "👍", "❤️", "😂", "🫡"], false)
198        .run(|ctx| async move {
199            let emoji = ctx.str("emoji").unwrap_or("🔥").to_string();
200            log_err("react", ctx.msg.react(&emoji).await.map(|_| String::new()));
201        });
202    bot.command("edit", "Watch me send then edit a message").run(|ctx| async move {
203        let ch = ctx.msg.channel();
204        if let Ok(id) = ch.send("editing this in one second…").await {
205            log_err("edit", ch.edit(&id, "edited ✏️ (this text was changed)").await.map(|_| String::new()));
206        }
207    });
208    bot.command("delete", "Watch a message self-destruct").run(|ctx| async move {
209        let ch = ctx.msg.channel();
210        if let Ok(id) = ch.send("…this message will self-destruct").await {
211            log_err("delete", ch.delete(&id).await.map(|_| String::new()));
212        }
213    });
214    bot.command("typing", "Emit a typing indicator").run(|ctx| async move {
215        log_err("typing", ctx.msg.channel().typing().await.map(|_| String::new()));
216    });
217    bot.command("file", "Receive a small encrypted attachment").run(|ctx| async move {
218        send_test_file(&ctx.msg.channel()).await;
219    });
220    bot.command("members", "The folded member list").run(|ctx| async move {
221        if let Some(community) = ctx.msg.community() {
222            let members = community.members().await;
223            let list: Vec<String> = members.iter().map(|m| short(m.npub())).collect();
224            let _ = ctx.reply(format!("{} member(s): {}", members.len(), list.join(", "))).await;
225        } else {
226            let _ = ctx.reply("not in a community here").await;
227        }
228    });
229    bot.command("channels", "The channels I can see here").run(|ctx| async move {
230        diagnostics(&ctx.bot, &ctx.msg, "!channels").await;
231    });
232    bot.command("caps", "My capabilities here (roles engine)").run(|ctx| async move {
233        diagnostics(&ctx.bot, &ctx.msg, "!caps").await;
234    });
235    bot.command("roles", "The community roster").run(|ctx| async move {
236        diagnostics(&ctx.bot, &ctx.msg, "!roles").await;
237    });
238    bot.command("info", "Community protocol + ownership summary").run(|ctx| async move {
239        diagnostics(&ctx.bot, &ctx.msg, "!info").await;
240    });
241    bot.command("whoami", "My npub and this channel id").run(|ctx| async move {
242        diagnostics(&ctx.bot, &ctx.msg, "!whoami").await;
243    });
244    bot.command("reconnect", "Bounce my relay sockets (reconnect drill)").run(|ctx| async move {
245        let bounced = bounce_community_relays().await;
246        let _ = ctx.reply(format!("bounced {bounced} relay socket(s) — /ping to test the post-reconnect subscription")).await;
247    });
248
249    // The join snapshot folds only owner-authored channels; admin-created ones
250    // arrive on the first control follow, so re-print once that has landed.
251    print_channels(&bot, "channels visible (startup)").await;
252    {
253        let bot = bot.clone();
254        tokio::spawn(async move {
255            tokio::time::sleep(std::time::Duration::from_secs(25)).await;
256            print_channels(&bot, "channels visible (after control follow)").await;
257        });
258    }
259    println!("── listening. Message me `!help` from your client.\n");
260
261    bot.on_event(|bot, event| async move {
262        match event {
263            BotEvent::Message(msg) => {
264                if msg.is_mine() {
265                    return; // never react to our own sends
266                }
267                let author = short(msg.message.npub.as_deref().unwrap_or("?"));
268                let text = msg.text().trim().to_string();
269                println!("[MSG]    {author}: {text}");
270
271                // Command console — each arm fires a distinct SDK send-path.
272                let ch = msg.channel();
273                match text.as_str() {
274                    "!help" => reply(&msg, HELP).await,
275                    "!ping" => reply(&msg, &format!("pong 🏓 ({} ms)", now_ms())).await,
276                    "!reply" => reply(&msg, "this is a threaded reply ✅ (I quoted your message)").await,
277                    "!react" => log_err("react", msg.react("🔥").await.map(|_| String::new())),
278                    "!typing" => log_err("typing", ch.typing().await.map(|_| String::new())),
279                    "!edit" => {
280                        if let Ok(id) = ch.send("editing this in one second…").await {
281                            log_err("edit", ch.edit(&id, "edited ✏️ (this text was changed)").await.map(|_| String::new()));
282                        }
283                    }
284                    "!delete" => {
285                        if let Ok(id) = ch.send("…this message will self-destruct").await {
286                            log_err("delete", ch.delete(&id).await.map(|_| String::new()));
287                        }
288                    }
289                    "!file" => send_test_file(&ch).await,
290                    "!members" => {
291                        if let Some(community) = msg.community() {
292                            let members = community.members().await;
293                            let list: Vec<String> = members.iter().map(|m| short(m.npub())).collect();
294                            reply(&msg, &format!("{} member(s): {}", members.len(), list.join(", "))).await;
295                        } else {
296                            reply(&msg, "not in a community here").await;
297                        }
298                    }
299                    "!reconnect" => {
300                        // Ops/repro tool: bounce this bot's community relay sockets (a
301                        // relay restart or idle drop in miniature) — then a !ping proves
302                        // whether the subscription survived the fresh AUTH gate.
303                        let bounced = bounce_community_relays().await;
304                        reply(&msg, &format!("bounced {bounced} relay socket(s) — send !ping to test the post-reconnect subscription")).await;
305                    }
306                    "!channels" | "!info" | "!caps" | "!roles" | "!whoami" => diagnostics(&bot, &msg, &text).await,
307                    other if other.starts_with('!') => reply(&msg, &format!("unknown command `{other}` — try !help")).await,
308                    // Non-command chatter is logged above but never replied to, so
309                    // the bot can sit in a busy community without spamming echoes.
310                    _ => {}
311                }
312            }
313            BotEvent::MessageUpdate { message, .. } => {
314                println!("[UPDATE] {} → \"{}\"  ({} reaction(s))", short(message.npub.as_deref().unwrap_or("?")), message.content, message.reactions.len());
315            }
316            BotEvent::Delete { message_id, .. } => println!("[DELETE] message {}", short(&message_id)),
317            BotEvent::MemberJoin { npub, .. } => println!("[JOIN]   {}", short(&npub)),
318            BotEvent::MemberLeave { npub, .. } => println!("[LEAVE]  {}", short(&npub)),
319            BotEvent::Typing { npub, .. } => println!("[TYPING] {}", short(&npub)),
320            BotEvent::Invite { community_id } => println!("[INVITE] for community {}", short(&community_id)),
321            BotEvent::Removed { community_id } => println!("[REMOVED] from community {} — I was kicked/banned", short(&community_id)),
322        }
323    })
324    .await?;
325
326    Ok(())
327}
328
329/// Reply (threaded) to the triggering message, logging any failure.
330async fn reply(msg: &vector_sdk::IncomingMessage, text: &str) {
331    if let Err(e) = msg.reply(text).await {
332        eprintln!("!! reply failed: {e}");
333    }
334}
335
336/// Public relays that index profile metadata for the whole network — the
337/// fallback clients use to resolve an author they've never seen. Ditto-family
338/// community relays silently drop a stranger's kind-0 (accepted, never stored),
339/// so for a bot these indexers are the RELIABLE path to a rendered name+avatar.
340const PROFILE_INDEXERS: &[&str] = &["wss://purplepag.es", "wss://relay.nostr.band", "wss://relay.damus.io", "wss://nos.lol"];
341
342/// Community relays are pool-isolated from profile ops by design (the GOSSIP
343/// flag keeps pool-wide DM/profile publishes off them), so the kind-0 above
344/// only reached the login relays — which community peers' clients never read.
345/// Re-target the freshly published metadata at every held community's relays
346/// (best-effort; Ditto drops it) plus the profile indexers.
347async fn push_profile_to_communities() {
348    use nostr_sdk::prelude::{Filter, Kind};
349    let Some(client) = vector_core::state::nostr_client() else { return };
350    let Some(me) = vector_core::state::my_public_key() else { return };
351    let filter = Filter::new().kind(Kind::Metadata).author(me).limit(1);
352    let Ok(evs) = client.fetch_events(filter, std::time::Duration::from_secs(8)).await else {
353        eprintln!("!! could not fetch own kind-0 back for the community push");
354        return;
355    };
356    let Some(ev) = evs.into_iter().next() else { return };
357    let mut targets: Vec<String> = PROFILE_INDEXERS.iter().map(|s| s.to_string()).collect();
358    for id in vector_core::db::community::list_community_ids().unwrap_or_default() {
359        if let Ok(Some(c)) = vector_core::db::community::load_community_v2(&id) {
360            targets.extend(c.relays.clone());
361        }
362    }
363    for t in &targets {
364        let _ = client.add_relay(t).await;
365    }
366    client.connect().await;
367    match client.send_event_to(targets, &ev).await {
368        Ok(out) => println!("── profile pushed: stored on {} relay(s), refused by {}", out.success.len(), out.failed.len()),
369        Err(e) => eprintln!("!! profile push failed: {e}"),
370    }
371}
372
373/// Print every v2 community's channel names under `label`.
374async fn print_channels(bot: &VectorBot, label: &str) {
375    for c in bot.core().list_communities().await {
376        if c.get("version").and_then(|v| v.as_u64()) == Some(2) {
377            let name = c.get("name").and_then(|v| v.as_str()).unwrap_or("?");
378            let chans: Vec<String> = c
379                .get("channels")
380                .and_then(|v| v.as_array())
381                .map(|a| a.iter().filter_map(|ch| ch.get("name").and_then(|n| n.as_str()).map(String::from)).collect())
382                .unwrap_or_default();
383            println!("── {label} in \"{name}\": {chans:?}");
384        }
385    }
386}
387
388/// Report richer diagnostics. `!caps`/`!roles`/`!whoami` come off the OO Community;
389/// `!channels`/`!info` come off the core facade summary (channels + protocol version).
390async fn diagnostics(bot: &VectorBot, msg: &vector_sdk::IncomingMessage, which: &str) {
391    let Some(community) = msg.community() else {
392        reply(msg, "not in a community here").await;
393        return;
394    };
395    let cid = community.id().to_string();
396    let out = match which {
397        "!whoami" => format!("me: {}  ·  this channel/community: {}", bot.npub(), cid),
398        "!caps" => community.capabilities().map(|v| v.to_string()).unwrap_or_else(|e| format!("caps error: {e}")),
399        "!roles" => community.roles().map(|v| v.to_string()).unwrap_or_else(|e| format!("roles error: {e}")),
400        _ /* !channels / !info */ => {
401            let mut line = format!("community {cid}: (not found in list)");
402            for c in bot.core().list_communities().await {
403                let id = c.get("id").or_else(|| c.get("community_id")).and_then(|v| v.as_str()).unwrap_or("");
404                if id != cid {
405                    continue;
406                }
407                let ver = c.get("version").and_then(|v| v.as_u64()).unwrap_or(0);
408                let owner = c.get("is_owner").and_then(|v| v.as_bool()).unwrap_or(false);
409                let chans: Vec<String> = c.get("channels").and_then(|v| v.as_array())
410                    .map(|a| a.iter().filter_map(|ch| ch.get("name").and_then(|n| n.as_str()).map(String::from)).collect())
411                    .unwrap_or_default();
412                line = format!("protocol=v{ver}  owner={owner}  channels={chans:?}");
413            }
414            line
415        }
416    };
417    reply(msg, &out).await;
418}
419
420/// Disconnect every held community's relay sockets, then reconnect the pool —
421/// the miniature of a relay restart / idle drop. Returns how many were bounced.
422async fn bounce_community_relays() -> usize {
423    let Some(client) = vector_core::state::nostr_client() else { return 0 };
424    let mut bounced = 0;
425    for id in vector_core::db::community::list_community_ids().unwrap_or_default() {
426        if let Ok(Some(c)) = vector_core::db::community::load_community_v2(&id) {
427            for r in &c.relays {
428                if let Ok(url) = nostr_sdk::prelude::RelayUrl::parse(r) {
429                    if let Ok(relay) = client.pool().relay(url).await {
430                        relay.disconnect();
431                        bounced += 1;
432                    }
433                }
434            }
435        }
436    }
437    client.connect().await;
438    bounced
439}
440
441/// Write a tiny file and send it as an encrypted attachment.
442async fn send_test_file(ch: &vector_sdk::Channel) {
443    let path = std::env::temp_dir().join(format!("concordia_{}.txt", now_ms()));
444    if let Err(e) = std::fs::write(&path, format!("Hello from {NAME}!\nsent at {} ms\n", now_ms())) {
445        eprintln!("!! temp file write failed: {e}");
446        return;
447    }
448    log_err("send_file", ch.send_file(&path).await.map(|_| String::new()));
449    let _ = std::fs::remove_file(&path);
450}
451
452fn log_err(what: &str, r: vector_sdk::Result<String>) {
453    if let Err(e) = r {
454        eprintln!("!! {what} failed: {e}");
455    }
456}
457
458/// A short npub/id for readable logs.
459fn short(s: &str) -> String {
460    if s.len() > 14 {
461        format!("{}…{}", &s[..10], &s[s.len() - 4..])
462    } else {
463        s.to_string()
464    }
465}
466
467/// Expand a leading `~/` so a pasted `~/Downloads/…` path works as expected.
468fn shellexpand_home(p: &str) -> String {
469    if let Some(rest) = p.strip_prefix("~/") {
470        if let Ok(home) = std::env::var("HOME") {
471            return format!("{home}/{rest}");
472        }
473    }
474    p.to_string()
475}
476
477fn now_ms() -> u64 {
478    SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0)
479}