vector_sdk 0.8.2

Ergonomic Rust SDK for building Vector bots and clients on top of vector-core.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
//! **Concordia** — a multi-purpose Concord (v2) community bot.
//!
//! Joins communities by invite link, answers a command console covering every
//! SDK send-path (message, threaded reply, reaction, edit, delete, typing,
//! encrypted file), and reports community diagnostics (members, channels,
//! roles, capabilities). Non-command chatter is logged but never replied to,
//! so it can sit quietly in busy channels.
//!
//! ```sh
//! # First run: join a community and (optionally) set the profile in one go.
//! CONCORDIA_AVATAR=~/Downloads/concord-icon.webp \
//!   cargo run -p vector_sdk --example concordia -- "https://…/invite/naddr1…#token"
//!
//! # Later runs: identity + memberships persist, no arguments needed.
//! cargo run -p vector_sdk --example concordia
//! ```
//!
//! Identity lives at `<data_dir>/identity.nsec` (created on first run; back it
//! up — that file IS the bot). `VECTOR_NSEC` overrides it. Set
//! `CONCORDIA_AVATAR=<image path>` on any run to (re)publish the bot profile
//! with that avatar.

use std::time::{SystemTime, UNIX_EPOCH};
use vector_sdk::{BotEvent, VectorBot};

const NAME: &str = "Concordia";
const ABOUT: &str = "A multi-purpose Concord community bot. Type / (or !help) for commands.";

const HELP: &str = "\
Concordia — a multi-purpose Concord bot (type / in a modern client for the picker):
  /help              — this menu
  /ping              — pong (send round-trip)
  /roll [sides]      — roll a die
  /announce <t> <b>  — format a two-part announcement
  /typetest …        — echo back every param type, parsed (the full gauntlet)
  /greet <who> <style> [times] — greet a member (user + choice + int)
  /calc <a> <op> <b> — arithmetic (number + choice + number)
  /reply             — a threaded reply to your message (reply context)
  /react [emoji]     — react to your message
  /edit              — send a message, then edit it
  /delete            — send a message, then delete it
  /typing            — emit a typing indicator
  /file              — send a small text attachment (encrypt → Blossom → imeta)
  /members           — the folded member list
  /channels          — the channels I can see
  /caps              — my capabilities here (roles engine)
  /roles             — the community roster
  /info              — community id, protocol version, owner, channel count
  /whoami            — my npub + this channel id
  /reconnect         — bounce my relay sockets (reconnect drill)
  (legacy !commands still work; non-command messages are ignored)";

