itsgoin/crates/cli/src/main.rs
Scott Reimers eabdb7ba4f Phase 2c: remove audience + PostPush + PostNotification + AudienceRequest/Response
v0.6.2 wire fork: every persona-identifying direct push is gone. Public posts
propagate only through the CDN (pull + header-diff neighbor propagation).
Encrypted posts propagate only through pull with merged author-or-recipient
match. There is no remaining sender→recipient traffic correlation signal on
the wire for content.

Protocol (network-breaking):
- Retire MessageType 0x42 (PostNotification), 0x43 (PostPush),
  0x44 (AudienceRequest), 0x45 (AudienceResponse). Their payload structs are
  deleted along with the handlers and senders.
- SocialDisconnectNotice (0x71) / SocialAddressUpdate (0x70) sender
  functions targeting audience are deleted; the existing handlers stay
  (both already dead code on the send side).

Core removals:
- `push_to_audience`, `notify_post`, `push_delete`,
  `push_disconnect_to_audience`, `push_address_update_to_audience`,
  `send_audience_request`, `send_audience_response`, `send_to_audience` —
  all gone from network.rs.
- `handle_post_notification` removed from connection.rs.
- `request_audience`, `approve_audience`, `deny_audience`,
  `remove_audience`, `list_audience_members`, `list_audience` removed from
  Node.
- `audience_pushed` step removed from post creation.
- `AudienceDirection`, `AudienceStatus`, `AudienceRecord`,
  `AudienceApprovalMode` removed from types.
- Storage: `store_audience`, `list_audience`, `list_audience_members`,
  `remove_audience`, `row_to_audience_record`, `audience_crud` test, the
  `audience` CREATE TABLE, and the audience-dependent social route rebuild
  branch all removed. Upgraded DBs retain the orphan `audience` table;
  nothing touches it.

Follow-on cleanups:
- `SocialRelation::Audience` + `::Mutual` collapsed into just `Follow`.
  The Display/FromStr impl accepts legacy "audience"/"mutual" strings from
  pre-v0.6.2 DBs and maps them to Follow.
- Blob-eviction priority function drops the audience factor; relationship
  is now own-author vs followed vs other. Tests updated accordingly.
- `CommentPermission::AudienceOnly` → `FollowersOnly`. Check uses the
  author's public follows (`list_public_follows`) rather than a separate
  audience table. `ModerationMode::AudienceOnly` similarly renamed.
- Follow/unfollow routines simplified: no audience downgrade logic;
  unfollow removes the social route entirely.

UI:
- CLI: `audience*` commands removed.
- Tauri: `AudienceDto`, `list_audience`, `list_audience_outbound`,
  `request_audience`, `approve_audience`, `remove_audience` commands
  removed from invoke_handler. Frontend: audience panel and audience/mutual
  badges removed; compose permission dropdown shows "Followers" instead of
  "Audience"; `loadAudience` is a no-op stub that hides any leftover DOM.

Tests: 111 / 111 core tests pass.

Breaking change: v0.6.2 nodes won't interoperate with v0.6.1 for delete
propagation, visibility updates, direct post push, post notifications, or
audience requests. Upgrade both ends.
2026-04-22 22:20:02 -04:00

909 lines
39 KiB
Rust

