use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use iris_chat_core::{
AppAction, AppReconciler, AppState, AppUpdate, ChatKind, ChatMessageSnapshot,
ChatThreadSnapshot, CurrentChatSnapshot, DeliveryState, DeviceAuthorizationState, FfiApp,
GroupDetailsSnapshot,
};
use rusqlite::Connection;
use serde::Serialize;
use serde_json::{json, Value};
#[derive(Parser)]
#[command(name = "iris")]
#[command(version)]
#[command(about = "Iris Chat command line client")]
struct Cli {
#[arg(short, long, global = true)]
json: bool,
#[arg(long, global = true, env = "IRIS_DATA_DIR")]
data_dir: Option<PathBuf>,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Login {
secret_key: String,
},
Restore {
secret_key: String,
},
Logout,
Whoami,
State,
Search {
query: String,
#[arg(short, long, default_value_t = 50)]
limit: usize,
},
Tail {
#[arg(short, long, default_value_t = 20)]
limit: usize,
},
Listen {
#[arg(short, long)]
chat: Option<String>,
#[arg(long, default_value_t = 1000)]
interval_ms: u64,
},
#[command(subcommand)]
Account(AccountCommands),
#[command(subcommand)]
Chat(ChatCommands),
Send {
chat: String,
message: String,
#[arg(long)]
ttl: Option<u64>,
#[arg(long, value_name = "UNIX_SECONDS")]
expires_at: Option<u64>,
},
React {
chat: String,
message_id: String,
emoji: String,
},
Typing {
chat: String,
#[arg(long)]
stop: bool,
},
Receipt {
chat: String,
receipt_type: String,
message_ids: Vec<String>,
},
Read {
chat: String,
#[arg(short, long, default_value_t = 50)]
limit: usize,
},
Seen {
chat: String,
message_ids: Vec<String>,
},
#[command(subcommand)]
Invite(InviteCommands),
#[command(subcommand)]
Link(LinkCommands),
#[command(subcommand)]
Group(GroupCommands),
#[command(subcommand)]
Relay(RelayCommands),
}
#[derive(Subcommand)]
enum AccountCommands {
Create {
#[arg(short, long, default_value = "")]
name: String,
},
Restore {
secret_key: String,
},
Bundle,
}
#[derive(Subcommand)]
enum ChatCommands {
List,
Create {
user_id: String,
},
Open {
chat: String,
},
Read {
chat: String,
#[arg(short, long, default_value_t = 50)]
limit: usize,
},
Send {
chat: String,
message: String,
#[arg(long)]
ttl: Option<u64>,
#[arg(long, value_name = "UNIX_SECONDS")]
expires_at: Option<u64>,
},
Seen {
chat: String,
message_ids: Vec<String>,
},
Delete {
chat: String,
},
Ttl {
chat: String,
seconds: Option<u64>,
},
Mute {
chat: String,
muted: bool,
},
}
#[derive(Subcommand)]
enum InviteCommands {
Create,
Accept { invite: String },
}
#[derive(Subcommand)]
enum LinkCommands {
Create,
Accept { invite: String },
}
#[derive(Subcommand)]
enum GroupCommands {
Create {
name: String,
members: Vec<String>,
},
List,
Show {
group: String,
},
Send {
group: String,
message: String,
},
Read {
group: String,
#[arg(short, long, default_value_t = 50)]
limit: usize,
},
Add {
group: String,
members: Vec<String>,
},
Rename {
group: String,
name: String,
},
}
#[derive(Subcommand)]
enum RelayCommands {
List,
Add { url: String },
Remove { url: String },
Set { urls: Vec<String> },
Reset,
}
#[derive(Clone, Debug, Serialize, serde::Deserialize)]
struct AccountBundle {
owner_nsec: Option<String>,
owner_pubkey_hex: String,
device_nsec: String,
}
#[derive(Default)]
struct CliReconciler {
data_dir: PathBuf,
updates: Mutex<Vec<AppUpdate>>,
}
struct ReconcilerHandle(Arc<CliReconciler>);
impl AppReconciler for ReconcilerHandle {
fn reconcile(&self, update: AppUpdate) {
if let AppUpdate::PersistAccountBundle {
owner_nsec,
owner_pubkey_hex,
device_nsec,
..
} = &update
{
let bundle = AccountBundle {
owner_nsec: owner_nsec.clone(),
owner_pubkey_hex: owner_pubkey_hex.clone(),
device_nsec: device_nsec.clone(),
};
let _ = write_account_bundle(&self.0.data_dir, &bundle);
}
if let Ok(mut updates) = self.0.updates.lock() {
updates.push(update);
}
}
}
struct CliApp {
app: Arc<FfiApp>,
reconciler: Arc<CliReconciler>,
}
#[derive(Serialize)]
struct Envelope<T: Serialize> {
status: &'static str,
command: String,
data: T,
}
#[derive(Serialize)]
struct ErrorEnvelope {
status: &'static str,
error: String,
}
fn main() {
let cli = Cli::parse();
let json_output = cli.json;
if let Err(error) = run(cli) {
if json_output {
println!(
"{}",
serde_json::to_string(&ErrorEnvelope {
status: "error",
error: error.to_string(),
})
.unwrap()
);
} else {
eprintln!("{}", error);
}
std::process::exit(1);
}
}
fn run(cli: Cli) -> Result<()> {
let data_dir = cli.data_dir.unwrap_or_else(default_data_dir);
std::fs::create_dir_all(&data_dir)
.with_context(|| format!("create data dir {}", data_dir.display()))?;
let cli_app = CliApp::open(&data_dir)?;
let command_name = command_name(&cli.command).to_string();
let data = handle_command(&cli_app, &data_dir, cli.command)?;
cli_app.app.shutdown();
print_output(cli.json, &command_name, data)
}
impl CliApp {
fn open(data_dir: &Path) -> Result<Self> {
let app = FfiApp::new(
data_dir.to_string_lossy().to_string(),
String::new(),
env!("CARGO_PKG_VERSION").to_string(),
);
let reconciler = Arc::new(CliReconciler {
data_dir: data_dir.to_path_buf(),
updates: Mutex::new(Vec::new()),
});
app.listen_for_updates(Box::new(ReconcilerHandle(reconciler.clone())));
let cli_app = Self { app, reconciler };
if let Some(bundle) = read_account_bundle(data_dir)? {
cli_app.dispatch_and_wait(
AppAction::RestoreAccountBundle {
owner_nsec: bundle.owner_nsec,
owner_pubkey_hex: bundle.owner_pubkey_hex,
device_nsec: bundle.device_nsec,
},
Duration::from_secs(3),
)?;
}
Ok(cli_app)
}
fn dispatch_and_wait(&self, action: AppAction, timeout: Duration) -> Result<AppState> {
let before = self.app.state().rev;
self.app.dispatch(action);
self.wait_for_quiet_after(before, timeout)
}
fn wait_for_quiet_after(&self, before_rev: u64, timeout: Duration) -> Result<AppState> {
let started = Instant::now();
let mut saw_change = false;
let mut last_rev = before_rev;
let mut stable_since = Instant::now();
while started.elapsed() < timeout {
let state = self.app.state();
if state.rev != last_rev {
saw_change = state.rev != before_rev || saw_change;
last_rev = state.rev;
stable_since = Instant::now();
}
if saw_change && !is_busy(&state) && stable_since.elapsed() >= Duration::from_millis(80)
{
return Ok(state);
}
thread::sleep(Duration::from_millis(20));
}
Ok(self.app.state())
}
fn wait_for_update_count(&self, before_count: usize, timeout: Duration) {
let started = Instant::now();
while started.elapsed() < timeout {
let count = self
.reconciler
.updates
.lock()
.map(|updates| updates.len())
.unwrap_or(before_count);
if count > before_count {
return;
}
thread::sleep(Duration::from_millis(20));
}
}
}
fn handle_command(cli: &CliApp, data_dir: &Path, command: Commands) -> Result<Value> {
match command {
Commands::Login { secret_key } | Commands::Restore { secret_key } => {
restore_account(cli, &secret_key)
}
Commands::Logout => {
cli.dispatch_and_wait(AppAction::Logout, Duration::from_secs(2))?;
let _ = std::fs::remove_file(account_bundle_path(data_dir));
Ok(json!({ "logged_out": true }))
}
Commands::Whoami => Ok(account_json(&require_account(&cli.app.state())?)),
Commands::State => Ok(state_json(&cli.app.state())),
Commands::Search { query, limit } => search_messages(data_dir, &query, limit),
Commands::Tail { limit } => tail_messages(data_dir, limit),
Commands::Listen { chat, interval_ms } => listen(data_dir, chat.as_deref(), interval_ms),
Commands::Account(AccountCommands::Create { name }) => {
cli.dispatch_and_wait(AppAction::CreateAccount { name }, Duration::from_secs(4))?;
let state = cli.app.state();
fail_on_toast(&state)?;
Ok(account_json(&require_account(&state)?))
}
Commands::Account(AccountCommands::Restore { secret_key }) => {
restore_account(cli, &secret_key)
}
Commands::Account(AccountCommands::Bundle) => {
let bundle = read_account_bundle(data_dir)?.context("No saved account bundle.")?;
Ok(json!({
"owner_pubkey_hex": bundle.owner_pubkey_hex,
"has_owner_secret": bundle.owner_nsec.is_some(),
"has_device_secret": !bundle.device_nsec.is_empty(),
}))
}
Commands::Chat(ChatCommands::List) => {
Ok(json!({ "chats": chat_list_json(&cli.app.state()) }))
}
Commands::Chat(ChatCommands::Create { user_id }) => create_chat(cli, &user_id),
Commands::Chat(ChatCommands::Open { chat }) => {
open_chat(cli, &chat).map(|chat| chat_json(&chat, usize::MAX))
}
Commands::Chat(ChatCommands::Read { chat, limit }) | Commands::Read { chat, limit } => {
open_chat(cli, &chat).map(|chat| chat_json(&chat, limit))
}
Commands::Chat(ChatCommands::Send {
chat,
message,
ttl,
expires_at,
})
| Commands::Send {
chat,
message,
ttl,
expires_at,
} => {
let expires_at = message_expiration(ttl, expires_at)?;
send_message(cli, &chat, &message, expires_at)
}
Commands::React {
chat,
message_id,
emoji,
} => react(cli, &chat, &message_id, &emoji),
Commands::Typing { chat, stop } => typing(cli, &chat, stop),
Commands::Receipt {
chat,
receipt_type,
message_ids,
} => receipt(cli, &chat, &receipt_type, message_ids),
Commands::Chat(ChatCommands::Seen { chat, message_ids })
| Commands::Seen { chat, message_ids } => mark_seen(cli, &chat, message_ids),
Commands::Chat(ChatCommands::Delete { chat }) => {
let chat_id = resolve_chat_id(&cli.app.state(), &chat)?;
cli.dispatch_and_wait(
AppAction::DeleteChat {
chat_id: chat_id.clone(),
},
Duration::from_secs(2),
)?;
Ok(json!({ "chat_id": chat_id, "deleted": true }))
}
Commands::Chat(ChatCommands::Ttl { chat, seconds }) => {
let chat_id = resolve_chat_id(&cli.app.state(), &chat)?;
cli.dispatch_and_wait(
AppAction::SetChatMessageTtl {
chat_id: chat_id.clone(),
ttl_seconds: seconds,
},
Duration::from_secs(2),
)?;
Ok(json!({ "chat_id": chat_id, "message_ttl_seconds": seconds }))
}
Commands::Chat(ChatCommands::Mute { chat, muted }) => {
let chat_id = resolve_chat_id(&cli.app.state(), &chat)?;
cli.dispatch_and_wait(
AppAction::SetChatMuted {
chat_id: chat_id.clone(),
muted,
},
Duration::from_secs(2),
)?;
Ok(json!({ "chat_id": chat_id, "muted": muted }))
}
Commands::Invite(InviteCommands::Create) => {
cli.dispatch_and_wait(AppAction::CreatePublicInvite, Duration::from_secs(3))?;
let state = cli.app.state();
fail_on_toast(&state)?;
let invite = state.public_invite.context("No invite was created.")?;
Ok(json!({ "url": invite.url }))
}
Commands::Invite(InviteCommands::Accept { invite }) => {
cli.dispatch_and_wait(
AppAction::AcceptInvite {
invite_input: invite,
},
Duration::from_secs(4),
)?;
let state = cli.app.state();
fail_on_toast(&state)?;
Ok(json!({
"chats": chat_list_json(&state),
"current_chat": state.current_chat.as_ref().map(|chat| chat_summary_json(chat)),
}))
}
Commands::Link(LinkCommands::Create) => {
cli.dispatch_and_wait(
AppAction::StartLinkedDevice {
owner_input: String::new(),
},
Duration::from_secs(3),
)?;
let state = cli.app.state();
fail_on_toast(&state)?;
let link = state.link_device.context("No link code was created.")?;
Ok(json!({
"url": link.url,
"device_input": link.device_input,
}))
}
Commands::Link(LinkCommands::Accept { invite }) => {
cli.dispatch_and_wait(
AppAction::AddAuthorizedDevice {
device_input: invite,
},
Duration::from_secs(4),
)?;
let state = cli.app.state();
fail_on_toast(&state)?;
Ok(json!({
"accepted": true,
"device_roster": state.device_roster.map(|roster| {
json!({
"device_count": roster.devices.len(),
"devices": roster.devices.iter().map(|device| {
json!({
"device_id": device.device_pubkey_hex,
"device_npub": device.device_npub,
"current": device.is_current_device,
"authorized": device.is_authorized,
"stale": device.is_stale,
})
}).collect::<Vec<_>>(),
})
}),
}))
}
Commands::Group(GroupCommands::Create { name, members }) => {
cli.dispatch_and_wait(
AppAction::CreateGroup {
name,
member_inputs: members,
},
Duration::from_secs(4),
)?;
let state = cli.app.state();
fail_on_toast(&state)?;
Ok(json!({
"groups": group_list_json(&state),
"current_chat": state.current_chat.as_ref().map(|chat| chat_summary_json(chat)),
}))
}
Commands::Group(GroupCommands::List) => {
Ok(json!({ "groups": group_list_json(&cli.app.state()) }))
}
Commands::Group(GroupCommands::Show { group }) => show_group(cli, &group),
Commands::Group(GroupCommands::Send { group, message }) => {
send_message(cli, &normalize_group_chat(&group), &message, None)
}
Commands::Group(GroupCommands::Read { group, limit }) => {
open_chat(cli, &normalize_group_chat(&group)).map(|chat| chat_json(&chat, limit))
}
Commands::Group(GroupCommands::Add { group, members }) => {
let group_id = resolve_group_id(&cli.app.state(), &group)?;
cli.dispatch_and_wait(
AppAction::AddGroupMembers {
group_id: group_id.clone(),
member_inputs: members,
},
Duration::from_secs(3),
)?;
show_group(cli, &group_id)
}
Commands::Group(GroupCommands::Rename { group, name }) => {
let group_id = resolve_group_id(&cli.app.state(), &group)?;
cli.dispatch_and_wait(
AppAction::UpdateGroupName {
group_id: group_id.clone(),
name,
},
Duration::from_secs(3),
)?;
show_group(cli, &group_id)
}
Commands::Relay(RelayCommands::List) => {
Ok(json!({ "message_servers": cli.app.state().preferences.nostr_relay_urls }))
}
Commands::Relay(RelayCommands::Add { url }) => {
cli.dispatch_and_wait(
AppAction::AddNostrRelay { relay_url: url },
Duration::from_secs(2),
)?;
Ok(json!({ "message_servers": cli.app.state().preferences.nostr_relay_urls }))
}
Commands::Relay(RelayCommands::Remove { url }) => {
cli.dispatch_and_wait(
AppAction::RemoveNostrRelay { relay_url: url },
Duration::from_secs(2),
)?;
Ok(json!({ "message_servers": cli.app.state().preferences.nostr_relay_urls }))
}
Commands::Relay(RelayCommands::Reset) => {
cli.dispatch_and_wait(AppAction::ResetNostrRelays, Duration::from_secs(2))?;
Ok(json!({ "message_servers": cli.app.state().preferences.nostr_relay_urls }))
}
Commands::Relay(RelayCommands::Set { urls }) => {
cli.dispatch_and_wait(
AppAction::SetNostrRelays { relay_urls: urls },
Duration::from_secs(3),
)?;
Ok(json!({ "message_servers": cli.app.state().preferences.nostr_relay_urls }))
}
}
}
fn restore_account(cli: &CliApp, secret_key: &str) -> Result<Value> {
let before = cli
.reconciler
.updates
.lock()
.map(|updates| updates.len())
.unwrap_or(0);
cli.dispatch_and_wait(
AppAction::RestoreSession {
owner_nsec: secret_key.to_string(),
},
Duration::from_secs(4),
)?;
cli.wait_for_update_count(before, Duration::from_secs(2));
let state = cli.app.state();
fail_on_toast(&state)?;
Ok(account_json(&require_account(&state)?))
}
fn create_chat(cli: &CliApp, user_id: &str) -> Result<Value> {
cli.dispatch_and_wait(
AppAction::CreateChat {
peer_input: user_id.to_string(),
},
Duration::from_secs(3),
)?;
let state = cli.app.state();
fail_on_toast(&state)?;
let chat = state.current_chat.context("No chat was opened.")?;
Ok(chat_json(&chat, usize::MAX))
}
fn open_chat(cli: &CliApp, chat: &str) -> Result<CurrentChatSnapshot> {
let chat_id = resolve_chat_id(&cli.app.state(), chat)?;
cli.dispatch_and_wait(AppAction::OpenChat { chat_id }, Duration::from_secs(2))?;
let state = cli.app.state();
fail_on_toast(&state)?;
state.current_chat.context("No chat is open.")
}
fn send_message(
cli: &CliApp,
chat: &str,
message: &str,
expires_at_secs: Option<u64>,
) -> Result<Value> {
let chat_id = resolve_chat_id(&cli.app.state(), chat)?;
let action = if let Some(expires_at_secs) = expires_at_secs {
AppAction::SendDisappearingMessage {
chat_id: chat_id.clone(),
text: message.to_string(),
expires_at_secs,
}
} else {
AppAction::SendMessage {
chat_id: chat_id.clone(),
text: message.to_string(),
}
};
cli.dispatch_and_wait(action, Duration::from_secs(3))?;
let state = cli.app.state();
fail_on_toast(&state)?;
let current = state.current_chat.context("No chat is open.")?;
let sent = current
.messages
.iter()
.rev()
.find(|item| item.is_outgoing && item.body == message)
.cloned()
.context("Message was not added to the chat.")?;
Ok(message_json(&sent))
}
fn react(cli: &CliApp, chat: &str, message_id: &str, emoji: &str) -> Result<Value> {
let chat_id = resolve_chat_id(&cli.app.state(), chat)?;
cli.dispatch_and_wait(
AppAction::ToggleReaction {
chat_id: chat_id.clone(),
message_id: message_id.to_string(),
emoji: emoji.to_string(),
},
Duration::from_secs(2),
)?;
let current = open_chat(cli, &chat_id)?;
let message = current
.messages
.iter()
.find(|message| message.id == message_id)
.context("Message not found.")?;
Ok(message_json(message))
}
fn typing(cli: &CliApp, chat: &str, stop: bool) -> Result<Value> {
let chat_id = resolve_chat_id(&cli.app.state(), chat)?;
cli.dispatch_and_wait(
AppAction::SetTypingIndicatorsEnabled { enabled: true },
Duration::from_secs(1),
)?;
let action = if stop {
AppAction::StopTyping {
chat_id: chat_id.clone(),
}
} else {
AppAction::SendTyping {
chat_id: chat_id.clone(),
}
};
cli.dispatch_and_wait(action, Duration::from_secs(2))?;
Ok(json!({ "chat_id": chat_id, "typing": !stop }))
}
fn receipt(
cli: &CliApp,
chat: &str,
receipt_type: &str,
message_ids: Vec<String>,
) -> Result<Value> {
let receipt_type = receipt_type.trim().to_ascii_lowercase();
if receipt_type != "delivered" && receipt_type != "seen" {
anyhow::bail!("Receipt type must be delivered or seen.");
}
if receipt_type == "seen" {
return mark_seen(cli, chat, message_ids);
}
let chat_id = resolve_chat_id(&cli.app.state(), chat)?;
cli.dispatch_and_wait(
AppAction::SendReceipt {
chat_id: chat_id.clone(),
receipt_type: receipt_type.clone(),
message_ids: message_ids.clone(),
},
Duration::from_secs(2),
)?;
Ok(json!({
"chat_id": chat_id,
"receipt_type": receipt_type,
"message_ids": message_ids,
}))
}
fn mark_seen(cli: &CliApp, chat: &str, message_ids: Vec<String>) -> Result<Value> {
let current = open_chat(cli, chat)?;
let ids = if message_ids.is_empty() {
current
.messages
.iter()
.filter(|message| !message.is_outgoing)
.map(|message| message.id.clone())
.collect()
} else {
message_ids
};
cli.dispatch_and_wait(
AppAction::MarkMessagesSeen {
chat_id: current.chat_id.clone(),
message_ids: ids.clone(),
},
Duration::from_secs(2),
)?;
Ok(json!({ "chat_id": current.chat_id, "message_ids": ids }))
}
fn message_expiration(ttl: Option<u64>, expires_at: Option<u64>) -> Result<Option<u64>> {
match (ttl, expires_at) {
(Some(_), Some(_)) => anyhow::bail!("Use either --ttl or --expires-at, not both."),
(Some(ttl), None) if ttl > 0 => Ok(Some(now_secs()?.saturating_add(ttl))),
(Some(_), None) => Ok(None),
(None, Some(expires_at)) if expires_at > 0 => Ok(Some(expires_at)),
(None, Some(_)) | (None, None) => Ok(None),
}
}
fn now_secs() -> Result<u64> {
Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs())
}
fn show_group(cli: &CliApp, group: &str) -> Result<Value> {
let group_id = resolve_group_id(&cli.app.state(), group)?;
cli.dispatch_and_wait(
AppAction::PushScreen {
screen: iris_chat_core::Screen::GroupDetails {
group_id: group_id.clone(),
},
},
Duration::from_secs(2),
)?;
let state = cli.app.state();
let details = state.group_details.context("No group details available.")?;
Ok(group_json(&details))
}
fn search_messages(data_dir: &Path, query: &str, limit: usize) -> Result<Value> {
let conn = open_existing_db(data_dir)?;
let pattern = format!("%{query}%");
let mut stmt = conn.prepare(
"SELECT chat_id, id, body, is_outgoing, created_at_secs, delivery
FROM messages
WHERE body LIKE ?1
ORDER BY created_at_secs DESC, id DESC
LIMIT ?2",
)?;
let rows = stmt.query_map((&pattern, limit as i64), |row| {
Ok(json!({
"chat_id": row.get::<_, String>(0)?,
"id": row.get::<_, String>(1)?,
"body": row.get::<_, String>(2)?,
"is_outgoing": row.get::<_, i64>(3)? != 0,
"created_at_secs": row.get::<_, i64>(4)?,
"delivery": row.get::<_, String>(5)?,
}))
})?;
let mut messages = Vec::new();
for row in rows {
messages.push(row?);
}
Ok(json!({ "messages": messages }))
}
fn tail_messages(data_dir: &Path, limit: usize) -> Result<Value> {
let conn = open_existing_db(data_dir)?;
let mut stmt = conn.prepare(
"SELECT chat_id, id, body, is_outgoing, created_at_secs, delivery
FROM messages
ORDER BY created_at_secs DESC, id DESC
LIMIT ?1",
)?;
let rows = stmt.query_map([limit as i64], |row| {
Ok(json!({
"chat_id": row.get::<_, String>(0)?,
"id": row.get::<_, String>(1)?,
"body": row.get::<_, String>(2)?,
"is_outgoing": row.get::<_, i64>(3)? != 0,
"created_at_secs": row.get::<_, i64>(4)?,
"delivery": row.get::<_, String>(5)?,
}))
})?;
let mut messages = Vec::new();
for row in rows {
messages.push(row?);
}
messages.reverse();
Ok(json!({ "messages": messages }))
}
fn listen(data_dir: &Path, chat: Option<&str>, interval_ms: u64) -> Result<Value> {
let interval = Duration::from_millis(interval_ms.max(100));
let mut seen = latest_message_keys(data_dir, chat)?;
println!(
"{}",
serde_json::to_string(&Envelope {
status: "ok",
command: "listen".to_string(),
data: json!({ "ready": true, "chat": chat }),
})?
);
loop {
thread::sleep(interval);
let messages = new_message_rows(data_dir, chat, &seen)?;
for message in messages {
if let (Some(chat_id), Some(id)) = (
message.get("chat_id").and_then(Value::as_str),
message.get("id").and_then(Value::as_str),
) {
seen.insert(format!("{chat_id}\0{id}"));
}
println!(
"{}",
serde_json::to_string(&Envelope {
status: "ok",
command: "message".to_string(),
data: message,
})?
);
}
}
}
fn latest_message_keys(
data_dir: &Path,
chat: Option<&str>,
) -> Result<std::collections::HashSet<String>> {
let conn = open_existing_db(data_dir)?;
let mut seen = std::collections::HashSet::new();
match chat {
Some(chat_id) => {
let mut stmt = conn.prepare("SELECT chat_id, id FROM messages WHERE chat_id = ?1")?;
let rows = stmt.query_map([chat_id], |row| {
Ok(format!(
"{}\0{}",
row.get::<_, String>(0)?,
row.get::<_, String>(1)?
))
})?;
for row in rows {
seen.insert(row?);
}
}
None => {
let mut stmt = conn.prepare("SELECT chat_id, id FROM messages")?;
let rows = stmt.query_map([], |row| {
Ok(format!(
"{}\0{}",
row.get::<_, String>(0)?,
row.get::<_, String>(1)?
))
})?;
for row in rows {
seen.insert(row?);
}
}
}
Ok(seen)
}
fn new_message_rows(
data_dir: &Path,
chat: Option<&str>,
seen: &std::collections::HashSet<String>,
) -> Result<Vec<Value>> {
let conn = open_existing_db(data_dir)?;
let sql = match chat {
Some(_) => {
"SELECT chat_id, id, body, is_outgoing, created_at_secs, delivery
FROM messages
WHERE chat_id = ?1
ORDER BY created_at_secs ASC, id ASC"
}
None => {
"SELECT chat_id, id, body, is_outgoing, created_at_secs, delivery
FROM messages
ORDER BY created_at_secs ASC, id ASC"
}
};
let mut stmt = conn.prepare(sql)?;
let map_row = |row: &rusqlite::Row<'_>| -> rusqlite::Result<Value> {
Ok(json!({
"chat_id": row.get::<_, String>(0)?,
"id": row.get::<_, String>(1)?,
"body": row.get::<_, String>(2)?,
"is_outgoing": row.get::<_, i64>(3)? != 0,
"created_at_secs": row.get::<_, i64>(4)?,
"delivery": row.get::<_, String>(5)?,
}))
};
let mut messages = Vec::new();
match chat {
Some(chat_id) => {
let rows = stmt.query_map([chat_id], map_row)?;
for row in rows {
let message = row?;
let key = format!(
"{}\0{}",
message["chat_id"].as_str().unwrap_or_default(),
message["id"].as_str().unwrap_or_default()
);
if !seen.contains(&key) {
messages.push(message);
}
}
}
None => {
let rows = stmt.query_map([], map_row)?;
for row in rows {
let message = row?;
let key = format!(
"{}\0{}",
message["chat_id"].as_str().unwrap_or_default(),
message["id"].as_str().unwrap_or_default()
);
if !seen.contains(&key) {
messages.push(message);
}
}
}
}
Ok(messages)
}
fn print_output(json_output: bool, command: &str, data: Value) -> Result<()> {
if json_output {
println!(
"{}",
serde_json::to_string(&Envelope {
status: "ok",
command: command.to_string(),
data,
})?
);
} else if data.is_object() || data.is_array() {
println!("{}", serde_json::to_string_pretty(&data)?);
} else {
println!("{data}");
}
Ok(())
}
fn command_name(command: &Commands) -> &'static str {
match command {
Commands::Login { .. } => "login",
Commands::Restore { .. } => "restore",
Commands::Logout => "logout",
Commands::Whoami => "whoami",
Commands::State => "state",
Commands::Search { .. } => "search",
Commands::Tail { .. } => "tail",
Commands::Listen { .. } => "listen",
Commands::Account(_) => "account",
Commands::Chat(_) => "chat",
Commands::Send { .. } => "send",
Commands::React { .. } => "react",
Commands::Typing { .. } => "typing",
Commands::Receipt { .. } => "receipt",
Commands::Read { .. } => "read",
Commands::Seen { .. } => "seen",
Commands::Invite(_) => "invite",
Commands::Link(_) => "link",
Commands::Group(_) => "group",
Commands::Relay(_) => "relay",
}
}
fn account_json(account: &iris_chat_core::AccountSnapshot) -> Value {
json!({
"user_id": account.public_key_hex,
"npub": account.npub,
"name": account.display_name,
"device_id": account.device_public_key_hex,
"device_npub": account.device_npub,
"has_secret_key": account.has_owner_signing_authority,
"device_state": authorization_state(&account.authorization_state),
})
}
fn state_json(state: &AppState) -> Value {
json!({
"account": state.account.as_ref().map(account_json),
"chats": chat_list_json(state),
"groups": group_list_json(state),
"current_chat": state.current_chat.as_ref().map(|chat| chat_json(chat, usize::MAX)),
"message_servers": state.preferences.nostr_relay_urls,
"toast": state.toast,
})
}
fn chat_list_json(state: &AppState) -> Vec<Value> {
state.chat_list.iter().map(thread_json).collect()
}
fn group_list_json(state: &AppState) -> Vec<Value> {
state
.chat_list
.iter()
.filter(|thread| matches!(thread.kind, ChatKind::Group))
.map(thread_json)
.collect()
}
fn thread_json(thread: &ChatThreadSnapshot) -> Value {
json!({
"chat_id": thread.chat_id,
"kind": chat_kind(&thread.kind),
"name": thread.display_name,
"subtitle": thread.subtitle,
"member_count": thread.member_count,
"last_message": thread.last_message_preview,
"last_message_at_secs": thread.last_message_at_secs,
"unread_count": thread.unread_count,
"muted": thread.is_muted,
})
}
fn chat_summary_json(chat: &CurrentChatSnapshot) -> Value {
json!({
"chat_id": chat.chat_id,
"kind": chat_kind(&chat.kind),
"name": chat.display_name,
"group_id": chat.group_id,
"member_count": chat.member_count,
"message_count": chat.messages.len(),
"message_ttl_seconds": chat.message_ttl_seconds,
"muted": chat.is_muted,
})
}
fn chat_json(chat: &CurrentChatSnapshot, limit: usize) -> Value {
let skip = chat.messages.len().saturating_sub(limit);
json!({
"chat": chat_summary_json(chat),
"messages": chat.messages.iter().skip(skip).map(message_json).collect::<Vec<_>>(),
})
}
fn message_json(message: &ChatMessageSnapshot) -> Value {
json!({
"id": message.id,
"chat_id": message.chat_id,
"author": message.author,
"body": message.body,
"is_outgoing": message.is_outgoing,
"created_at_secs": message.created_at_secs,
"expires_at_secs": message.expires_at_secs,
"delivery": delivery(&message.delivery),
"source_event_id": message.source_event_id,
"attachments": message.attachments.iter().map(|item| {
json!({
"filename": item.filename,
"nhash": item.nhash,
"url": item.htree_url,
})
}).collect::<Vec<_>>(),
"reactions": message.reactions.iter().map(|item| {
json!({
"emoji": item.emoji,
"count": item.count,
"reacted_by_me": item.reacted_by_me,
})
}).collect::<Vec<_>>(),
})
}
fn group_json(group: &GroupDetailsSnapshot) -> Value {
json!({
"group_id": group.group_id,
"name": group.name,
"picture_url": group.picture_url,
"can_manage": group.can_manage,
"muted": group.is_muted,
"revision": group.revision,
"members": group.members.iter().map(|member| {
json!({
"user_id": member.owner_pubkey_hex,
"name": member.display_name,
"npub": member.npub,
"admin": member.is_admin,
"creator": member.is_creator,
"me": member.is_local_owner,
})
}).collect::<Vec<_>>(),
})
}
fn require_account(state: &AppState) -> Result<iris_chat_core::AccountSnapshot> {
state
.account
.clone()
.context("Create or restore a profile first.")
}
fn fail_on_toast(state: &AppState) -> Result<()> {
if let Some(toast) = &state.toast {
anyhow::bail!(toast.clone());
}
Ok(())
}
fn resolve_chat_id(state: &AppState, input: &str) -> Result<String> {
if let Some(chat) = &state.current_chat {
if chat.chat_id == input || chat.group_id.as_deref() == Some(input) {
return Ok(chat.chat_id.clone());
}
}
state
.chat_list
.iter()
.find(|thread| {
thread.chat_id == input
|| thread.display_name.eq_ignore_ascii_case(input)
|| thread.subtitle.as_deref() == Some(input)
})
.map(|thread| thread.chat_id.clone())
.with_context(|| format!("Chat not found: {input}"))
}
fn resolve_group_id(state: &AppState, input: &str) -> Result<String> {
if let Some(group) = &state.group_details {
if group.group_id == input || group.name.eq_ignore_ascii_case(input) {
return Ok(group.group_id.clone());
}
}
state
.chat_list
.iter()
.find(|thread| {
matches!(thread.kind, ChatKind::Group)
&& (thread.chat_id == input
|| thread.chat_id == normalize_group_chat(input)
|| thread.display_name.eq_ignore_ascii_case(input))
})
.map(|thread| {
thread
.chat_id
.strip_prefix("group:")
.unwrap_or(&thread.chat_id)
.to_string()
})
.with_context(|| format!("Group not found: {input}"))
}
fn normalize_group_chat(group: &str) -> String {
if group.starts_with("group:") {
group.to_string()
} else {
format!("group:{group}")
}
}
fn is_busy(state: &AppState) -> bool {
let busy = &state.busy;
busy.creating_account
|| busy.restoring_session
|| busy.linking_device
|| busy.creating_chat
|| busy.creating_group
|| busy.sending_message
|| busy.updating_roster
|| busy.updating_group
|| busy.creating_invite
|| busy.accepting_invite
|| busy.uploading_attachment
}
fn chat_kind(kind: &ChatKind) -> &'static str {
match kind {
ChatKind::Direct => "direct",
ChatKind::Group => "group",
}
}
fn delivery(delivery: &DeliveryState) -> &'static str {
match delivery {
DeliveryState::Queued => "queued",
DeliveryState::Pending => "pending",
DeliveryState::Sent => "sent",
DeliveryState::Received => "received",
DeliveryState::Seen => "seen",
DeliveryState::Failed => "failed",
}
}
fn authorization_state(state: &DeviceAuthorizationState) -> &'static str {
match state {
DeviceAuthorizationState::Authorized => "authorized",
DeviceAuthorizationState::AwaitingApproval => "awaiting_approval",
DeviceAuthorizationState::Revoked => "revoked",
}
}
fn open_existing_db(data_dir: &Path) -> Result<Connection> {
let path = data_dir.join("core.sqlite3");
Connection::open(path).context("Open Iris chat database")
}
fn account_bundle_path(data_dir: &Path) -> PathBuf {
data_dir.join("cli-account.json")
}
fn read_account_bundle(data_dir: &Path) -> Result<Option<AccountBundle>> {
let path = account_bundle_path(data_dir);
if !path.exists() {
return Ok(None);
}
let raw = std::fs::read_to_string(&path)
.with_context(|| format!("read account bundle {}", path.display()))?;
Ok(Some(serde_json::from_str(&raw)?))
}
fn write_account_bundle(data_dir: &Path, bundle: &AccountBundle) -> Result<()> {
std::fs::create_dir_all(data_dir)?;
let path = account_bundle_path(data_dir);
std::fs::write(&path, serde_json::to_vec_pretty(bundle)?)
.with_context(|| format!("write account bundle {}", path.display()))
}
fn default_data_dir() -> PathBuf {
if let Some(home) = std::env::var_os("HOME") {
return PathBuf::from(home)
.join("Library")
.join("Application Support")
.join("Iris Chat CLI");
}
PathBuf::from(".iris-chat")
}