#[tokio::main]
async fn main() -> vector_sdk::Result<()> {
    // Identity: VECTOR_NSEC override, else the persisted <data_dir>/identity.nsec
    // (created on first run by the builder). Public invite policy: any member can
    // pull the bot into their community with a direct invite (v1 or v2).
    let mut builder = VectorBot::builder().public();
    if let Ok(nsec) = std::env::var("VECTOR_NSEC") {
        builder = builder.nsec(nsec);
    }
    let bot = builder.build().await?;
    println!("── {NAME} online as {}", bot.npub());

    // Optional one-shot profile publish: upload the avatar plainly (avatars are
    // public) and publish the kind-0 with the bot flag. Existing fields are
    // carried forward by the profile pipeline, so this never clobbers. Deferred
    // until after listen() has connected the community relays — a kind-0 that
    // only reaches the login relays is invisible to community peers' clients.
    if let Ok(avatar_path) = std::env::var("CONCORDIA_AVATAR") {
        let bot = bot.clone();
        tokio::spawn(async move {
            tokio::time::sleep(std::time::Duration::from_secs(10)).await;
            let avatar_path = shellexpand_home(&avatar_path);
            println!("── uploading avatar {avatar_path}");
            match bot.core().upload_public_image(&avatar_path).await {
                Ok(url) => {
                    let ok = bot.core().update_bot_profile(NAME, &url, "", ABOUT).await;
                    println!("── profile publish {}  avatar={url}", if ok { "" } else { "FAILED" });
                    if ok {
                        push_profile_to_communities().await;
                    }
                }
                Err(e) => eprintln!("!! avatar upload failed: {e}"),
            }
        });
    }

    // Optional invite link: join on first run; later runs come up with the
    // persisted memberships and need no arguments.
    if let Some(invite) = std::env::args().nth(1).or_else(|| std::env::var("VECTOR_INVITE").ok()) {
        println!("── joining via link…");
        match bot.core().join_community(&invite).await {
            Ok(summary) => {
                let name = summary.get("name").and_then(|v| v.as_str()).unwrap_or("?");
                let ver = summary.get("version").and_then(|v| v.as_u64()).unwrap_or(0);
                println!("── joined \"{name}\"  protocol=v{ver}");
            }
            Err(e) => {
                eprintln!("!! join failed: {e}");
                return Err(e);
            }
        }
    }

    // Slash commands (Bot Interface Phase 1): registered here, published as the
    // kind-10304 manifest at listen start, invoked by PLAIN TEXT from any client.
    // Vector's `/` picker renders exactly this registry with argument hints; a
    // matched invocation is consumed before the legacy !bang console below.
    bot.command("help", "List everything Concordia can do").run(|ctx| async move {
        let _ = ctx.reply(HELP).await;
    });
    bot.command("ping", "Round-trip latency check").run(|ctx| async move {
        let _ = ctx.reply(format!("pong 🏓 ({} ms)", now_ms())).await;
    });
    bot.command("roll", "Roll a die")
        .int("sides", "How many sides (default 6)", false)
        .run(|ctx| async move {
            let sides = ctx.int("sides").unwrap_or(6).clamp(2, 1_000_000);
            let roll = (now_ms() as i64 % sides) + 1;
            let _ = ctx.reply(format!("🎲 rolled {roll} on a d{sides}")).await;
        });
    bot.command("announce", "Format a two-part announcement")
        .string("title", "Announcement title", true)
        .string("body", "Announcement body", true)
        .run(|ctx| async move {
            let title = ctx.str("title").unwrap_or("(untitled)").to_string();
            let body = ctx.str("body").unwrap_or_default().to_string();
            let _ = ctx.reply(format!("📣 {title}\n{body}")).await;
        });
    // ── Param-type test battery: every ArgType, solo and mixed ──────────────
    bot.command("typetest", "Echo back every param type, parsed")
        .string("text", "Any free text", true)
        .int("count", "A whole number", true)
        .number("ratio", "A decimal number", true)
        .flag("loud", "true or false", true)
        .user("who", "Any user (npub)", true)
        .choice("color", "Pick a color", ["red", "green", "blue"], true)
        .run(|ctx| async move {
            let report = format!(
                "typetest received:\n\
                 text   (String) = {:?}\n\
                 count  (Int)    = {}\n\
                 ratio  (Number) = {}\n\
                 loud   (Bool)   = {}\n\
                 who    (User)   = {}\n\
                 color  (Choice) = {}",
                ctx.str("text").unwrap_or_default(),
                ctx.int("count").unwrap_or_default(),
                ctx.number("ratio").unwrap_or_default(),
                ctx.flag("loud").unwrap_or_default(),
                ctx.str("who").unwrap_or_default(),
                ctx.str("color").unwrap_or_default(),
            );
            let _ = ctx.reply(report).await;
        });
    bot.command("greet", "Greet a member in a chosen style")
        .user("who", "Who to greet", true)
        .choice("style", "Greeting style", ["formal", "casual", "pirate"], true)
        .int("times", "Repeat 1-5 times (default 1)", false)
        .run(|ctx| async move {
            let who = ctx.str("who").unwrap_or_default().to_string();
            let line = match ctx.str("style").unwrap_or("casual") {
                "formal" => format!("Good day to you, {who}."),
                "pirate" => format!("Ahoy, {who}! 🏴‍☠️"),
                _ => format!("yo {who} 👋"),
            };
            let times = ctx.int("times").unwrap_or(1).clamp(1, 5) as usize;
            let _ = ctx.reply(vec![line; times].join("\n")).await;
        });
    bot.command("calc", "Do some arithmetic")
        .number("a", "First operand", true)
        .choice("op", "Operation", ["add", "sub", "mul", "div"], true)
        .number("b", "Second operand", true)
        .run(|ctx| async move {
            let a = ctx.number("a").unwrap_or_default();
            let b = ctx.number("b").unwrap_or_default();
            let answer = match ctx.str("op").unwrap_or_default() {
                "add" => Some(a + b),
                "sub" => Some(a - b),
                "mul" => Some(a * b),
                "div" if b != 0.0 => Some(a / b),
                _ => None,
            };
            let _ = ctx
                .reply(match answer {
                    Some(v) => format!("🧮 {v}"),
                    None => "cannot divide by zero".to_string(),
                })
                .await;
        });

    bot.command("reply", "Get a threaded reply to your message").run(|ctx| async move {
        let _ = ctx.reply("this is a threaded reply ✅ (I quoted your message)").await;
    });
    bot.command("react", "React to your message")
        .choice("emoji", "Which reaction", ["🔥", "👍", "❤️", "😂", "🫡"], false)
        .run(|ctx| async move {
            let emoji = ctx.str("emoji").unwrap_or("🔥").to_string();
            log_err("react", ctx.msg.react(&emoji).await.map(|_| String::new()));
        });
    bot.command("edit", "Watch me send then edit a message").run(|ctx| async move {
        let ch = ctx.msg.channel();
        if let Ok(id) = ch.send("editing this in one second…").await {
            log_err("edit", ch.edit(&id, "edited ✏️ (this text was changed)").await.map(|_| String::new()));
        }
    });
    bot.command("delete", "Watch a message self-destruct").run(|ctx| async move {
        let ch = ctx.msg.channel();
        if let Ok(id) = ch.send("…this message will self-destruct").await {
            log_err("delete", ch.delete(&id).await.map(|_| String::new()));
        }
    });
    bot.command("typing", "Emit a typing indicator").run(|ctx| async move {
        log_err("typing", ctx.msg.channel().typing().await.map(|_| String::new()));
    });
    bot.command("file", "Receive a small encrypted attachment").run(|ctx| async move {
        send_test_file(&ctx.msg.channel()).await;
    });
    bot.command("members", "The folded member list").run(|ctx| async move {
        if let Some(community) = ctx.msg.community() {
            let members = community.members().await;
            let list: Vec<String> = members.iter().map(|m| short(m.npub())).collect();
            let _ = ctx.reply(format!("{} member(s): {}", members.len(), list.join(", "))).await;
        } else {
            let _ = ctx.reply("not in a community here").await;
        }
    });
    bot.command("channels", "The channels I can see here").run(|ctx| async move {
        diagnostics(&ctx.bot, &ctx.msg, "!channels").await;
    });
    bot.command("caps", "My capabilities here (roles engine)").run(|ctx| async move {
        diagnostics(&ctx.bot, &ctx.msg, "!caps").await;
    });
    bot.command("roles", "The community roster").run(|ctx| async move {
        diagnostics(&ctx.bot, &ctx.msg, "!roles").await;
    });
    bot.command("info", "Community protocol + ownership summary").run(|ctx| async move {
        diagnostics(&ctx.bot, &ctx.msg, "!info").await;
    });
    bot.command("whoami", "My npub and this channel id").run(|ctx| async move {
        diagnostics(&ctx.bot, &ctx.msg, "!whoami").await;
    });
    bot.command("reconnect", "Bounce my relay sockets (reconnect drill)").run(|ctx| async move {
        let bounced = bounce_community_relays().await;
        let _ = ctx.reply(format!("bounced {bounced} relay socket(s) — /ping to test the post-reconnect subscription")).await;
    });

    // The join snapshot folds only owner-authored channels; admin-created ones
    // arrive on the first control follow, so re-print once that has landed.
    print_channels(&bot, "channels visible (startup)").await;
    {
        let bot = bot.clone();
        tokio::spawn(async move {
            tokio::time::sleep(std::time::Duration::from_secs(25)).await;
            print_channels(&bot, "channels visible (after control follow)").await;
        });
    }
    println!("── listening. Message me `!help` from your client.\n");

    bot.on_event(|bot, event| async move {
        match event {
            BotEvent::Message(msg) => {
                if msg.is_mine() {
                    return; // never react to our own sends
                }
                let author = short(msg.message.npub.as_deref().unwrap_or("?"));
                let text = msg.text().trim().to_string();
                println!("[MSG]    {author}: {text}");

                // Command console — each arm fires a distinct SDK send-path.
                let ch = msg.channel();
                match text.as_str() {
                    "!help" => reply(&msg, HELP).await,
                    "!ping" => reply(&msg, &format!("pong 🏓 ({} ms)", now_ms())).await,
                    "!reply" => reply(&msg, "this is a threaded reply ✅ (I quoted your message)").await,
                    "!react" => log_err("react", msg.react("🔥").await.map(|_| String::new())),
                    "!typing" => log_err("typing", ch.typing().await.map(|_| String::new())),
                    "!edit" => {
                        if let Ok(id) = ch.send("editing this in one second…").await {
                            log_err("edit", ch.edit(&id, "edited ✏️ (this text was changed)").await.map(|_| String::new()));
                        }
                    }
                    "!delete" => {
                        if let Ok(id) = ch.send("…this message will self-destruct").await {
                            log_err("delete", ch.delete(&id).await.map(|_| String::new()));
                        }
                    }
                    "!file" => send_test_file(&ch).await,
                    "!members" => {
                        if let Some(community) = msg.community() {
                            let members = community.members().await;
                            let list: Vec<String> = members.iter().map(|m| short(m.npub())).collect();
                            reply(&msg, &format!("{} member(s): {}", members.len(), list.join(", "))).await;
                        } else {
                            reply(&msg, "not in a community here").await;
                        }
                    }
                    "!reconnect" => {
                        // Ops/repro tool: bounce this bot's community relay sockets (a
                        // relay restart or idle drop in miniature) — then a !ping proves
                        // whether the subscription survived the fresh AUTH gate.
                        let bounced = bounce_community_relays().await;
                        reply(&msg, &format!("bounced {bounced} relay socket(s) — send !ping to test the post-reconnect subscription")).await;
                    }
                    "!channels" | "!info" | "!caps" | "!roles" | "!whoami" => diagnostics(&bot, &msg, &text).await,
                    other if other.starts_with('!') => reply(&msg, &format!("unknown command `{other}` — try !help")).await,
                    // Non-command chatter is logged above but never replied to, so
                    // the bot can sit in a busy community without spamming echoes.
                    _ => {}
                }
            }
            BotEvent::MessageUpdate { message, .. } => {
                println!("[UPDATE] {}\"{}\"  ({} reaction(s))", short(message.npub.as_deref().unwrap_or("?")), message.content, message.reactions.len());
            }
            BotEvent::Delete { message_id, .. } => println!("[DELETE] message {}", short(&message_id)),
            BotEvent::MemberJoin { npub, .. } => println!("[JOIN]   {}", short(&npub)),
            BotEvent::MemberLeave { npub, .. } => println!("[LEAVE]  {}", short(&npub)),
            BotEvent::Typing { npub, .. } => println!("[TYPING] {}", short(&npub)),
            BotEvent::Invite { community_id } => println!("[INVITE] for community {}", short(&community_id)),
            BotEvent::Removed { community_id } => println!("[REMOVED] from community {} — I was kicked/banned", short(&community_id)),
            BotEvent::Ready { communities } => {
                println!("[READY] live — subscribed across {communities} communities");
            }
            BotEvent::ChannelKeyed { community_id, channel_id, backfilled } => {
                println!("[KEYED] private channel {} in {} is readable now ({backfilled} back-filled)", short(&channel_id), short(&community_id));
                // Back-filled history is NOT delivered as live events — read it.
                if backfilled > 0 {
                    for m in bot.community(&community_id).channel(&channel_id).history(backfilled).await {
                        println!("[KEYED]   history: {}", m.content.chars().take(60).collect::<String>());
                    }
                }
                print_channels(&bot, "channels visible (after key vend)").await;
            }
            _ => {} // BotEvent is non_exhaustive: future events land here
        }
    })
    .await?;

    Ok(())
}