use std::io::{self, BufRead, Write};
use std::net::SocketAddr;
use std::path::Path;
use std::sync::Arc;
use itsgoin_core::node::Node;
use itsgoin_core::types::DeviceProfile;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info,iroh=warn,swarm_discovery=warn".parse().unwrap()),
)
.init();
let args: Vec<String> = std::env::args().collect();
// Parse flags before anything else
let mut data_dir = String::from("./itsgoin-data");
let mut import_key: Option<String> = None;
let mut bind_addr: Option<SocketAddr> = None;
let mut daemon = false;
let mut mobile = false;
let mut web_port: Option<u16> = None;
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"--import-key" => {
if i + 1 >= args.len() {
eprintln!("Error: --import-key requires a 64-char hex key argument");
std::process::exit(1);
}
import_key = Some(args[i + 1].clone());
i += 2;
}
"--bind" => {
if i + 1 >= args.len() {
eprintln!("Error: --bind requires an address argument (e.g. 0.0.0.0:4433)");
std::process::exit(1);
}
bind_addr = Some(args[i + 1].parse().unwrap_or_else(|e| {
eprintln!("Error: invalid bind address '{}': {}", args[i + 1], e);
std::process::exit(1);
}));
i += 2;
}
"--daemon" => {
daemon = true;
i += 1;
}
"--mobile" => {
mobile = true;
i += 1;
}
"--web" => {
if i + 1 >= args.len() {
eprintln!("Error: --web requires a port argument (e.g. --web 8080)");
std::process::exit(1);
}
web_port = Some(args[i + 1].parse().unwrap_or_else(|e| {
eprintln!("Error: invalid web port '{}': {}", args[i + 1], e);
std::process::exit(1);
}));
i += 2;
}
_ => {
if data_dir == "./itsgoin-data" {
data_dir = args[i].clone();
}
i += 1;
}
}
}
// Handle --import-key before opening the node
if let Some(hex_key) = import_key {
println!("Importing identity key into {}...", data_dir);
match Node::import_identity(Path::new(&data_dir), &hex_key) {
Ok(()) => println!("Identity key imported successfully."),
Err(e) => {
eprintln!("Error importing key: {}", e);
std::process::exit(1);
}
}
}
let profile = if mobile { DeviceProfile::Mobile } else { DeviceProfile::Desktop };
println!("Starting ItsGoin node (data: {}, profile: {:?})...", data_dir, profile);
let node = Arc::new(Node::open_with_bind(&data_dir, bind_addr, profile).await?);
// Wait briefly for address resolution
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
let node_id_hex = hex::encode(node.node_id);
let addr = node.endpoint_addr();
let sockets: Vec<_> = addr.ip_addrs().collect();
// Show our display name if set
let my_name = node.get_display_name(&node.node_id).await.unwrap_or(None);
let name_display = my_name.as_deref().unwrap_or("(not set)");
println!();
println!("========================================");
println!(" ItsGoin node running");
println!(" Name: {}", name_display);
println!(" Node ID: {}", node_id_hex);
for sock in &sockets {
println!(" Listen: {}", sock);
}
println!("========================================");
// Print a connect string others can paste
if let Some(sock) = sockets.first() {
println!(" Share this to connect:");
println!(" connect {}@{}", node_id_hex, sock);
}
println!();
println!("Commands:");
println!(" post <text> Create a public post");
println!(" post-to <target> <text> Encrypted post (circle name, node_id, or 'friends')");
println!(" feed Show your feed");
println!(" posts Show all known posts");
println!(" follow <node_id> Follow a node");
println!(" unfollow <node_id> Unfollow a node");
println!(" follows List followed nodes");
println!(" connect <id>@<ip:port> Connect to a peer and sync");
println!(" connect <node_id> Connect via address resolution");
println!(" sync Sync with all known peers");
println!(" peers List known peers");
println!(" circles List circles");
println!(" create-circle <name> Create a circle");
println!(" delete-circle <name> Delete a circle");
println!(" add-to-circle <circle> <node_id> Add member to circle");
println!(" remove-from-circle <circle> <id> Remove member from circle");
println!(" delete <post_id_hex> Delete one of your posts");
println!(" revoke <id> <node_id> [mode] Revoke access (mode: sync|reencrypt)");
println!(" revoke-circle <circle> <nid> [m] Revoke circle access for a node");
println!(" redundancy Show replica counts for your posts");
println!(" worm <node_id> Worm lookup (find peer beyond 3-hop map)");
println!(" connections Show mesh connections");
println!(" social-routes Show social routing cache");
println!(" name <display_name> Set your display name");
println!(" stats Show node stats");
println!(" export-key Export identity key (KEEP SECRET)");
println!(" id Show this node's ID");
println!(" quit Shut down");
println!();
// Start background tasks (v2: mesh connections)
let _accept_handle = node.start_accept_loop();
let _pull_handle = node.start_pull_cycle(300); // 5 min pull cycle
let _diff_handle = node.start_diff_cycle(120); // 2 min routing diff
let _rebalance_handle = node.start_rebalance_cycle(600); // 10 min rebalance
let _growth_handle = node.start_growth_loop(); // reactive mesh growth
let _recovery_handle = node.start_recovery_loop(); // reactive anchor reconnect on mesh loss
let _checkin_handle = node.start_social_checkin_cycle(3600); // 1 hour social checkin
let _anchor_handle = node.start_anchor_register_cycle(600); // 10 min anchor register
let _upnp_handle = node.start_upnp_renewal_cycle(); // UPnP lease renewal (if mapped)
let _upnp_tcp_handle = node.start_upnp_tcp_renewal_cycle(); // UPnP TCP lease renewal
let _http_handle = node.start_http_server(); // HTTP post delivery (if publicly reachable)
let _bootstrap_check = node.start_bootstrap_connectivity_check(); // 24h isolation check
let _replication_handle = node.start_replication_cycle(600); // 10 min active replication
// Start blob eviction cycle (every 5 min, default 1 GB cache limit)
let cache_max_bytes: u64 = {
let storage = node.storage.get().await;
storage.get_setting("cache_size_bytes")
.ok()
.flatten()
.and_then(|s| s.parse().ok())
.unwrap_or(1_073_741_824u64)
};
let _eviction_handle = Node::start_eviction_cycle(Arc::clone(&node), 300, cache_max_bytes);
let _web_handle = if let Some(wp) = web_port {
Some(node.start_web_handler(wp))
} else {
None
};
if daemon {
println!("Running as daemon. Press Ctrl+C to stop.");
tokio::signal::ctrl_c().await?;
println!("Shutting down...");
return Ok(());
}
// Interactive command loop
let stdin = io::stdin();
let reader = stdin.lock();
print!("> ");
io::stdout().flush()?;
for line in reader.lines() {
let line = line?;
let line = line.trim().to_string();
if line.is_empty() {
print!("> ");
io::stdout().flush()?;
continue;
}
let parts: Vec<&str> = line.splitn(2, ' ').collect();
let cmd = parts[0];
let arg = parts.get(1).map(|s| s.trim());
match cmd {
"post" => {
if let Some(text) = arg {
match node.create_post(text.to_string()).await {
Ok((id, _post)) => {
println!("Posted! ID: {}", hex::encode(id));
}
Err(e) => println!("Error: {}", e),
}
} else {
println!("Usage: post <text>");
}
}
"feed" => match node.get_feed().await {
Ok(posts) => {
if posts.is_empty() {
println!("(feed is empty - follow some nodes first)");
}
for (id, post, vis, decrypted) in posts {
print_post(&id, &post, &vis, decrypted.as_deref(), &node).await;
}
}
Err(e) => println!("Error: {}", e),
},
"posts" => match node.get_all_posts().await {
Ok(posts) => {
if posts.is_empty() {
println!("(no posts yet)");
}
for (id, post, vis, decrypted) in posts {
print_post(&id, &post, &vis, decrypted.as_deref(), &node).await;
}
}
Err(e) => println!("Error: {}", e),
},
"follow" => {
if let Some(id_hex) = arg {
match itsgoin_core::parse_node_id_hex(id_hex) {
Ok(nid) => {
node.follow(&nid).await?;
println!("Following {}", &id_hex[..16.min(id_hex.len())]);
}
Err(e) => println!("Invalid node ID: {}", e),
}
} else {
println!("Usage: follow <node_id_hex>");
}
}
"unfollow" => {
if let Some(id_hex) = arg {
match itsgoin_core::parse_node_id_hex(id_hex) {
Ok(nid) => {
node.unfollow(&nid).await?;
println!("Unfollowed {}", &id_hex[..16.min(id_hex.len())]);
}
Err(e) => println!("Invalid node ID: {}", e),
}
} else {
println!("Usage: unfollow <node_id_hex>");
}
}
"follows" => match node.list_follows().await {
Ok(follows) => {
if follows.is_empty() {
println!("(not following anyone)");
}
for nid in follows {
let name = node.get_display_name(&nid).await.unwrap_or(None);
let label = if nid == node.node_id { " (you)" } else { "" };
if let Some(name) = name {
println!(" {} ({}){}", name, &hex::encode(nid)[..12], label);
} else {
println!(" {}{}", hex::encode(nid), label);
}
}
}
Err(e) => println!("Error: {}", e),
},
"connect" => {
if let Some(addr_str) = arg {
if addr_str.contains('@') {
// Full connect string: node_id@ip:port
match itsgoin_core::parse_connect_string(addr_str) {
Ok((nid, endpoint_addr)) => {
println!("Connecting and syncing...");
node.add_peer(nid).await?;
node.follow(&nid).await?;
match node.sync_with_addr(endpoint_addr).await {
Ok(()) => {
let name = node.get_display_name(&nid).await.unwrap_or(None);
if let Some(name) = name {
println!("Sync complete with {}!", name);
} else {
println!("Sync complete!");
}
}
Err(e) => println!("Sync failed: {}", e),
}
}
Err(e) => println!("Invalid address: {}", e),
}
} else {
// Node ID only: use address resolution (N2/N3 + worm)
match itsgoin_core::parse_node_id_hex(addr_str) {
Ok(nid) => {
println!("Resolving address...");
node.add_peer(nid).await?;
node.follow(&nid).await?;
match node.sync_with(nid).await {
Ok(()) => {
let name = node.get_display_name(&nid).await.unwrap_or(None);
if let Some(name) = name {
println!("Connected to {}!", name);
} else {
println!("Connected!");
}
}
Err(e) => println!("Address resolution failed: {}", e),
}
}
Err(e) => println!("Invalid node ID: {}", e),
}
}
} else {
println!("Usage: connect <node_id@ip:port> or connect <node_id>");
}
}
"sync" => {
println!("Syncing with all known peers...");
match node.sync_all().await {
Ok(()) => println!("Sync complete!"),
Err(e) => println!("Sync error: {}", e),
}
}
"post-to" => {
if let Some(rest) = arg {
let parts: Vec<&str> = rest.splitn(2, ' ').collect();
if parts.len() < 2 {
println!("Usage: post-to <target> <text>");
println!(" target: 'friends', circle name, or node_id hex");
} else {
let target = parts[0];
let text = parts[1];
let intent = if target == "friends" {
itsgoin_core::types::VisibilityIntent::Friends
} else if let Ok(nid) = itsgoin_core::parse_node_id_hex(target) {
itsgoin_core::types::VisibilityIntent::Direct(vec![nid])
} else {
itsgoin_core::types::VisibilityIntent::Circle(target.to_string())
};
match node
.create_post_with_visibility(text.to_string(), intent, vec![])
.await
{
Ok((id, _post, _vis)) => {
println!("Encrypted post! ID: {}", hex::encode(id));
}
Err(e) => println!("Error: {}", e),
}
}
} else {
println!("Usage: post-to <target> <text>");
}
}
"circles" => match node.list_circles().await {
Ok(circles) => {
if circles.is_empty() {
println!("(no circles)");
}
for c in circles {
println!(
" {} ({} members, created {})",
c.name,
c.members.len(),
chrono_lite(c.created_at / 1000)
);
for m in &c.members {
let name = node.get_display_name(m).await.unwrap_or(None);
let label = name.unwrap_or_else(|| hex::encode(m)[..12].to_string());
println!(" - {}", label);
}
}
}
Err(e) => println!("Error: {}", e),
},
"create-circle" => {
if let Some(name) = arg {
match node.create_circle(name.to_string()).await {
Ok(()) => println!("Circle '{}' created", name),
Err(e) => println!("Error: {}", e),
}
} else {
println!("Usage: create-circle <name>");
}
}
"delete-circle" => {
if let Some(name) = arg {
match node.delete_circle(name.to_string()).await {
Ok(()) => println!("Circle '{}' deleted", name),
Err(e) => println!("Error: {}", e),
}
} else {
println!("Usage: delete-circle <name>");
}
}
"add-to-circle" => {
if let Some(rest) = arg {
let parts: Vec<&str> = rest.splitn(2, ' ').collect();
if parts.len() < 2 {
println!("Usage: add-to-circle <circle_name> <node_id_hex>");
} else {
match itsgoin_core::parse_node_id_hex(parts[1]) {
Ok(nid) => {
match node.add_to_circle(parts[0].to_string(), nid).await {
Ok(()) => println!("Added to circle '{}'", parts[0]),
Err(e) => println!("Error: {}", e),
}
}
Err(e) => println!("Invalid node ID: {}", e),
}
}
} else {
println!("Usage: add-to-circle <circle_name> <node_id_hex>");
}
}
"remove-from-circle" => {
if let Some(rest) = arg {
let parts: Vec<&str> = rest.splitn(2, ' ').collect();
if parts.len() < 2 {
println!("Usage: remove-from-circle <circle_name> <node_id_hex>");
} else {
match itsgoin_core::parse_node_id_hex(parts[1]) {
Ok(nid) => {
match node.remove_from_circle(parts[0].to_string(), nid).await {
Ok(()) => println!("Removed from circle '{}'", parts[0]),
Err(e) => println!("Error: {}", e),
}
}
Err(e) => println!("Invalid node ID: {}", e),
}
}
} else {
println!("Usage: remove-from-circle <circle_name> <node_id_hex>");
}
}
"delete" => {
if let Some(id_hex) = arg {
match itsgoin_core::parse_node_id_hex(id_hex) {
Ok(post_id) => match node.delete_post(&post_id).await {
Ok(()) => println!("Post deleted: {}", &id_hex[..16.min(id_hex.len())]),
Err(e) => println!("Error: {}", e),
},
Err(e) => println!("Invalid post ID: {}", e),
}
} else {
println!("Usage: delete <post_id_hex>");
}
}
"revoke" => {
if let Some(rest) = arg {
let parts: Vec<&str> = rest.splitn(3, ' ').collect();
if parts.len() < 2 {
println!("Usage: revoke <post_id_hex> <node_id_hex> [sync|reencrypt]");
} else {
let mode = match parts.get(2).unwrap_or(&"sync") {
&"reencrypt" => itsgoin_core::types::RevocationMode::ReEncrypt,
_ => itsgoin_core::types::RevocationMode::SyncAccessList,
};
match (
itsgoin_core::parse_node_id_hex(parts[0]),
itsgoin_core::parse_node_id_hex(parts[1]),
) {
(Ok(post_id), Ok(node_id)) => {
match node.revoke_post_access(&post_id, &node_id, mode).await {
Ok(Some(new_id)) => {
println!("Re-encrypted. New post ID: {}", hex::encode(new_id));
}
Ok(None) => println!("Access revoked (sync mode)"),
Err(e) => println!("Error: {}", e),
}
}
(Err(e), _) => println!("Invalid post ID: {}", e),
(_, Err(e)) => println!("Invalid node ID: {}", e),
}
}
} else {
println!("Usage: revoke <post_id_hex> <node_id_hex> [sync|reencrypt]");
}
}
"revoke-circle" => {
if let Some(rest) = arg {
let parts: Vec<&str> = rest.splitn(3, ' ').collect();
if parts.len() < 2 {
println!("Usage: revoke-circle <circle_name> <node_id_hex> [sync|reencrypt]");
} else {
let mode = match parts.get(2).unwrap_or(&"sync") {
&"reencrypt" => itsgoin_core::types::RevocationMode::ReEncrypt,
_ => itsgoin_core::types::RevocationMode::SyncAccessList,
};
match itsgoin_core::parse_node_id_hex(parts[1]) {
Ok(node_id) => {
match node.revoke_circle_access(parts[0], &node_id, mode).await {
Ok(count) => println!("Revoked access on {} posts", count),
Err(e) => println!("Error: {}", e),
}
}
Err(e) => println!("Invalid node ID: {}", e),
}
}
} else {
println!("Usage: revoke-circle <circle_name> <node_id_hex> [sync|reencrypt]");
}
}
"redundancy" => {
match node.get_redundancy_summary().await {
Ok((total, zero, one, two_plus)) => {
println!("Redundancy for your {} authored posts:", total);
println!(" No replicas: {} posts", zero);
println!(" 1 replica: {} posts", one);
println!(" 2+ replicas: {} posts", two_plus);
if zero > 0 {
println!(" WARNING: {} posts have no peer replicas!", zero);
}
}
Err(e) => println!("Error: {}", e),
}
}
"peers" => match node.list_peer_records().await {
Ok(records) => {
if records.is_empty() {
println!("(no known peers)");
}
for rec in records {
let name = node.get_display_name(&rec.node_id).await.unwrap_or(None);
let id_short = &hex::encode(rec.node_id)[..12];
let label = if let Some(name) = name {
format!("{} ({})", name, id_short)
} else {
hex::encode(rec.node_id)
};
let anchor = if rec.is_anchor { " [anchor]" } else { "" };
let addrs = if rec.addresses.is_empty() {
String::from("(mDNS only)")
} else {
rec.addresses.iter().map(|a| a.to_string()).collect::<Vec<_>>().join(", ")
};
let intro = if let Some(ib) = rec.introduced_by {
let ib_name = node.get_display_name(&ib).await.unwrap_or(None);
format!(
" via {}",
ib_name.unwrap_or_else(|| hex::encode(ib)[..12].to_string())
)
} else {
String::new()
};
println!(" {}{} [{}]{}", label, anchor, addrs, intro);
}
}
Err(e) => println!("Error: {}", e),
},
"name" => {
if let Some(display_name) = arg {
match node.set_profile(display_name.to_string(), String::new()).await {
Ok(profile) => {
println!("Display name set to: {}", profile.display_name);
}
Err(e) => println!("Error: {}", e),
}
} else {
// Show current name
match node.my_profile().await {
Ok(Some(profile)) => println!("Your name: {}", profile.display_name),
Ok(None) => println!("No display name set. Usage: name <display_name>"),
Err(e) => println!("Error: {}", e),
}
}
}
"stats" => match node.stats().await {
Ok(stats) => {
println!("Posts: {}", stats.post_count);
println!("Peers: {}", stats.peer_count);
println!("Following: {}", stats.follow_count);
}
Err(e) => println!("Error: {}", e),
},
"personas" => match node.list_posting_identities().await {
Ok(identities) => {
let default = {
let s = node.storage.get().await;
s.get_default_posting_id().ok().flatten()
};
if identities.is_empty() {
println!("(no posting identities)");
}
for id in identities {
let mark = if Some(id.node_id) == default { " *" } else { "" };
let name = if id.display_name.is_empty() { "(unnamed)" } else { &id.display_name };
println!(" {} {} {}{}", &hex::encode(id.node_id)[..12], name, id.created_at, mark);
}
}
Err(e) => println!("Error: {}", e),
},
"create-persona" => {
let name = arg.unwrap_or("").to_string();
match node.create_posting_identity(name).await {
Ok(id) => {
println!("Created posting identity: {}", hex::encode(id.node_id));
}
Err(e) => println!("Error: {}", e),
}
}
"delete-persona" => {
if let Some(id_hex) = arg {
match itsgoin_core::parse_node_id_hex(id_hex) {
Ok(nid) => match node.delete_posting_identity(&nid).await {
Ok(()) => println!("Deleted posting identity"),
Err(e) => println!("Error: {}", e),
},
Err(e) => println!("Invalid node id: {}", e),
}
} else {
println!("Usage: delete-persona <node_id>");
}
}
"post-as" => {
if let Some(rest) = arg {
let parts: Vec<&str> = rest.splitn(2, ' ').collect();
if parts.len() < 2 {
println!("Usage: post-as <posting_id_hex> <text>");
} else {
match itsgoin_core::parse_node_id_hex(parts[0]) {
Ok(nid) => {
match node
.create_post_as(&nid, parts[1].to_string(),
itsgoin_core::types::VisibilityIntent::Public, vec![])
.await
{
Ok((id, _, _)) => println!("Posted as persona! ID: {}", hex::encode(id)),
Err(e) => println!("Error: {}", e),
}
}
Err(e) => println!("Invalid node id: {}", e),
}
}
} else {
println!("Usage: post-as <posting_id_hex> <text>");
}
}
"export-key" => {
match node.export_identity_hex() {
Ok(hex_key) => {
println!("WARNING: This is your SECRET identity key. Anyone with");
println!("this key can impersonate you. Keep it safe!");
println!();
println!("{}", hex_key);
println!();
println!("To import on another device, use:");
println!(" itsgoin <data_dir> --import-key {}", hex_key);
}
Err(e) => println!("Error: {}", e),
}
}
"id" => {
let addr = node.endpoint_addr();
let sockets: Vec<_> = addr.ip_addrs().collect();
println!("Node ID: {}", node_id_hex);
for sock in &sockets {
if let Some(first_sock) = sockets.first() {
println!("Connect: {}@{}", node_id_hex, first_sock);
break;
}
println!("Listen: {}", sock);
}
}
"worm" => {
if let Some(id_hex) = arg {
match itsgoin_core::parse_node_id_hex(id_hex) {
Ok(nid) => {
println!("Worm lookup for {}...", &id_hex[..16.min(id_hex.len())]);
let start = std::time::Instant::now();
match node.worm_lookup(&nid).await {
Ok(Some(wr)) => {
let elapsed = start.elapsed();
if wr.node_id == nid {
println!("Found! ({:.1}ms)", elapsed.as_secs_f64() * 1000.0);
} else {
println!("Found via recent peer {}! ({:.1}ms)",
&hex::encode(wr.node_id)[..12],
elapsed.as_secs_f64() * 1000.0);
}
if wr.addresses.is_empty() {
println!(" (no address resolved)");
} else {
for addr in &wr.addresses {
println!(" Address: {}", addr);
}
}
println!(" Reporter: {}", &hex::encode(wr.reporter)[..12]);
}
Ok(None) => {
let elapsed = start.elapsed();
println!("Not found ({:.1}ms)", elapsed.as_secs_f64() * 1000.0);
}
Err(e) => println!("Error: {}", e),
}
}
Err(e) => println!("Invalid node ID: {}", e),
}
} else {
println!("Usage: worm <node_id_hex>");
}
}
"connections" => {
let conns = node.list_connections().await;
if conns.is_empty() {
println!("(no mesh connections)");
} else {
println!("Mesh connections ({}):", conns.len());
for (nid, slot_kind, connected_at) in conns {
let name = node.get_display_name(&nid).await.unwrap_or(None);
let id_short = &hex::encode(nid)[..12];
let label = name.map(|n| format!("{} ({})", n, id_short))
.unwrap_or_else(|| hex::encode(nid));
let duration_secs = {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
(now.saturating_sub(connected_at)) / 1000
};
println!(" {} [{:?}] connected {}s ago", label, slot_kind, duration_secs);
}
}
}
"social-routes" => {
match node.list_social_routes().await {
Ok(routes) => {
if routes.is_empty() {
println!("(no social routes)");
} else {
println!("Social routes ({}):", routes.len());
for r in routes {
let name = node.get_display_name(&r.node_id).await.unwrap_or(None);
let id_short = &hex::encode(r.node_id)[..12];
let label = name.map(|n| format!("{} ({})", n, id_short))
.unwrap_or_else(|| hex::encode(r.node_id));
let addrs = if r.addresses.is_empty() {
String::from("(no addr)")
} else {
r.addresses.iter().map(|a| a.to_string()).collect::<Vec<_>>().join(", ")
};
println!(" {} [{:?}] {} [{}] via {:?} peers:{}",
label, r.relation, r.status, addrs,
r.reach_method, r.peer_addresses.len());
}
}
}
Err(e) => println!("Error: {}", e),
}
}
"quit" | "exit" | "q" => {
println!("Shutting down...");
break;
}
_ => {
println!("Unknown command: {}", cmd);
}
}
print!("> ");
io::stdout().flush()?;
}
Ok(())
}
async fn print_post(
id: &[u8; 32],
post: &itsgoin_core::types::Post,
vis: &itsgoin_core::types::PostVisibility,
decrypted: Option<&str>,
node: &Node,
) {
let author_hex = hex::encode(post.author);
let author_short = &author_hex[..12];
let is_me = &post.author == &node.node_id;
let author_name = node.get_display_name(&post.author).await.unwrap_or(None);
let author_label = match (author_name, is_me) {
(Some(name), true) => format!("{} (you)", name),
(Some(name), false) => name,
(None, true) => format!("{} (you)", author_short),
(None, false) => author_short.to_string(),
};
let vis_label = match vis {
itsgoin_core::types::PostVisibility::Public => String::new(),
itsgoin_core::types::PostVisibility::Encrypted { recipients } => {
format!(" [encrypted, {} recipients]", recipients.len())
}
itsgoin_core::types::PostVisibility::GroupEncrypted { epoch, .. } => {
format!(" [group-encrypted, epoch {}]", epoch)
}
};
let ts = post.timestamp_ms / 1000;
let datetime = chrono_lite(ts);
let display_content = match vis {
itsgoin_core::types::PostVisibility::Public => post.content.as_str().to_string(),
itsgoin_core::types::PostVisibility::Encrypted { .. }
| itsgoin_core::types::PostVisibility::GroupEncrypted { .. } => match decrypted {
Some(text) => text.to_string(),
None => "(encrypted)".to_string(),
},
};
println!("---");
println!(" {} | {}{}", author_label, datetime, vis_label);
println!(" {}", display_content);
println!(" id: {}", &hex::encode(id)[..16]);
}
/// Minimal timestamp formatting without pulling in chrono
fn chrono_lite(unix_secs: u64) -> String {
let secs_per_min = 60u64;
let secs_per_hour = 3600u64;
let secs_per_day = 86400u64;
let days_since_epoch = unix_secs / secs_per_day;
let time_of_day = unix_secs % secs_per_day;
let hours = time_of_day / secs_per_hour;
let minutes = (time_of_day % secs_per_hour) / secs_per_min;
let mut year = 1970u64;
let mut remaining_days = days_since_epoch;
loop {
let days_in_year = if is_leap(year) { 366 } else { 365 };
if remaining_days < days_in_year {
break;
}
remaining_days -= days_in_year;
year += 1;
}
let month_days: Vec<u64> = if is_leap(year) {
vec![31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
} else {
vec![31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
};
let mut month = 1u64;
for &d in &month_days {
if remaining_days < d {
break;
}
remaining_days -= d;
month += 1;
}
let day = remaining_days + 1;
format!(
"{:04}-{:02}-{:02} {:02}:{:02} UTC",
year, month, day, hours, minutes
)
}
fn is_leap(year: u64) -> bool {
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
}