feat: Sprint 6 — disappearing messages, group info, account deletion
- Disappearing messages: ttlSecs param on enqueue/batchEnqueue RPCs, expires_at column (migration 007), server GC deletes expired messages, /disappear command with human-friendly duration parsing (30m, 1h, 1d) - Group info: /group-info shows type, members, MLS epoch; /rename renames conversations; /members resolves usernames via resolveIdentity - Account deletion: deleteAccount @23 RPC with transactional purge of all user data (deliveries, keys, channels), session invalidation, KT log preserved for auditability; /delete-account with confirmation - Added epoch() accessor to GroupMember, enqueue_with_ttl client helper All 35 server + 71 core + 14 E2E tests pass.
This commit is contained in:
@@ -26,9 +26,9 @@ use super::conversation::{
|
||||
};
|
||||
use super::display;
|
||||
use super::rpc::{
|
||||
connect_node, create_channel, download_blob_chunk, enqueue, fetch_hybrid_key,
|
||||
fetch_key_package, fetch_wait, resolve_identity, resolve_user, try_hybrid_decrypt,
|
||||
upload_blob_chunk, upload_hybrid_key, upload_key_package,
|
||||
connect_node, create_channel, delete_account, download_blob_chunk, enqueue,
|
||||
enqueue_with_ttl, fetch_hybrid_key, fetch_key_package, fetch_wait, resolve_identity,
|
||||
resolve_user, try_hybrid_decrypt, upload_blob_chunk, upload_hybrid_key, upload_key_package,
|
||||
};
|
||||
use super::session::SessionState;
|
||||
use super::state::{decode_identity_key, load_or_init_state};
|
||||
@@ -55,6 +55,8 @@ enum SlashCommand {
|
||||
Leave,
|
||||
Join,
|
||||
Members,
|
||||
GroupInfo,
|
||||
Rename { name: String },
|
||||
History { count: usize },
|
||||
/// Mesh subcommands: /mesh peers, /mesh server <addr>
|
||||
MeshPeers,
|
||||
@@ -77,6 +79,10 @@ enum SlashCommand {
|
||||
SendFile { path: String },
|
||||
/// Download a file attachment by message index.
|
||||
Download { index: usize },
|
||||
/// Permanently delete the user's account on the server.
|
||||
DeleteAccount,
|
||||
/// Set or query disappearing message TTL for the active conversation.
|
||||
Disappear { arg: Option<String> },
|
||||
}
|
||||
|
||||
fn parse_input(line: &str) -> Input {
|
||||
@@ -135,6 +141,14 @@ fn parse_input(line: &str) -> Input {
|
||||
"/leave" => Input::Slash(SlashCommand::Leave),
|
||||
"/join" => Input::Slash(SlashCommand::Join),
|
||||
"/members" => Input::Slash(SlashCommand::Members),
|
||||
"/group-info" | "/gi" => Input::Slash(SlashCommand::GroupInfo),
|
||||
"/rename" => match arg {
|
||||
Some(name) => Input::Slash(SlashCommand::Rename { name }),
|
||||
None => {
|
||||
display::print_error("usage: /rename <new-name>");
|
||||
Input::Empty
|
||||
}
|
||||
},
|
||||
"/history" | "/hist" => {
|
||||
let count = arg.and_then(|s| s.parse().ok()).unwrap_or(20);
|
||||
Input::Slash(SlashCommand::History { count })
|
||||
@@ -224,6 +238,8 @@ fn parse_input(line: &str) -> Input {
|
||||
Input::Empty
|
||||
}
|
||||
},
|
||||
"/delete-account" => Input::Slash(SlashCommand::DeleteAccount),
|
||||
"/disappear" => Input::Slash(SlashCommand::Disappear { arg }),
|
||||
_ => {
|
||||
display::print_error(&format!("unknown command: {cmd}. Try /help"));
|
||||
Input::Empty
|
||||
@@ -687,7 +703,9 @@ async fn handle_slash(
|
||||
SlashCommand::Remove { target } => cmd_remove(session, client, &target).await,
|
||||
SlashCommand::Leave => cmd_leave(session, client).await,
|
||||
SlashCommand::Join => cmd_join(session, client).await,
|
||||
SlashCommand::Members => cmd_members(session),
|
||||
SlashCommand::Members => cmd_members(session, client).await,
|
||||
SlashCommand::GroupInfo => cmd_group_info(session, client).await,
|
||||
SlashCommand::Rename { name } => cmd_rename(session, &name),
|
||||
SlashCommand::History { count } => cmd_history(session, count),
|
||||
SlashCommand::MeshPeers => cmd_mesh_peers(),
|
||||
SlashCommand::MeshServer { addr } => {
|
||||
@@ -712,6 +730,8 @@ async fn handle_slash(
|
||||
SlashCommand::Delete { index } => cmd_delete(session, client, index).await,
|
||||
SlashCommand::SendFile { path } => cmd_send_file(session, client, &path).await,
|
||||
SlashCommand::Download { index } => cmd_download(session, client, index).await,
|
||||
SlashCommand::DeleteAccount => cmd_delete_account(session, client).await,
|
||||
SlashCommand::Disappear { arg } => cmd_disappear(session, arg.as_deref()),
|
||||
};
|
||||
if let Err(e) = result {
|
||||
display::print_error(&format!("{e:#}"));
|
||||
@@ -729,6 +749,8 @@ fn print_help() {
|
||||
display::print_status(" /switch <@user|#group> - Switch conversation");
|
||||
display::print_status(" /list - List all conversations");
|
||||
display::print_status(" /members - Show members of current conversation");
|
||||
display::print_status(" /group-info - Show detailed info about the active conversation");
|
||||
display::print_status(" /rename <name> - Rename the current conversation");
|
||||
display::print_status(" /history [N] - Show last N messages (default: 20)");
|
||||
display::print_status(" /whoami - Show your identity");
|
||||
display::print_status(" /mesh peers - Discover nearby qpq nodes via mDNS");
|
||||
@@ -742,9 +764,84 @@ fn print_help() {
|
||||
display::print_status(" /delete <index> - Delete a sent message");
|
||||
display::print_status(" /send-file <path> - Upload and send a file (max 50 MB)");
|
||||
display::print_status(" /download <index> - Download a received file attachment");
|
||||
display::print_status(" /delete-account - Permanently delete your account");
|
||||
display::print_status(" /disappear <duration> - Set disappearing messages (1h, 30m, 1d, 300)");
|
||||
display::print_status(" /disappear off - Disable disappearing messages");
|
||||
display::print_status(" /disappear - Show current setting");
|
||||
display::print_status(" /quit - Exit");
|
||||
}
|
||||
|
||||
/// Parse a human-friendly duration string into seconds.
|
||||
/// Supports: "30s", "5m", "1h", "1d", "300" (plain seconds).
|
||||
fn parse_duration_secs(s: &str) -> Option<u32> {
|
||||
let s = s.trim().to_lowercase();
|
||||
if s.ends_with('d') {
|
||||
s[..s.len() - 1].parse::<u32>().ok().map(|d| d * 86400)
|
||||
} else if s.ends_with('h') {
|
||||
s[..s.len() - 1].parse::<u32>().ok().map(|h| h * 3600)
|
||||
} else if s.ends_with('m') {
|
||||
s[..s.len() - 1].parse::<u32>().ok().map(|m| m * 60)
|
||||
} else if s.ends_with('s') {
|
||||
s[..s.len() - 1].parse::<u32>().ok()
|
||||
} else {
|
||||
s.parse::<u32>().ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a TTL in seconds into a human-friendly string.
|
||||
fn format_ttl(secs: u32) -> String {
|
||||
if secs >= 86400 && secs % 86400 == 0 {
|
||||
format!("{} day(s)", secs / 86400)
|
||||
} else if secs >= 3600 && secs % 3600 == 0 {
|
||||
format!("{} hour(s)", secs / 3600)
|
||||
} else if secs >= 60 && secs % 60 == 0 {
|
||||
format!("{} minute(s)", secs / 60)
|
||||
} else {
|
||||
format!("{} second(s)", secs)
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_disappear(
|
||||
session: &mut SessionState,
|
||||
arg: Option<&str>,
|
||||
) -> anyhow::Result<()> {
|
||||
let conv_id = session
|
||||
.active_conversation
|
||||
.as_ref()
|
||||
.context("no active conversation")?
|
||||
.clone();
|
||||
|
||||
match arg {
|
||||
None => {
|
||||
// Show current setting.
|
||||
match session.disappear_ttl.get(&conv_id) {
|
||||
Some(ttl) => display::print_status(&format!(
|
||||
"messages will disappear after {}",
|
||||
format_ttl(*ttl)
|
||||
)),
|
||||
None => display::print_status("disappearing messages are off"),
|
||||
}
|
||||
}
|
||||
Some(s) if s.eq_ignore_ascii_case("off") => {
|
||||
session.disappear_ttl.remove(&conv_id);
|
||||
display::print_status("disappearing messages disabled");
|
||||
}
|
||||
Some(s) => {
|
||||
let secs = parse_duration_secs(s)
|
||||
.context("invalid duration; use e.g. 30m, 1h, 1d, or 300")?;
|
||||
if secs == 0 {
|
||||
anyhow::bail!("TTL must be greater than 0");
|
||||
}
|
||||
session.disappear_ttl.insert(conv_id, secs);
|
||||
display::print_status(&format!(
|
||||
"messages will disappear after {}",
|
||||
format_ttl(secs)
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Discover nearby qpq servers via mDNS (requires `--features mesh` build).
|
||||
fn cmd_mesh_peers() -> anyhow::Result<()> {
|
||||
use super::mesh_discovery::MeshDiscovery;
|
||||
@@ -1325,7 +1422,21 @@ async fn cmd_join(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_members(session: &SessionState) -> anyhow::Result<()> {
|
||||
/// Resolve an identity key to a username, falling back to a hex prefix on failure.
|
||||
async fn resolve_or_hex(
|
||||
client: &node_service::Client,
|
||||
identity_key: &[u8],
|
||||
) -> String {
|
||||
match resolve_identity(client, identity_key).await {
|
||||
Ok(Some(name)) => name,
|
||||
_ => hex::encode(&identity_key[..8.min(identity_key.len())]),
|
||||
}
|
||||
}
|
||||
|
||||
async fn cmd_members(
|
||||
session: &SessionState,
|
||||
client: &node_service::Client,
|
||||
) -> anyhow::Result<()> {
|
||||
let conv_id = session
|
||||
.active_conversation
|
||||
.as_ref()
|
||||
@@ -1338,11 +1449,77 @@ fn cmd_members(session: &SessionState) -> anyhow::Result<()> {
|
||||
|
||||
let my_key = session.identity_bytes();
|
||||
let ids = member.member_identities();
|
||||
display::print_status(&format!("{} members:", ids.len()));
|
||||
let mut names = Vec::with_capacity(ids.len());
|
||||
for id in &ids {
|
||||
let tag = if id.as_slice() == my_key.as_slice() { " (you)" } else { "" };
|
||||
display::print_status(&format!(" {}{tag}", hex::encode(&id[..8])));
|
||||
let mut name = resolve_or_hex(client, id).await;
|
||||
if id.as_slice() == my_key.as_slice() {
|
||||
name.push_str(" (you)");
|
||||
}
|
||||
names.push(name);
|
||||
}
|
||||
display::print_status(&format!("Members ({}): {}", names.len(), names.join(", ")));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cmd_group_info(
|
||||
session: &SessionState,
|
||||
client: &node_service::Client,
|
||||
) -> anyhow::Result<()> {
|
||||
let conv_id = session
|
||||
.active_conversation
|
||||
.as_ref()
|
||||
.context("no active conversation")?;
|
||||
|
||||
let conv = session
|
||||
.conv_store
|
||||
.load_conversation(conv_id)?
|
||||
.context("conversation not found in store")?;
|
||||
|
||||
let member = session
|
||||
.members
|
||||
.get(conv_id)
|
||||
.context("no group member for active conversation")?;
|
||||
|
||||
let my_key = session.identity_bytes();
|
||||
let ids = member.member_identities();
|
||||
let conv_type = if ids.len() <= 2 { "DM" } else { "Group" };
|
||||
|
||||
display::print_status(&format!("Conversation: {}", conv.display_name));
|
||||
display::print_status(&format!("Type: {}", conv_type));
|
||||
display::print_status(&format!("Members: {}", ids.len()));
|
||||
|
||||
let mut names = Vec::with_capacity(ids.len());
|
||||
for id in &ids {
|
||||
let mut name = resolve_or_hex(client, id).await;
|
||||
if id.as_slice() == my_key.as_slice() {
|
||||
name.push_str(" (you)");
|
||||
}
|
||||
names.push(name);
|
||||
}
|
||||
display::print_status(&format!(" {}", names.join(", ")));
|
||||
|
||||
if let Some(epoch) = member.epoch() {
|
||||
display::print_status(&format!("MLS epoch: {}", epoch));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_rename(session: &mut SessionState, new_name: &str) -> anyhow::Result<()> {
|
||||
let conv_id = session
|
||||
.active_conversation
|
||||
.as_ref()
|
||||
.context("no active conversation")?
|
||||
.clone();
|
||||
|
||||
let mut conv = session
|
||||
.conv_store
|
||||
.load_conversation(&conv_id)?
|
||||
.context("conversation not found in store")?;
|
||||
|
||||
conv.display_name = new_name.to_string();
|
||||
session.conv_store.save_conversation(&conv)?;
|
||||
display::print_status(&format!("Conversation renamed to: {new_name}"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2002,6 +2179,43 @@ fn extract_filename_from_body(body: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
async fn cmd_delete_account(
|
||||
session: &mut SessionState,
|
||||
client: &node_service::Client,
|
||||
) -> anyhow::Result<()> {
|
||||
display::print_error("WARNING: This will permanently delete your account and all data on the server.");
|
||||
display::print_status("Type 'DELETE' to confirm:");
|
||||
|
||||
// Read confirmation from stdin.
|
||||
let mut input = String::new();
|
||||
{
|
||||
use std::io::Write;
|
||||
std::io::stderr().flush().ok();
|
||||
}
|
||||
std::io::stdin()
|
||||
.read_line(&mut input)
|
||||
.context("failed to read confirmation")?;
|
||||
|
||||
if input.trim() != "DELETE" {
|
||||
display::print_status("Account deletion cancelled.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
delete_account(client).await?;
|
||||
|
||||
// Clear local state file.
|
||||
if session.state_path.exists() {
|
||||
std::fs::remove_file(&session.state_path)
|
||||
.with_context(|| format!("remove state file: {}", session.state_path.display()))?;
|
||||
}
|
||||
|
||||
// Clear cached session token.
|
||||
clear_cached_session(&session.state_path);
|
||||
|
||||
display::print_status("Account deleted successfully.");
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
// ── Sending ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async fn handle_send(
|
||||
@@ -2068,6 +2282,8 @@ async fn do_send(
|
||||
.filter(|id| id.as_slice() != my_key.as_slice())
|
||||
.collect();
|
||||
|
||||
let ttl = session.disappear_ttl.get(&conv_id).copied();
|
||||
|
||||
for recipient_key in &recipients {
|
||||
let peer_hybrid_pk = fetch_hybrid_key(client, recipient_key).await?;
|
||||
let payload = if let Some(ref pk) = peer_hybrid_pk {
|
||||
@@ -2075,7 +2291,7 @@ async fn do_send(
|
||||
} else {
|
||||
ct.clone()
|
||||
};
|
||||
enqueue(client, recipient_key, &payload).await?;
|
||||
enqueue_with_ttl(client, recipient_key, &payload, ttl).await?;
|
||||
}
|
||||
|
||||
// Extract message_id from what we just serialized.
|
||||
|
||||
@@ -226,6 +226,16 @@ pub async fn enqueue(
|
||||
client: &node_service::Client,
|
||||
recipient_key: &[u8],
|
||||
payload: &[u8],
|
||||
) -> anyhow::Result<u64> {
|
||||
enqueue_with_ttl(client, recipient_key, payload, None).await
|
||||
}
|
||||
|
||||
/// Enqueue with an optional TTL (seconds). 0 or None means no expiry.
|
||||
pub async fn enqueue_with_ttl(
|
||||
client: &node_service::Client,
|
||||
recipient_key: &[u8],
|
||||
payload: &[u8],
|
||||
ttl_secs: Option<u32>,
|
||||
) -> anyhow::Result<u64> {
|
||||
let client = client.clone();
|
||||
let recipient_key = recipient_key.to_vec();
|
||||
@@ -243,6 +253,9 @@ pub async fn enqueue(
|
||||
p.set_payload(&payload);
|
||||
p.set_channel_id(&[]);
|
||||
p.set_version(1);
|
||||
if let Some(ttl) = ttl_secs {
|
||||
p.set_ttl_secs(ttl);
|
||||
}
|
||||
let mut auth = p.reborrow().init_auth();
|
||||
set_auth(&mut auth)?;
|
||||
}
|
||||
@@ -831,6 +844,32 @@ pub async fn download_blob_chunk(
|
||||
Ok((chunk, total_size, mime_type))
|
||||
}
|
||||
|
||||
/// Delete the authenticated user's account on the server.
|
||||
/// Requires an identity-bound session (OPAQUE login).
|
||||
pub async fn delete_account(
|
||||
client: &node_service::Client,
|
||||
) -> anyhow::Result<bool> {
|
||||
let mut req = client.delete_account_request();
|
||||
{
|
||||
let mut p = req.get();
|
||||
let mut auth = p.reborrow().init_auth();
|
||||
set_auth(&mut auth)?;
|
||||
}
|
||||
|
||||
let resp = req
|
||||
.send()
|
||||
.promise
|
||||
.await
|
||||
.context("delete_account RPC failed")?;
|
||||
|
||||
let success = resp
|
||||
.get()
|
||||
.context("delete_account: bad response")?
|
||||
.get_success();
|
||||
|
||||
Ok(success)
|
||||
}
|
||||
|
||||
/// Return the current Unix timestamp in milliseconds.
|
||||
pub fn current_timestamp_ms() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
|
||||
@@ -42,6 +42,8 @@ pub struct SessionState {
|
||||
/// Tracks who is currently typing and when the indicator was last received.
|
||||
/// Entries older than 10 seconds are considered expired.
|
||||
pub typing_indicators: HashMap<String, Instant>,
|
||||
/// Per-conversation disappearing message TTL in seconds. None = messages persist.
|
||||
pub disappear_ttl: HashMap<ConversationId, u32>,
|
||||
}
|
||||
|
||||
impl SessionState {
|
||||
@@ -77,6 +79,7 @@ impl SessionState {
|
||||
pending_member: None,
|
||||
typing_notify_enabled: true,
|
||||
typing_indicators: HashMap::new(),
|
||||
disappear_ttl: HashMap::new(),
|
||||
};
|
||||
|
||||
// Migrate legacy single-group into conversations if present and not yet migrated.
|
||||
|
||||
Reference in New Issue
Block a user