/// Reply (threaded) to the triggering message, logging any failure.
async fn reply(msg: &vector_sdk::IncomingMessage, text: &str) {
    if let Err(e) = msg.reply(text).await {
        eprintln!("!! reply failed: {e}");
    }
}

/// Public relays that index profile metadata for the whole network — the
/// fallback clients use to resolve an author they've never seen. Ditto-family
/// community relays silently drop a stranger's kind-0 (accepted, never stored),
/// so for a bot these indexers are the RELIABLE path to a rendered name+avatar.
const PROFILE_INDEXERS: &[&str] = &["wss://purplepag.es", "wss://relay.nostr.band", "wss://nos.lol"];

/// Community relays are pool-isolated from profile ops by design (the GOSSIP
/// flag keeps pool-wide DM/profile publishes off them), so the kind-0 above
/// only reached the login relays — which community peers' clients never read.
/// Re-target the freshly published metadata at every held community's relays
/// (best-effort; Ditto drops it) plus the profile indexers.
async fn push_profile_to_communities() {
    use nostr_sdk::prelude::{Filter, Kind};
    let Some(client) = vector_core::state::nostr_client() else { return };
    let Some(me) = vector_core::state::my_public_key() else { return };
    let filter = Filter::new().kind(Kind::Metadata).author(me).limit(1);
    let Ok(evs) = client.fetch_events(filter).timeout(std::time::Duration::from_secs(8)).await else {
        eprintln!("!! could not fetch own kind-0 back for the community push");
        return;
    };
    let Some(ev) = evs.into_iter().next() else { return };
    let mut targets: Vec<String> = PROFILE_INDEXERS.iter().map(|s| s.to_string()).collect();
    for id in vector_core::db::community::list_community_ids().unwrap_or_default() {
        if let Ok(Some(c)) = vector_core::db::community::load_community_v2(&id) {
            targets.extend(c.relays.clone());
        }
    }
    for t in &targets {
        let _ = client.add_relay(t).await;
    }
    client.connect().await;
    match client.send_event(&ev).to(targets).await {
        Ok(out) => println!("── profile pushed: stored on {} relay(s), refused by {}", out.success.len(), out.failed.len()),
        Err(e) => eprintln!("!! profile push failed: {e}"),
    }
}

