1use 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 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 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 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 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 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 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; }
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 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 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 _ => {}
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
329async 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
336const PROFILE_INDEXERS: &[&str] = &["wss://purplepag.es", "wss://relay.nostr.band", "wss://relay.damus.io", "wss://nos.lol"];
341
342async 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
373async 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
388async 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 _ => {
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
420async 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
441async 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
458fn 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
467fn 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}