/// Print every v2 community's channel names under `label`.
/// Every channel with its key state. A private channel we've been told about but
/// hold no key for prints as `#name (locked)` — the signal that an admin granted
/// access whose key vend hasn't landed yet, rather than a channel that is simply
/// quiet.
async fn print_channels(bot: &VectorBot, label: &str) {
    for community in bot.communities().await {
        let channels = community.channels().await;
        if channels.is_empty() {
            continue;
        }
        let rendered: Vec<String> = channels
            .iter()
            .map(|ch| match (ch.is_private(), ch.is_readable()) {
                (true, false) => format!("#{} (locked)", ch.name()),
                (true, true) => format!("#{} (private)", ch.name()),
                _ => format!("#{}", ch.name()),
            })
            .collect();
        println!("── {label} in {}: {}", short(community.id()), rendered.join(", "));
    }
}

/// Report richer diagnostics. `!caps`/`!roles`/`!whoami` come off the OO Community;
/// `!channels`/`!info` come off the core facade summary (channels + protocol version).
async fn diagnostics(bot: &VectorBot, msg: &vector_sdk::IncomingMessage, which: &str) {
    let Some(community) = msg.community() else {
        reply(msg, "not in a community here").await;
        return;
    };
    let cid = community.id().to_string();
    let out = match which {
        "!whoami" => format!("me: {}  ·  this channel/community: {}", bot.npub(), cid),
        "!caps" => community.capabilities().map(|v| v.to_string()).unwrap_or_else(|e| format!("caps error: {e}")),
        "!roles" => community.roles().map(|v| v.to_string()).unwrap_or_else(|e| format!("roles error: {e}")),
        _ /* !channels / !info */ => {
            let mut line = format!("community {cid}: (not found in list)");
            for c in bot.core().list_communities().await {
                let id = c.get("id").or_else(|| c.get("community_id")).and_then(|v| v.as_str()).unwrap_or("");
                if id != cid {
                    continue;
                }
                let ver = c.get("version").and_then(|v| v.as_u64()).unwrap_or(0);
                let owner = c.get("is_owner").and_then(|v| v.as_bool()).unwrap_or(false);
                let chans: Vec<String> = c.get("channels").and_then(|v| v.as_array())
                    .map(|a| a.iter().filter_map(|ch| ch.get("name").and_then(|n| n.as_str()).map(String::from)).collect())
                    .unwrap_or_default();
                line = format!("protocol=v{ver}  owner={owner}  channels={chans:?}");
            }
            line
        }
    };
    reply(msg, &out).await;
}

/// Disconnect every held community's relay sockets, then reconnect the pool —
/// the miniature of a relay restart / idle drop. Returns how many were bounced.
async fn bounce_community_relays() -> usize {
    let Some(client) = vector_core::state::nostr_client() else { return 0 };
    let mut bounced = 0;
    for id in vector_core::db::community::list_community_ids().unwrap_or_default() {
        if let Ok(Some(c)) = vector_core::db::community::load_community_v2(&id) {
            for r in &c.relays {
                if let Ok(url) = nostr_sdk::prelude::RelayUrl::parse(r) {
                    if let Ok(Some(relay)) = client.relay(url).await {
                        relay.disconnect();
                        bounced += 1;
                    }
                }
            }
        }
    }
    client.connect().await;
    bounced
}

/// Write a tiny file and send it as an encrypted attachment.
async fn send_test_file(ch: &vector_sdk::Channel) {
    let path = std::env::temp_dir().join(format!("concordia_{}.txt", now_ms()));
    if let Err(e) = std::fs::write(&path, format!("Hello from {NAME}!\nsent at {} ms\n", now_ms())) {
        eprintln!("!! temp file write failed: {e}");
        return;
    }
    log_err("send_file", ch.send_file(&path).await.map(|_| String::new()));
    let _ = std::fs::remove_file(&path);
}

fn log_err(what: &str, r: vector_sdk::Result<String>) {
    if let Err(e) = r {
        eprintln!("!! {what} failed: {e}");
    }
}

/// A short npub/id for readable logs.
fn short(s: &str) -> String {
    if s.len() > 14 {
        format!("{}{}", &s[..10], &s[s.len() - 4..])
    } else {
        s.to_string()
    }
}

/// Expand a leading `~/` so a pasted `~/Downloads/…` path works as expected.
fn shellexpand_home(p: &str) -> String {
    if let Some(rest) = p.strip_prefix("~/") {
        if let Ok(home) = std::env::var("HOME") {
            return format!("{home}/{rest}");
        }
    }
    p.to_string()
}

fn now_ms() -> u64 {
    SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0)
}