diff --git a/src/callback_handlers.rs b/src/callback_handlers.rs new file mode 100644 index 0000000..29889e2 --- /dev/null +++ b/src/callback_handlers.rs @@ -0,0 +1,1232 @@ +use super::*; +use crate::message_handlers::{add_resource_from_text, handle_single_item, start_resource_picker}; + +pub(super) async fn handle_callback( + bot: Bot, + q: CallbackQuery, + state: std::sync::Arc, +) -> Result<()> { + let user_id = q.from.id.0; + if user_id != state.config.user_id { + return Ok(()); + } + + if let Some(data) = q.data.as_deref() { + if data.starts_with("ls:") { + handle_list_callback(bot, q, state).await?; + } else if data.starts_with("pick:") { + handle_picker_callback(bot, q, state).await?; + } else if data.starts_with("add:") { + handle_add_callback(bot, q, state).await?; + } else if data.starts_with("res:") { + handle_resource_callback(bot, q, state).await?; + } else if data.starts_with("dl:") { + handle_download_callback(bot, q, state).await?; + } else if data.starts_with("msgdel") { + handle_message_delete_callback(bot, q).await?; + } else if data.starts_with("undos:") { + handle_undos_callback(bot, q, state).await?; + } else if data.starts_with("undo:") { + handle_undo_callback(bot, q, state).await?; + } + } + + Ok(()) +} + +async fn handle_add_callback( + bot: Bot, + q: CallbackQuery, + state: std::sync::Arc, +) -> Result<()> { + let Some(message) = q.message.clone() else { + return Ok(()); + }; + let Some(data) = q.data.as_deref() else { + return Ok(()); + }; + let mut parts = data.split(':'); + let _ = parts.next(); + let prompt_id = match parts.next() { + Some(id) => id.to_string(), + None => return Ok(()), + }; + let action = match parts.next() { + Some(action) => action, + None => return Ok(()), + }; + + let prompt = { + let mut prompts = state.add_prompts.lock().await; + let prompt = match prompts.remove(&prompt_id) { + Some(prompt) => prompt, + None => { + bot.answer_callback_query(q.id).await?; + return Ok(()); + } + }; + if prompt.chat_id != message.chat.id.0 || prompt.message_id != message.id { + prompts.insert(prompt_id.clone(), prompt); + bot.answer_callback_query(q.id).await?; + return Ok(()); + } + prompt + }; + + match action { + "normal" => { + handle_single_item( + bot.clone(), + message.chat.id, + state.clone(), + &prompt.text, + Some(prompt.source_message_id), + ) + .await?; + } + "resource" => { + start_resource_picker( + &bot, + message.chat.id, + &state, + &prompt.text, + Some(prompt.source_message_id), + ) + .await?; + } + "cancel" => {} + _ => { + let mut prompts = state.add_prompts.lock().await; + prompts.insert(prompt_id, prompt); + bot.answer_callback_query(q.id).await?; + return Ok(()); + } + } + + let _ = bot.delete_message(message.chat.id, message.id).await; + bot.answer_callback_query(q.id).await?; + Ok(()) +} + +async fn handle_resource_callback( + bot: Bot, + q: CallbackQuery, + state: std::sync::Arc, +) -> Result<()> { + let Some(message) = q.message.clone() else { + return Ok(()); + }; + let Some(data) = q.data.as_deref() else { + return Ok(()); + }; + let mut parts = data.split(':'); + let _ = parts.next(); + let picker_id = match parts.next() { + Some(id) => id.to_string(), + None => return Ok(()), + }; + let action = match parts.next() { + Some(action) => action, + None => return Ok(()), + }; + + let picker = { + let mut pickers = state.resource_pickers.lock().await; + let picker = match pickers.remove(&picker_id) { + Some(picker) => picker, + None => { + bot.answer_callback_query(q.id).await?; + return Ok(()); + } + }; + if picker.chat_id != message.chat.id.0 || picker.message_id != message.id { + pickers.insert(picker_id.clone(), picker); + bot.answer_callback_query(q.id).await?; + return Ok(()); + } + picker + }; + + let mut reinsert = false; + match action { + "file" => { + let index = parts.next().and_then(|p| p.parse::().ok()); + if let Some(index) = index { + if let Some(path) = picker.files.get(index).cloned() { + add_resource_from_text( + &bot, + message.chat.id, + &state, + path, + &picker.text, + picker.source_message_id.clone(), + ) + .await?; + let _ = bot.delete_message(message.chat.id, message.id).await; + } else { + reinsert = true; + } + } else { + reinsert = true; + } + } + "new" => { + let prompt_text = "Send the new resource filename (example: Resources.md)."; + let sent = bot.send_message(message.chat.id, prompt_text).await?; + let prompt = ResourceFilenamePrompt { + text: picker.text.clone(), + source_message_id: picker.source_message_id.clone(), + prompt_message_id: sent.id, + expires_at: now_ts() + RESOURCE_PROMPT_TTL_SECS, + }; + let previous = state + .resource_filename_prompts + .lock() + .await + .insert(message.chat.id.0, prompt); + if let Some(previous) = previous { + let _ = bot + .delete_message(message.chat.id, previous.prompt_message_id) + .await; + } + let _ = bot.delete_message(message.chat.id, message.id).await; + } + "cancel" => { + let _ = bot.delete_message(message.chat.id, message.id).await; + } + _ => { + reinsert = true; + } + } + + if reinsert { + state + .resource_pickers + .lock() + .await + .insert(picker_id, picker); + } + + bot.answer_callback_query(q.id).await?; + Ok(()) +} + +async fn handle_download_callback( + bot: Bot, + q: CallbackQuery, + state: std::sync::Arc, +) -> Result<()> { + let Some(message) = q.message.clone() else { + return Ok(()); + }; + let Some(data) = q.data.as_deref() else { + return Ok(()); + }; + let mut parts = data.split(':'); + let _ = parts.next(); + let picker_id = match parts.next() { + Some(id) => id.to_string(), + None => return Ok(()), + }; + let action = match parts.next() { + Some(action) => action, + None => return Ok(()), + }; + + let mut picker = { + let mut pickers = state.download_pickers.lock().await; + let picker = match pickers.remove(&picker_id) { + Some(picker) => picker, + None => { + bot.answer_callback_query(q.id).await?; + return Ok(()); + } + }; + if picker.chat_id != message.chat.id.0 || picker.message_id != message.id { + pickers.insert(picker_id.clone(), picker); + bot.answer_callback_query(q.id).await?; + return Ok(()); + } + picker + }; + + let mut reinsert = false; + bot.answer_callback_query(q.id).await?; + + match action { + "send" => { + if !matches!(picker.mode, DownloadPickerMode::Links) { + reinsert = true; + } else { + let index = parts.next().and_then(|p| p.parse::().ok()); + if let Some(index) = index { + if let Some(link) = picker.links.get(index).cloned() { + let link_for_probe = link.clone(); + let options = tokio::task::spawn_blocking(move || { + run_ytdlp_list_formats(&link_for_probe) + }) + .await + .context("yt-dlp formats task failed")?; + match options { + Ok(options) => { + let text = build_download_quality_text( + &link, + DownloadAction::Send, + &options, + ); + let kb = build_download_quality_keyboard(&picker_id, &options); + bot.edit_message_text(message.chat.id, message.id, text) + .reply_markup(kb) + .await?; + picker.mode = DownloadPickerMode::Quality { + link_index: index, + action: DownloadAction::Send, + options, + }; + reinsert = true; + } + Err(err) => { + send_error(&bot, message.chat.id, &err.to_string()).await?; + reinsert = true; + } + } + } else { + reinsert = true; + } + } else { + reinsert = true; + } + } + } + "save" => { + if !matches!(picker.mode, DownloadPickerMode::Links) { + reinsert = true; + } else { + let index = parts.next().and_then(|p| p.parse::().ok()); + if let Some(index) = index { + if let Some(link) = picker.links.get(index).cloned() { + let link_for_probe = link.clone(); + let options = tokio::task::spawn_blocking(move || { + run_ytdlp_list_formats(&link_for_probe) + }) + .await + .context("yt-dlp formats task failed")?; + match options { + Ok(options) => { + let text = build_download_quality_text( + &link, + DownloadAction::Save, + &options, + ); + let kb = build_download_quality_keyboard(&picker_id, &options); + bot.edit_message_text(message.chat.id, message.id, text) + .reply_markup(kb) + .await?; + picker.mode = DownloadPickerMode::Quality { + link_index: index, + action: DownloadAction::Save, + options, + }; + reinsert = true; + } + Err(err) => { + send_error(&bot, message.chat.id, &err.to_string()).await?; + reinsert = true; + } + } + } else { + reinsert = true; + } + } else { + reinsert = true; + } + } + } + "quality" => { + let selected = parts.next().and_then(|p| p.parse::().ok()); + if let ( + Some(selected), + DownloadPickerMode::Quality { + link_index, + action, + options, + }, + ) = (selected, &picker.mode) + { + if let (Some(link), Some(option)) = ( + picker.links.get(*link_index).cloned(), + options.get(selected).cloned(), + ) { + match action { + DownloadAction::Send => { + match download_and_send_link( + &bot, + message.chat.id, + &link, + &option.format_selector, + ) + .await + { + Ok(()) => { + let _ = bot.delete_message(message.chat.id, message.id).await; + } + Err(err) => { + send_error(&bot, message.chat.id, &err.to_string()).await?; + reinsert = true; + } + } + } + DownloadAction::Save => { + match download_and_save_link(&state, &link, &option.format_selector) + .await + { + Ok(path) => { + let note = format!("Saved to {}", path.display()); + send_message_with_delete_button(&bot, message.chat.id, note) + .await?; + let _ = bot.delete_message(message.chat.id, message.id).await; + } + Err(err) => { + send_error(&bot, message.chat.id, &err.to_string()).await?; + reinsert = true; + } + } + } + } + } else { + reinsert = true; + } + } else { + reinsert = true; + } + } + "back" => { + if matches!(picker.mode, DownloadPickerMode::Quality { .. }) { + let text = build_download_picker_text(&picker.links); + let kb = build_download_picker_keyboard(&picker_id, &picker.links); + bot.edit_message_text(message.chat.id, message.id, text) + .reply_markup(kb) + .await?; + picker.mode = DownloadPickerMode::Links; + reinsert = true; + } else { + reinsert = true; + } + } + "add" => { + if matches!(picker.mode, DownloadPickerMode::Links) { + let prompt_text = "Send a link to add."; + let sent = bot.send_message(message.chat.id, prompt_text).await?; + let prompt = DownloadLinkPrompt { + links: picker.links.clone(), + prompt_message_id: sent.id, + expires_at: now_ts() + DOWNLOAD_PROMPT_TTL_SECS, + }; + let previous = state + .download_link_prompts + .lock() + .await + .insert(message.chat.id.0, prompt); + if let Some(previous) = previous { + let _ = bot + .delete_message(message.chat.id, previous.prompt_message_id) + .await; + } + let _ = bot.delete_message(message.chat.id, message.id).await; + } else { + reinsert = true; + } + } + "cancel" => { + let _ = bot.delete_message(message.chat.id, message.id).await; + } + _ => { + reinsert = true; + } + } + + if reinsert { + state + .download_pickers + .lock() + .await + .insert(picker_id, picker); + } + + Ok(()) +} + +async fn handle_message_delete_callback(bot: Bot, q: CallbackQuery) -> Result<()> { + if let Some(message) = q.message.clone() { + let _ = bot.delete_message(message.chat.id, message.id).await; + } + bot.answer_callback_query(q.id).await?; + Ok(()) +} + +async fn handle_list_callback( + bot: Bot, + q: CallbackQuery, + state: std::sync::Arc, +) -> Result<()> { + let Some(message) = q.message.clone() else { + return Ok(()); + }; + let Some(data) = q.data.as_deref() else { + return Ok(()); + }; + let mut parts = data.split(':'); + let _ = parts.next(); + let session_id = match parts.next() { + Some(id) => id.to_string(), + None => return Ok(()), + }; + let action = match parts.next() { + Some(action) => action, + None => return Ok(()), + }; + + let chat_id = message.chat.id.0; + let mut session = { + let mut sessions = state.sessions.lock().await; + let session = match sessions.remove(&session_id) { + Some(session) => session, + None => { + bot.answer_callback_query(q.id).await?; + return Ok(()); + } + }; + if session.chat_id != chat_id { + sessions.insert(session_id.clone(), session); + bot.answer_callback_query(q.id).await?; + return Ok(()); + } + session + }; + + let peeked_snapshot = state.peeked.lock().await.clone(); + let mut refresh_list_view = true; + let mut close_session = false; + + let action_result: Result<()> = async { + match action { + "menu" => { + if matches!(&session.kind, SessionKind::List) { + session.view = ListView::Menu; + } + } + "top" => { + let page = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0); + session.view = ListView::Peek { + mode: ListMode::Top, + page, + }; + } + "bottom" => { + let page = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0); + session.view = ListView::Peek { + mode: ListMode::Bottom, + page, + }; + } + "next" => { + if let ListView::Peek { mode, page } = session.view.clone() { + session.view = ListView::Peek { + mode, + page: page + 1, + }; + } + } + "prev" => { + if let ListView::Peek { mode, page } = session.view.clone() { + session.view = ListView::Peek { + mode, + page: page.saturating_sub(1), + }; + } + } + "back" => { + session.view = match session.view.clone() { + ListView::Selected { return_to, .. } => *return_to, + ListView::Peek { .. } => ListView::Menu, + other => other, + }; + } + "close" => { + if matches!(&session.kind, SessionKind::Search { .. }) { + delete_embedded_media_messages( + &bot, + message.chat.id, + &session.sent_media_message_ids, + ) + .await; + bot.delete_message(message.chat.id, message.id).await?; + let mut active = state.active_sessions.lock().await; + if active.get(&chat_id) == Some(&session.id) { + active.remove(&chat_id); + } + close_session = true; + refresh_list_view = false; + } + } + "random" => { + if matches!(&session.kind, SessionKind::List) { + if session.entries.is_empty() { + // Stay in place. + } else { + let mut remaining: Vec = (0..session.entries.len()) + .filter(|i| !session.seen_random.contains(i)) + .filter(|i| { + session + .entries + .get(*i) + .map(|entry| !peeked_snapshot.contains(&entry.block_string())) + .unwrap_or(false) + }) + .collect(); + if remaining.is_empty() { + send_ephemeral( + &bot, + message.chat.id, + "Everything's been peeked already.", + ACK_TTL_SECS, + ) + .await?; + // Stay in place. + session.view = ListView::Menu; + } else { + let index = { + let mut rng = rand::thread_rng(); + remaining.shuffle(&mut rng); + remaining.first().copied() + }; + if let Some(index) = index { + session.seen_random.insert(index); + let return_to = Box::new(session.view.clone()); + session.view = ListView::Selected { return_to, index }; + if let Some(entry) = session.entries.get(index) { + state.peeked.lock().await.insert(entry.block_string()); + } + } + } + } + } + } + "pick" => { + if let ListView::Peek { mode, page } = session.view.clone() { + let pick_index = parts.next().and_then(|p| p.parse::().ok()); + if let Some(pick_index) = pick_index { + if let Some(entry_index) = + peek_indices_for_session(&session, &peeked_snapshot, mode, page) + .get(pick_index.saturating_sub(1)) + .copied() + { + let return_to = Box::new(ListView::Peek { mode, page }); + session.view = ListView::Selected { + return_to, + index: entry_index, + }; + if matches!(&session.kind, SessionKind::List) { + if let Some(entry) = session.entries.get(entry_index) { + state.peeked.lock().await.insert(entry.block_string()); + } + } + } + } + } + } + "finish" => { + if let ListView::Selected { index, .. } = session.view.clone() { + session.view = ListView::FinishConfirm { + selected: Box::new(session.view.clone()), + index, + }; + } + } + "finish_now" => { + if let ListView::FinishConfirm { selected, index } = session.view.clone() { + let entry_block = session.entries.get(index).map(|e| e.block_string()); + if let Some(entry_block) = entry_block { + let op = QueuedOp { + kind: QueuedOpKind::MoveToFinished, + entry: entry_block.clone(), + resource_path: None, + updated_entry: None, + }; + match apply_user_op(&state, &op).await? { + UserOpOutcome::Applied(ApplyOutcome::Applied) => { + session.entries.remove(index); + if let ListView::Selected { return_to, .. } = *selected { + session.view = *return_to; + } else { + session.view = ListView::Menu; + } + normalize_peek_view(&mut session, &peeked_snapshot); + send_ephemeral(&bot, message.chat.id, "Moved.", ACK_TTL_SECS) + .await?; + let _ = + add_undo(&state, UndoKind::MoveToFinished, entry_block).await?; + } + UserOpOutcome::Applied(ApplyOutcome::NotFound) => { + send_error(&bot, message.chat.id, "Item not found.").await?; + session.view = *selected; + } + UserOpOutcome::Applied(ApplyOutcome::Duplicate) => { + session.view = *selected; + } + UserOpOutcome::Queued => { + send_error( + &bot, + message.chat.id, + "Write failed; queued for retry.", + ) + .await?; + session.view = *selected; + } + } + } + } + } + "finish_title" => { + if let ListView::FinishConfirm { selected, index } = session.view.clone() { + let selected_view = *selected; + if let Some(entry) = session.entries.get(index) { + let text = entry.display_lines().join("\n"); + let links = extract_links(&text); + if let Some(link) = links.first().cloned() { + let prompt_text = "Send a title for the finished item."; + let sent = bot.send_message(message.chat.id, prompt_text).await?; + let return_to = match selected_view.clone() { + ListView::Selected { return_to, .. } => *return_to, + _ => ListView::Menu, + }; + let prompt = FinishTitlePrompt { + session_id: session.id.clone(), + chat_id, + entry: entry.block_string(), + link, + return_to, + prompt_message_id: sent.id, + expires_at: now_ts() + FINISH_TITLE_PROMPT_TTL_SECS, + }; + let previous = state + .finish_title_prompts + .lock() + .await + .insert(chat_id, prompt); + if let Some(previous) = previous { + let _ = bot + .delete_message(message.chat.id, previous.prompt_message_id) + .await; + } + session.view = selected_view; + } else { + send_error(&bot, message.chat.id, "No link found for a title.").await?; + session.view = selected_view; + } + } else { + send_error(&bot, message.chat.id, "Item not found.").await?; + session.view = selected_view; + } + } + } + "finish_cancel" => { + if let ListView::FinishConfirm { selected, .. } = session.view.clone() { + session.view = *selected; + } + } + "resource" => { + if let ListView::Selected { index, .. } = session.view.clone() { + if let Some(entry) = session.entries.get(index) { + let text = entry.display_lines().join("\n"); + start_resource_picker(&bot, message.chat.id, &state, &text, None).await?; + refresh_list_view = false; + } else { + send_error(&bot, message.chat.id, "Item not found.").await?; + } + } + } + "delete" => { + if let ListView::Selected { index, .. } = session.view.clone() { + let expires_at = now_ts() + DELETE_CONFIRM_TTL_SECS; + session.view = ListView::DeleteConfirm { + selected: Box::new(session.view.clone()), + index, + step: 1, + expires_at, + }; + } + } + "del1" => { + if let ListView::DeleteConfirm { + selected, + index, + step: _, + expires_at, + } = session.view.clone() + { + if now_ts() > expires_at { + session.view = *selected; + send_error(&bot, message.chat.id, "Delete confirmation expired.").await?; + } else { + session.view = ListView::DeleteConfirm { + selected, + index, + step: 2, + expires_at, + }; + } + } + } + "del2" => { + if let ListView::DeleteConfirm { + selected, + index, + step: _, + expires_at, + } = session.view.clone() + { + if now_ts() > expires_at { + session.view = *selected; + send_error(&bot, message.chat.id, "Delete confirmation expired.").await?; + } else { + let entry_block = session.entries.get(index).map(|e| e.block_string()); + if let Some(entry_block) = entry_block { + let op = QueuedOp { + kind: QueuedOpKind::Delete, + entry: entry_block.clone(), + resource_path: None, + updated_entry: None, + }; + match apply_user_op(&state, &op).await? { + UserOpOutcome::Applied(ApplyOutcome::Applied) => { + session.entries.remove(index); + if let ListView::Selected { return_to, .. } = *selected { + session.view = *return_to; + } else { + session.view = ListView::Menu; + } + normalize_peek_view(&mut session, &peeked_snapshot); + let _ = add_undo(&state, UndoKind::Delete, entry_block).await?; + } + UserOpOutcome::Applied(ApplyOutcome::NotFound) => { + send_error(&bot, message.chat.id, "Item not found.").await?; + session.view = *selected; + } + UserOpOutcome::Applied(ApplyOutcome::Duplicate) => {} + UserOpOutcome::Queued => { + send_error( + &bot, + message.chat.id, + "Write failed; queued for retry.", + ) + .await?; + session.view = *selected; + } + } + } + } + } + } + "cancel_del" => { + if let ListView::DeleteConfirm { selected, .. } = session.view.clone() { + session.view = *selected; + } + } + _ => {} + } + + if close_session { + return Ok(()); + } + + if refresh_list_view { + session.message_id = Some(message.id); + let (text, kb) = + render_list_view(&session.id, &session, &peeked_snapshot, &state.config); + match bot + .edit_message_text(message.chat.id, message.id, text) + .reply_markup(kb) + .await + { + Ok(_) => {} + Err(err) if is_message_not_modified_error(&err) => {} + Err(err) => { + error!( + "list view edit failed; sending replacement message instead: {:#}", + err + ); + let (fallback_text, fallback_kb) = + render_list_view(&session.id, &session, &peeked_snapshot, &state.config); + let sent = bot + .send_message(message.chat.id, fallback_text) + .reply_markup(fallback_kb) + .await?; + session.message_id = Some(sent.id); + } + } + if let Err(err) = refresh_embedded_media_for_view( + &bot, + message.chat.id, + &state, + &mut session, + &peeked_snapshot, + ) + .await + { + error!("send embedded media failed: {:#}", err); + } + } + + Ok(()) + } + .await; + + if !close_session { + state + .sessions + .lock() + .await + .insert(session.id.clone(), session.clone()); + state + .active_sessions + .lock() + .await + .insert(chat_id, session.id.clone()); + } + + let answer_result = bot.answer_callback_query(q.id).await; + match action_result { + Ok(()) => { + answer_result?; + Ok(()) + } + Err(err) => { + if let Err(answer_err) = answer_result { + error!( + "answer callback query failed after list callback error: {:#}", + answer_err + ); + } + Err(err) + } + } +} + +fn is_message_not_modified_error(err: &teloxide::RequestError) -> bool { + err.to_string() + .to_ascii_lowercase() + .contains("message is not modified") +} + +async fn handle_picker_callback( + bot: Bot, + q: CallbackQuery, + state: std::sync::Arc, +) -> Result<()> { + let Some(message) = q.message.clone() else { + return Ok(()); + }; + let Some(data) = q.data.as_deref() else { + return Ok(()); + }; + let mut parts = data.split(':'); + let _ = parts.next(); + let picker_id = match parts.next() { + Some(id) => id.to_string(), + None => return Ok(()), + }; + let action = match parts.next() { + Some(action) => action, + None => return Ok(()), + }; + + let mut picker = { + let mut pickers = state.pickers.lock().await; + let picker = match pickers.remove(&picker_id) { + Some(picker) => picker, + None => { + bot.answer_callback_query(q.id).await?; + return Ok(()); + } + }; + if picker.chat_id != message.chat.id.0 || picker.message_id != message.id { + pickers.insert(picker_id.clone(), picker); + bot.answer_callback_query(q.id).await?; + return Ok(()); + } + picker + }; + + let mut reinsert = false; + + match action { + "toggle" => { + if let Some(index) = parts.next().and_then(|p| p.parse::().ok()) { + if index < picker.selected.len() { + picker.selected[index] = !picker.selected[index]; + } + } + let text = build_picker_text(&picker.items, &picker.selected); + let kb = build_picker_keyboard(&picker.id, &picker.selected); + bot.edit_message_text(message.chat.id, message.id, text) + .reply_markup(kb) + .await?; + reinsert = true; + } + "add" => { + let selected_items: Vec = picker + .items + .iter() + .zip(picker.selected.iter()) + .filter_map(|(item, selected)| if *selected { Some(item.clone()) } else { None }) + .collect(); + if selected_items.is_empty() { + bot.answer_callback_query(q.id) + .text("Select at least one item.") + .await?; + return Ok(()); + } + + let mut added = 0usize; + let mut duplicates = 0usize; + let mut queued = false; + for item in selected_items { + let entry = EntryBlock::from_text(&item); + let op = QueuedOp { + kind: QueuedOpKind::Add, + entry: entry.block_string(), + resource_path: None, + updated_entry: None, + }; + match apply_user_op(&state, &op).await? { + UserOpOutcome::Applied(ApplyOutcome::Applied) => added += 1, + UserOpOutcome::Applied(ApplyOutcome::Duplicate) => duplicates += 1, + UserOpOutcome::Applied(ApplyOutcome::NotFound) => {} + UserOpOutcome::Queued => queued = true, + } + } + + if queued { + send_error(&bot, message.chat.id, "Write failed; queued for retry.").await?; + } + + let summary = if duplicates > 0 { + format!( + "Saved {} item(s); {} duplicate(s) skipped.", + added, duplicates + ) + } else { + format!("Saved {} item(s).", added) + }; + send_ephemeral(&bot, message.chat.id, &summary, ACK_TTL_SECS).await?; + if !queued { + let _ = bot + .delete_message(ChatId(picker.chat_id), picker.source_message_id) + .await; + } + bot.delete_message(message.chat.id, message.id).await?; + } + "cancel" => { + bot.delete_message(message.chat.id, message.id).await?; + } + _ => {} + } + + if reinsert { + state.pickers.lock().await.insert(picker_id, picker); + } + + bot.answer_callback_query(q.id).await?; + Ok(()) +} + +async fn handle_undos_callback( + bot: Bot, + q: CallbackQuery, + state: std::sync::Arc, +) -> Result<()> { + let Some(message) = q.message.clone() else { + return Ok(()); + }; + let Some(data) = q.data.as_deref() else { + return Ok(()); + }; + + let mut parts = data.split(':'); + let _ = parts.next(); + let session_id = match parts.next() { + Some(id) => id.to_string(), + None => return Ok(()), + }; + let action = match parts.next() { + Some(action) => action, + None => return Ok(()), + }; + + let session = { + let mut sessions = state.undo_sessions.lock().await; + let session = match sessions.remove(&session_id) { + Some(session) => session, + None => { + bot.answer_callback_query(q.id).await?; + return Ok(()); + } + }; + if session.chat_id != message.chat.id.0 || session.message_id != message.id { + sessions.insert(session_id, session); + bot.answer_callback_query(q.id).await?; + return Ok(()); + } + session + }; + + match action { + "close" => { + let _ = bot.delete_message(message.chat.id, message.id).await; + bot.answer_callback_query(q.id).await?; + return Ok(()); + } + "undo" => { + let index = parts.next().and_then(|p| p.parse::().ok()); + let Some(index) = index else { + bot.answer_callback_query(q.id).await?; + return Ok(()); + }; + let Some(record) = session.records.get(index).cloned() else { + bot.answer_callback_query(q.id).await?; + return Ok(()); + }; + let op = match record.kind { + UndoKind::MoveToFinished => QueuedOp { + kind: QueuedOpKind::MoveToReadLater, + entry: record.entry, + resource_path: None, + updated_entry: None, + }, + UndoKind::Delete => QueuedOp { + kind: QueuedOpKind::Add, + entry: record.entry, + resource_path: None, + updated_entry: None, + }, + }; + + let mut undo = state.undo.lock().await; + prune_undo(&mut undo); + undo.retain(|r| r.id != record.id); + save_undo(&state.undo_path, &undo)?; + + match apply_user_op(&state, &op).await? { + UserOpOutcome::Applied(ApplyOutcome::Applied) + | UserOpOutcome::Applied(ApplyOutcome::Duplicate) + | UserOpOutcome::Applied(ApplyOutcome::NotFound) => { + send_ephemeral(&bot, message.chat.id, "Undone.", ACK_TTL_SECS).await?; + } + UserOpOutcome::Queued => { + send_error(&bot, message.chat.id, "Write failed; queued for retry.").await?; + } + } + } + "delete" => { + let index = parts.next().and_then(|p| p.parse::().ok()); + let Some(index) = index else { + bot.answer_callback_query(q.id).await?; + return Ok(()); + }; + let Some(record) = session.records.get(index).cloned() else { + bot.answer_callback_query(q.id).await?; + return Ok(()); + }; + let mut undo = state.undo.lock().await; + prune_undo(&mut undo); + undo.retain(|r| r.id != record.id); + save_undo(&state.undo_path, &undo)?; + } + _ => { + bot.answer_callback_query(q.id).await?; + return Ok(()); + } + } + + let _ = bot.delete_message(message.chat.id, message.id).await; + bot.answer_callback_query(q.id).await?; + Ok(()) +} + +async fn handle_undo_callback( + bot: Bot, + q: CallbackQuery, + state: std::sync::Arc, +) -> Result<()> { + let Some(data) = q.data.as_deref() else { + return Ok(()); + }; + let mut parts = data.trim_start_matches("undo:").split(':'); + let undo_id = parts.next().unwrap_or(""); + let action = parts.next().unwrap_or("undo"); + + let (record, undo_snapshot) = { + let mut undo = state.undo.lock().await; + prune_undo(&mut undo); + let pos = undo.iter().position(|r| r.id == undo_id); + let record = if let Some(pos) = pos { + Some(undo.remove(pos)) + } else { + None + }; + (record, undo.clone()) + }; + save_undo(&state.undo_path, &undo_snapshot)?; + + if action == "delete" { + if let Some(message) = q.message.clone() { + bot.delete_message(message.chat.id, message.id).await?; + } + bot.answer_callback_query(q.id).await?; + return Ok(()); + } + + if let Some(record) = record { + let chat_id = chat_id_from_user_id(q.from.id.0); + if record.expires_at < now_ts() { + send_error(&bot, chat_id, "Undo expired.").await?; + bot.answer_callback_query(q.id).await?; + return Ok(()); + } + + let op = match record.kind { + UndoKind::MoveToFinished => QueuedOp { + kind: QueuedOpKind::MoveToReadLater, + entry: record.entry, + resource_path: None, + updated_entry: None, + }, + UndoKind::Delete => QueuedOp { + kind: QueuedOpKind::Add, + entry: record.entry, + resource_path: None, + updated_entry: None, + }, + }; + + match apply_user_op(&state, &op).await? { + UserOpOutcome::Applied(ApplyOutcome::Applied) + | UserOpOutcome::Applied(ApplyOutcome::Duplicate) + | UserOpOutcome::Applied(ApplyOutcome::NotFound) => { + send_ephemeral(&bot, chat_id, "Undone.", ACK_TTL_SECS).await?; + } + UserOpOutcome::Queued => { + send_error(&bot, chat_id, "Write failed; queued for retry.").await?; + } + } + if let Some(message) = q.message.clone() { + let _ = bot.delete_message(message.chat.id, message.id).await; + } + } else { + send_error(&bot, chat_id_from_user_id(q.from.id.0), "Undo not found.").await?; + } + + bot.answer_callback_query(q.id).await?; + Ok(()) +} diff --git a/src/helpers.rs b/src/helpers.rs new file mode 100644 index 0000000..b5ffd54 --- /dev/null +++ b/src/helpers.rs @@ -0,0 +1,1592 @@ +use super::*; + +pub(super) fn search_entries(entries: &[EntryBlock], query: &str) -> Vec { + entries + .iter() + .filter(|entry| matches_query(entry, query)) + .cloned() + .collect() +} + +pub(super) fn matches_query(entry: &EntryBlock, query: &str) -> bool { + let needle = query.trim().to_lowercase(); + if needle.is_empty() { + return false; + } + let haystack = entry.display_lines().join("\n").to_lowercase(); + needle + .split_whitespace() + .all(|term| haystack.contains(term)) +} + +#[cfg(test)] +pub(super) fn displayed_indices_for_view(session: &ListSession, peeked: &HashSet) -> Vec { + match session.view { + ListView::Peek { mode, page } => peek_indices_for_session(session, peeked, mode, page), + ListView::Selected { index, .. } => vec![index], + ListView::FinishConfirm { index, .. } => vec![index], + ListView::DeleteConfirm { index, .. } => vec![index], + _ => Vec::new(), + } +} + +pub(super) fn embedded_lines_for_view(session: &ListSession, peeked: &HashSet) -> Vec { + match session.view { + ListView::Peek { mode, page } => peek_indices_for_session(session, peeked, mode, page) + .into_iter() + .filter_map(|index| session.entries.get(index)) + .flat_map(|entry| entry.preview_lines()) + .collect(), + ListView::Selected { index, .. } => session + .entries + .get(index) + .map(|entry| entry.display_lines()) + .unwrap_or_default(), + ListView::FinishConfirm { index, .. } | ListView::DeleteConfirm { index, .. } => session + .entries + .get(index) + .map(|entry| entry.preview_lines()) + .unwrap_or_default(), + _ => Vec::new(), + } +} + +pub(super) fn norm_target_index(session: &ListSession, peeked: &HashSet) -> Option { + match &session.view { + ListView::Selected { index, .. } => Some(*index), + ListView::FinishConfirm { index, .. } => Some(*index), + ListView::Peek { mode, page } => { + let indices = peek_indices_for_session(session, peeked, *mode, *page); + if indices.len() == 1 { + indices.first().copied() + } else { + None + } + } + _ => None, + } +} + +pub(super) fn normalize_entry_markdown_links(entry: &EntryBlock) -> Option { + let mut changed = false; + let mut lines = Vec::with_capacity(entry.lines.len()); + for line in &entry.lines { + let (normalized, line_changed) = normalize_markdown_links(line); + if line_changed { + changed = true; + } + lines.push(normalized); + } + if changed { + Some(EntryBlock { lines }) + } else { + None + } +} + +pub(super) fn normalize_markdown_links(text: &str) -> (String, bool) { + if !text.contains('[') { + return (text.to_string(), false); + } + + let mut out = String::with_capacity(text.len()); + let mut index = 0; + let mut changed = false; + + while let Some(start_rel) = text[index..].find('[') { + let start = index + start_rel; + out.push_str(&text[index..start]); + + let label_start = start + 1; + let Some(label_end_rel) = text[label_start..].find(']') else { + out.push_str(&text[start..]); + return (out, changed); + }; + let label_end = label_start + label_end_rel; + let after_label = label_end + 1; + if after_label >= text.len() || !text[after_label..].starts_with('(') { + out.push_str(&text[start..after_label]); + index = after_label; + continue; + } + + let url_start = after_label + 1; + let Some(url_end_rel) = text[url_start..].find(')') else { + out.push_str(&text[start..]); + return (out, changed); + }; + let url_end = url_start + url_end_rel; + out.push_str(&text[url_start..url_end]); + changed = true; + index = url_end + 1; + } + + out.push_str(&text[index..]); + (out, changed) +} + +pub(super) fn extract_links(text: &str) -> Vec { + let mut links = Vec::new(); + let mut seen: HashSet = HashSet::new(); + + let mut index = 0; + while let Some(start_rel) = text[index..].find('[') { + let start = index + start_rel; + let label_start = start + 1; + let Some(label_end_rel) = text[label_start..].find(']') else { + break; + }; + let label_end = label_start + label_end_rel; + let after_label = label_end + 1; + if after_label >= text.len() || !text[after_label..].starts_with('(') { + index = after_label; + continue; + } + let url_start = after_label + 1; + let Some(url_end_rel) = text[url_start..].find(')') else { + break; + }; + let url_end = url_start + url_end_rel; + let url = text[url_start..url_end].trim(); + if is_http_link(url) { + push_link(&mut links, &mut seen, url.to_string()); + } + index = url_end + 1; + } + + let mut scan = 0; + while scan < text.len() { + let slice = &text[scan..]; + let http_pos = slice.find("http://"); + let https_pos = slice.find("https://"); + let pos = match (http_pos, https_pos) { + (Some(a), Some(b)) => Some(a.min(b)), + (Some(a), None) => Some(a), + (None, Some(b)) => Some(b), + (None, None) => None, + }; + let Some(pos) = pos else { + break; + }; + let start = scan + pos; + let rest = &text[start..]; + let end_rel = rest.find(|c: char| c.is_whitespace()).unwrap_or(rest.len()); + let end = start + end_rel; + let mut url = text[start..end].to_string(); + url = trim_link(&url); + if is_http_link(&url) { + push_link(&mut links, &mut seen, url); + } + scan = end; + } + + links +} + +pub(super) fn is_http_link(link: &str) -> bool { + link.starts_with("http://") || link.starts_with("https://") +} + +pub(super) fn push_link(links: &mut Vec, seen: &mut HashSet, link: String) { + if seen.insert(link.clone()) { + links.push(link); + } +} + +pub(super) fn trim_link(link: &str) -> String { + link.trim() + .trim_end_matches(|c: char| ")]}>\"'.,;:!?".contains(c)) + .to_string() +} + +pub(super) fn entry_with_title(entry: &str, title: &str, link: &str) -> String { + let mut entry = EntryBlock::from_block(entry); + let line = format!("- [{}]({})", title.trim(), link); + if entry.lines.is_empty() { + entry.lines.push(line); + } else { + entry.lines[0] = line; + } + entry.block_string() +} + +pub(super) fn build_picker_text(items: &[String], selected: &[bool]) -> String { + let mut text = String::from("Select items to save:\n\n"); + for (idx, item) in items.iter().enumerate() { + let marker = if selected.get(idx).copied().unwrap_or(false) { + "[x]" + } else { + "[ ]" + }; + let preview = preview_text(item); + text.push_str(&format!("{} {}\n", idx + 1, marker)); + if let Some(first) = preview.get(0) { + text.push_str(&format!("{}\n", first)); + } + if let Some(second) = preview.get(1) { + text.push_str(&format!("{}\n", second)); + } + text.push('\n'); + } + text.trim_end().to_string() +} + +pub(super) fn build_picker_keyboard(picker_id: &str, selected: &[bool]) -> InlineKeyboardMarkup { + let mut rows = Vec::new(); + for (idx, is_selected) in selected.iter().enumerate() { + let label = if *is_selected { + format!("{} [x]", idx + 1) + } else { + format!("{} [ ]", idx + 1) + }; + let data = format!("pick:{}:toggle:{}", picker_id, idx); + rows.push(vec![InlineKeyboardButton::callback(label, data)]); + } + rows.push(vec![ + InlineKeyboardButton::callback("Add selected", format!("pick:{}:add", picker_id)), + InlineKeyboardButton::callback("Cancel", format!("pick:{}:cancel", picker_id)), + ]); + InlineKeyboardMarkup::new(rows) +} + +pub(super) fn build_add_prompt_keyboard(prompt_id: &str) -> InlineKeyboardMarkup { + InlineKeyboardMarkup::new(vec![ + vec![ + InlineKeyboardButton::callback("Reading list", format!("add:{}:normal", prompt_id)), + InlineKeyboardButton::callback("Resource", format!("add:{}:resource", prompt_id)), + ], + vec![InlineKeyboardButton::callback( + "Cancel", + format!("add:{}:cancel", prompt_id), + )], + ]) +} + +pub(super) fn build_resource_picker_keyboard(picker_id: &str, files: &[PathBuf]) -> InlineKeyboardMarkup { + let mut rows: Vec> = Vec::new(); + let mut current_row = Vec::new(); + for (idx, path) in files.iter().enumerate() { + let label = path + .file_name() + .and_then(|name| name.to_str()) + .map(|name| name.to_string()) + .unwrap_or_else(|| path.to_string_lossy().to_string()); + current_row.push(InlineKeyboardButton::callback( + label, + format!("res:{}:file:{}", picker_id, idx), + )); + if current_row.len() == 2 { + rows.push(std::mem::take(&mut current_row)); + } + } + if !current_row.is_empty() { + rows.push(current_row); + } + rows.push(vec![InlineKeyboardButton::callback( + "New file", + format!("res:{}:new", picker_id), + )]); + rows.push(vec![InlineKeyboardButton::callback( + "Cancel", + format!("res:{}:cancel", picker_id), + )]); + InlineKeyboardMarkup::new(rows) +} + +pub(super) fn build_download_picker_text(links: &[String]) -> String { + if links.is_empty() { + return "No links found. Add one?".to_string(); + } + let mut text = String::from("Links:\n\n"); + for (idx, link) in links.iter().enumerate() { + text.push_str(&format!("{}: {}\n", idx + 1, link)); + } + text.trim_end().to_string() +} + +pub(super) fn build_download_quality_text( + link: &str, + action: DownloadAction, + options: &[DownloadQualityOption], +) -> String { + let action_label = match action { + DownloadAction::Send => "send", + DownloadAction::Save => "save", + }; + let mut text = format!("Choose quality to {}:\n{}\n\n", action_label, link); + for (idx, option) in options.iter().enumerate() { + text.push_str(&format!("{}: {}\n", idx + 1, option.label)); + } + text.trim_end().to_string() +} + +pub(super) fn build_download_picker_keyboard(picker_id: &str, links: &[String]) -> InlineKeyboardMarkup { + let mut rows = Vec::new(); + for (idx, _) in links.iter().enumerate() { + rows.push(vec![ + InlineKeyboardButton::callback( + format!("Send {}", idx + 1), + format!("dl:{}:send:{}", picker_id, idx), + ), + InlineKeyboardButton::callback( + format!("Save {}", idx + 1), + format!("dl:{}:save:{}", picker_id, idx), + ), + ]); + } + rows.push(vec![InlineKeyboardButton::callback( + "Add link", + format!("dl:{}:add", picker_id), + )]); + rows.push(vec![InlineKeyboardButton::callback( + "Cancel", + format!("dl:{}:cancel", picker_id), + )]); + InlineKeyboardMarkup::new(rows) +} + +pub(super) fn build_download_quality_keyboard( + picker_id: &str, + options: &[DownloadQualityOption], +) -> InlineKeyboardMarkup { + let mut rows = Vec::new(); + for (idx, option) in options.iter().enumerate() { + rows.push(vec![InlineKeyboardButton::callback( + option.label.clone(), + format!("dl:{}:quality:{}", picker_id, idx), + )]); + } + rows.push(vec![InlineKeyboardButton::callback( + "Back", + format!("dl:{}:back", picker_id), + )]); + rows.push(vec![InlineKeyboardButton::callback( + "Cancel", + format!("dl:{}:cancel", picker_id), + )]); + InlineKeyboardMarkup::new(rows) +} + +pub(super) fn render_list_view( + session_id: &str, + session: &ListSession, + peeked: &HashSet, + config: &Config, +) -> (String, InlineKeyboardMarkup) { + match &session.view { + ListView::Menu => build_menu_view(session_id, session), + ListView::Peek { mode, page } => { + build_peek_view(session_id, session, *mode, *page, peeked, config) + } + ListView::Selected { index, .. } => { + build_selected_view(session_id, session, *index, config) + } + ListView::FinishConfirm { index, .. } => { + build_finish_confirm_view(session_id, session, *index, config) + } + ListView::DeleteConfirm { step, index, .. } => { + build_delete_confirm_view(session_id, session, *index, *step, config) + } + } +} + +pub(super) fn build_menu_view(session_id: &str, session: &ListSession) -> (String, InlineKeyboardMarkup) { + let count = session.entries.len(); + match &session.kind { + SessionKind::List => { + let text = if count == 0 { + "Read Later is empty.".to_string() + } else { + "Choose Top, Bottom, or Random.".to_string() + }; + + let mut rows = Vec::new(); + if count > 0 { + rows.push(vec![ + InlineKeyboardButton::callback( + format!("Top ({})", count), + format!("ls:{}:top:0", session_id), + ), + InlineKeyboardButton::callback( + format!("Bottom ({})", count), + format!("ls:{}:bottom:0", session_id), + ), + ]); + rows.push(vec![InlineKeyboardButton::callback( + "Random", + format!("ls:{}:random", session_id), + )]); + } + + (text, InlineKeyboardMarkup::new(rows)) + } + SessionKind::Search { query } => { + let text = if count == 0 { + format!("No matches for \"{}\".", query) + } else { + format!("Matches for \"{}\" ({}).", query, count) + }; + + let mut rows = Vec::new(); + if count > 0 { + rows.push(vec![InlineKeyboardButton::callback( + "Show", + format!("ls:{}:top:0", session_id), + )]); + } + rows.push(vec![InlineKeyboardButton::callback( + "Close", + format!("ls:{}:close", session_id), + )]); + + (text, InlineKeyboardMarkup::new(rows)) + } + } +} + +pub(super) fn build_peek_view( + session_id: &str, + session: &ListSession, + mode: ListMode, + page: usize, + peeked: &HashSet, + config: &Config, +) -> (String, InlineKeyboardMarkup) { + let total_unpeeked = count_visible_entries(session, peeked); + let indices = peek_indices_for_session(session, peeked, mode, page); + let total_pages = if total_unpeeked == 0 { + 0 + } else { + (total_unpeeked + PAGE_SIZE - 1) / PAGE_SIZE + }; + let mut text = match &session.kind { + SessionKind::List => { + let title = match mode { + ListMode::Top => "Top view", + ListMode::Bottom => "Bottom view", + }; + let page_display = if total_pages == 0 { 0 } else { page + 1 }; + format!("{} (page {})\n", title, page_display) + } + SessionKind::Search { query } => { + if total_pages > 0 { + format!( + "Matches for \"{}\" (page {}/{})\n", + query, + page + 1, + total_pages + ) + } else { + format!("Matches for \"{}\"\n", query) + } + } + }; + if total_unpeeked == 0 { + text.push_str("Everything's been peeked already."); + } else if indices.is_empty() { + text.push_str("No items on this page."); + } else { + for (display_index, entry_index) in indices.iter().enumerate() { + if let Some(entry) = session.entries.get(*entry_index) { + let preview = format_embedded_references_for_lines(&entry.preview_lines(), config); + text.push_str(&format!("{}) ", display_index + 1)); + if let Some(first) = preview.get(0) { + text.push_str(first); + } + text.push('\n'); + if let Some(second) = preview.get(1) { + text.push_str(" "); + text.push_str(second); + text.push('\n'); + } + } + } + } + + let mut rows = Vec::new(); + if !indices.is_empty() { + let mut pick_row = Vec::new(); + for i in 0..indices.len() { + pick_row.push(InlineKeyboardButton::callback( + format!("{}", i + 1), + format!("ls:{}:pick:{}", session_id, i + 1), + )); + } + rows.push(pick_row); + } + + rows.push(vec![ + InlineKeyboardButton::callback("Prev", format!("ls:{}:prev", session_id)), + InlineKeyboardButton::callback("Next", format!("ls:{}:next", session_id)), + ]); + match &session.kind { + SessionKind::List => { + rows.push(vec![ + InlineKeyboardButton::callback("Back", format!("ls:{}:back", session_id)), + InlineKeyboardButton::callback("Random", format!("ls:{}:random", session_id)), + ]); + } + SessionKind::Search { .. } => { + rows.push(vec![InlineKeyboardButton::callback( + "Close", + format!("ls:{}:close", session_id), + )]); + } + } + + (text.trim_end().to_string(), InlineKeyboardMarkup::new(rows)) +} + +pub(super) fn build_selected_view( + session_id: &str, + session: &ListSession, + index: usize, + config: &Config, +) -> (String, InlineKeyboardMarkup) { + let entry = session.entries.get(index); + let text = if let Some(entry) = entry { + let lines = format_embedded_references_for_lines(&entry.display_lines(), config); + format!("Selected item:\n\n{}", lines.join("\n")) + } else { + "Selected item not found.".to_string() + }; + + let rows = match &session.kind { + SessionKind::List => vec![ + vec![ + InlineKeyboardButton::callback( + "Mark Finished", + format!("ls:{}:finish", session_id), + ), + InlineKeyboardButton::callback( + "Add Resource", + format!("ls:{}:resource", session_id), + ), + ], + vec![ + InlineKeyboardButton::callback("Delete", format!("ls:{}:delete", session_id)), + InlineKeyboardButton::callback("Random", format!("ls:{}:random", session_id)), + ], + vec![InlineKeyboardButton::callback( + "Back", + format!("ls:{}:back", session_id), + )], + ], + SessionKind::Search { .. } => vec![ + vec![InlineKeyboardButton::callback( + "Add Resource", + format!("ls:{}:resource", session_id), + )], + vec![InlineKeyboardButton::callback( + "Delete", + format!("ls:{}:delete", session_id), + )], + vec![InlineKeyboardButton::callback( + "Back", + format!("ls:{}:back", session_id), + )], + ], + }; + + (text, InlineKeyboardMarkup::new(rows)) +} + +pub(super) fn build_undos_view(session_id: &str, records: &[UndoRecord]) -> (String, InlineKeyboardMarkup) { + let mut text = format!("Undos ({})\n\n", records.len()); + for (idx, record) in records.iter().enumerate() { + let label = match record.kind { + UndoKind::MoveToFinished => "Moved to finished", + UndoKind::Delete => "Deleted", + }; + text.push_str(&format!("{}) {}\n", idx + 1, label)); + let preview = undo_preview(&record.entry); + if let Some(first) = preview.get(0) { + text.push_str(" "); + text.push_str(first); + text.push('\n'); + } + if let Some(second) = preview.get(1) { + text.push_str(" "); + text.push_str(second); + text.push('\n'); + } + text.push('\n'); + } + + let mut rows = Vec::new(); + for (idx, _) in records.iter().enumerate() { + rows.push(vec![ + InlineKeyboardButton::callback( + format!("Undo {}", idx + 1), + format!("undos:{}:undo:{}", session_id, idx), + ), + InlineKeyboardButton::callback( + format!("Delete {}", idx + 1), + format!("undos:{}:delete:{}", session_id, idx), + ), + ]); + } + rows.push(vec![InlineKeyboardButton::callback( + "Close", + format!("undos:{}:close", session_id), + )]); + + (text.trim_end().to_string(), InlineKeyboardMarkup::new(rows)) +} + +pub(super) fn build_finish_confirm_view( + session_id: &str, + session: &ListSession, + index: usize, + config: &Config, +) -> (String, InlineKeyboardMarkup) { + let entry = session.entries.get(index); + let preview = entry + .map(|e| format_embedded_references_for_lines(&e.preview_lines(), config)) + .unwrap_or_default(); + let mut text = String::from("Finish this item?\n\n"); + if let Some(first) = preview.get(0) { + text.push_str(first); + text.push('\n'); + } + if let Some(second) = preview.get(1) { + text.push_str(second); + text.push('\n'); + } + + let rows = vec![ + vec![InlineKeyboardButton::callback( + "Finish", + format!("ls:{}:finish_now", session_id), + )], + vec![InlineKeyboardButton::callback( + "Finish + Title", + format!("ls:{}:finish_title", session_id), + )], + vec![InlineKeyboardButton::callback( + "Cancel", + format!("ls:{}:finish_cancel", session_id), + )], + ]; + + (text.trim_end().to_string(), InlineKeyboardMarkup::new(rows)) +} + +pub(super) fn build_delete_confirm_view( + session_id: &str, + session: &ListSession, + index: usize, + step: u8, + config: &Config, +) -> (String, InlineKeyboardMarkup) { + let entry = session.entries.get(index); + let preview = entry + .map(|e| format_embedded_references_for_lines(&e.preview_lines(), config)) + .unwrap_or_default(); + let mut text = format!("Confirm delete ({}/2)?\n\n", step); + if let Some(first) = preview.get(0) { + text.push_str(first); + text.push('\n'); + } + if let Some(second) = preview.get(1) { + text.push_str(second); + text.push('\n'); + } + + let confirm_action = if step == 1 { "del1" } else { "del2" }; + let rows = vec![ + vec![InlineKeyboardButton::callback( + "Confirm", + format!("ls:{}:{}", session_id, confirm_action), + )], + vec![InlineKeyboardButton::callback( + "Cancel", + format!("ls:{}:cancel_del", session_id), + )], + ]; + + (text.trim_end().to_string(), InlineKeyboardMarkup::new(rows)) +} + +pub(super) fn count_unpeeked_entries(entries: &[EntryBlock], peeked: &HashSet) -> usize { + entries + .iter() + .filter(|entry| !peeked.contains(&entry.block_string())) + .count() +} + +pub(super) fn count_visible_entries(session: &ListSession, peeked: &HashSet) -> usize { + match session.kind { + SessionKind::Search { .. } => session.entries.len(), + SessionKind::List => count_unpeeked_entries(&session.entries, peeked), + } +} + +pub(super) fn ordered_unpeeked_indices( + entries: &[EntryBlock], + peeked: &HashSet, + mode: ListMode, +) -> Vec { + let mut indices: Vec = entries + .iter() + .enumerate() + .filter(|(_, entry)| !peeked.contains(&entry.block_string())) + .map(|(idx, _)| idx) + .collect(); + if matches!(mode, ListMode::Bottom) { + indices.reverse(); + } + indices +} + +pub(super) fn ordered_indices(entries: &[EntryBlock], mode: ListMode) -> Vec { + let mut indices: Vec = (0..entries.len()).collect(); + if matches!(mode, ListMode::Bottom) { + indices.reverse(); + } + indices +} + +pub(super) fn peek_indices( + entries: &[EntryBlock], + peeked: &HashSet, + mode: ListMode, + page: usize, +) -> Vec { + let ordered = ordered_unpeeked_indices(entries, peeked, mode); + if ordered.is_empty() { + return Vec::new(); + } + let start = page * PAGE_SIZE; + if start >= ordered.len() { + return Vec::new(); + } + let end = (start + PAGE_SIZE).min(ordered.len()); + ordered[start..end].to_vec() +} + +pub(super) fn peek_indices_all(entries: &[EntryBlock], mode: ListMode, page: usize) -> Vec { + let ordered = ordered_indices(entries, mode); + if ordered.is_empty() { + return Vec::new(); + } + let start = page * PAGE_SIZE; + if start >= ordered.len() { + return Vec::new(); + } + let end = (start + PAGE_SIZE).min(ordered.len()); + ordered[start..end].to_vec() +} + +pub(super) fn peek_indices_for_session( + session: &ListSession, + peeked: &HashSet, + mode: ListMode, + page: usize, +) -> Vec { + match session.kind { + SessionKind::Search { .. } => peek_indices_all(&session.entries, mode, page), + SessionKind::List => peek_indices(&session.entries, peeked, mode, page), + } +} + +pub(super) fn normalize_peek_view(session: &mut ListSession, peeked: &HashSet) { + if let ListView::Peek { mode, page } = session.view.clone() { + let indices = peek_indices_for_session(session, peeked, mode, page); + if indices.is_empty() && page > 0 { + session.view = ListView::Peek { + mode, + page: page.saturating_sub(1), + }; + } + } +} + +pub(super) fn preview_text(text: &str) -> Vec { + let normalized = normalize_line_endings(text); + let lines: Vec<&str> = normalized.lines().collect(); + let mut out = Vec::new(); + if let Some(first) = lines.get(0) { + out.push(first.to_string()); + } + if let Some(second) = lines.get(1) { + out.push(second.to_string()); + } + if lines.len() > 2 { + if let Some(last) = out.last_mut() { + last.push_str("..."); + } + } + out +} + +pub(super) fn undo_preview(entry: &str) -> Vec { + let entry = EntryBlock::from_block(entry); + entry.preview_lines() +} + +pub(super) fn delete_message_keyboard() -> InlineKeyboardMarkup { + InlineKeyboardMarkup::new(vec![vec![InlineKeyboardButton::callback( + "Delete message", + "msgdel", + )]]) +} + +pub(super) async fn send_message_with_delete_button( + bot: &Bot, + chat_id: ChatId, + text: impl Into, +) -> Result { + let sent = bot + .send_message(chat_id, text.into()) + .reply_markup(delete_message_keyboard()) + .await?; + Ok(sent) +} + +pub(super) async fn send_ephemeral(bot: &Bot, chat_id: ChatId, text: &str, ttl_secs: u64) -> Result<()> { + let sent = bot.send_message(chat_id, text).await?; + let bot = bot.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(ttl_secs)).await; + let _ = bot.delete_message(chat_id, sent.id).await; + }); + Ok(()) +} + +pub(super) async fn send_error(bot: &Bot, chat_id: ChatId, text: &str) -> Result<()> { + send_message_with_delete_button(bot, chat_id, text).await?; + Ok(()) +} + +pub(super) async fn send_embedded_media_for_view( + bot: &Bot, + chat_id: ChatId, + state: &std::sync::Arc, + session: &ListSession, + peeked: &HashSet, +) -> Result> { + let lines = embedded_lines_for_view(session, peeked); + let embeds = extract_embedded_paths(&lines, &state.config); + let mut sent_message_ids = Vec::new(); + for path in embeds { + if is_image_path(&path) { + let sent = bot.send_photo(chat_id, InputFile::file(path)).await?; + sent_message_ids.push(sent.id); + } else if is_video_path(&path) { + let sent = bot.send_video(chat_id, InputFile::file(path)).await?; + sent_message_ids.push(sent.id); + } else { + let sent = bot.send_document(chat_id, InputFile::file(path)).await?; + sent_message_ids.push(sent.id); + } + } + Ok(sent_message_ids) +} + +pub(super) async fn delete_embedded_media_messages(bot: &Bot, chat_id: ChatId, message_ids: &[MessageId]) { + for message_id in message_ids { + let _ = bot.delete_message(chat_id, *message_id).await; + } +} + +pub(super) async fn refresh_embedded_media_for_view( + bot: &Bot, + chat_id: ChatId, + state: &std::sync::Arc, + session: &mut ListSession, + peeked: &HashSet, +) -> Result<()> { + delete_embedded_media_messages(bot, chat_id, &session.sent_media_message_ids).await; + session.sent_media_message_ids = + send_embedded_media_for_view(bot, chat_id, state, session, peeked).await?; + Ok(()) +} + +pub(super) async fn reset_peeked(state: &std::sync::Arc) { + let mut peeked = state.peeked.lock().await; + peeked.clear(); +} + +pub(super) async fn add_undo( + state: &std::sync::Arc, + kind: UndoKind, + entry: String, +) -> Result { + let mut undo = state.undo.lock().await; + prune_undo(&mut undo); + let id = short_id(); + undo.push(UndoRecord { + id: id.clone(), + kind, + entry, + expires_at: now_ts() + UNDO_TTL_SECS, + }); + save_undo(&state.undo_path, &undo)?; + Ok(id) +} + +pub(super) async fn with_retries(mut f: F) -> Result +where + F: FnMut() -> Result, +{ + let mut last_err = None; + for attempt in 0..3 { + match f() { + Ok(value) => return Ok(value), + Err(err) => last_err = Some(err), + } + if attempt < 2 { + tokio::time::sleep(Duration::from_millis(200)).await; + } + } + Err(last_err.unwrap_or_else(|| anyhow!("retry failed"))) +} + +pub(super) fn resolve_user_id(input: UserIdInput, config_dir: &Path) -> Result { + match input { + UserIdInput::Number(value) => Ok(value), + UserIdInput::String(raw) => resolve_user_id_string(&raw, config_dir), + UserIdInput::File { file } => { + let path = resolve_user_id_path(&file, config_dir); + read_user_id_file(&path) + } + } +} + +pub(super) fn resolve_user_id_string(raw: &str, config_dir: &Path) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(anyhow!("user_id is empty")); + } + if trimmed.chars().all(|c| c.is_ascii_digit()) { + return parse_user_id_value(trimmed).context("parse user_id"); + } + let path = resolve_user_id_path(Path::new(trimmed), config_dir); + read_user_id_file(&path) +} + +pub(super) fn resolve_user_id_path(path: &Path, config_dir: &Path) -> PathBuf { + if path.is_relative() { + config_dir.join(path) + } else { + path.to_path_buf() + } +} + +pub(super) fn read_user_id_file(path: &Path) -> Result { + let contents = fs::read_to_string(path) + .with_context(|| format!("read user_id file {}", path.display()))?; + parse_user_id_value(contents.trim()) + .with_context(|| format!("parse user_id from {}", path.display())) +} + +pub(super) fn parse_user_id_value(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(anyhow!("user_id is empty")); + } + trimmed.parse::().context("parse user_id") +} + +pub(super) fn load_config(path: &Path) -> Result { + let contents = + fs::read_to_string(path).with_context(|| format!("read config {}", path.display()))?; + let config_file: ConfigFile = toml::from_str(&contents).context("parse config")?; + let config_dir = path.parent().unwrap_or_else(|| Path::new(".")); + let user_id = resolve_user_id(config_file.user_id, config_dir)?; + let default_media_dir = config_file + .read_later_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join("Misc/images_misc"); + let media_dir = config_file.media_dir.unwrap_or(default_media_dir); + let sync_x = config_file.sync_x.map(|sync_x| SyncXConfig { + source_project_path: resolve_user_id_path(&sync_x.source_project_path, config_dir), + work_dir: sync_x + .work_dir + .as_ref() + .map(|p| resolve_user_id_path(p, config_dir)), + python_bin: sync_x + .python_bin + .as_ref() + .map(|p| resolve_user_id_path(p, config_dir)), + }); + Ok(Config { + token: config_file.token, + user_id, + read_later_path: config_file.read_later_path, + finished_path: config_file.finished_path, + resources_path: config_file.resources_path, + media_dir, + data_dir: config_file.data_dir, + retry_interval_seconds: config_file.retry_interval_seconds, + sync: config_file.sync, + sync_x, + }) +} + +pub(super) fn list_resource_files(dir: &Path) -> Result> { + let mut files = Vec::new(); + if !dir.exists() { + return Ok(files); + } + let entries = fs::read_dir(dir).with_context(|| format!("read dir {}", dir.display()))?; + for entry in entries { + let entry = entry.with_context(|| format!("read dir entry {}", dir.display()))?; + let path = entry.path(); + let file_type = entry + .file_type() + .with_context(|| format!("read file type {}", path.display()))?; + if !file_type.is_file() { + continue; + } + let is_md = path + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.eq_ignore_ascii_case("md")) + .unwrap_or(false); + if is_md { + files.push(path); + } + } + files.sort_by(|a, b| { + let a_name = a + .file_name() + .map(|n| n.to_string_lossy()) + .unwrap_or_default(); + let b_name = b + .file_name() + .map(|n| n.to_string_lossy()) + .unwrap_or_default(); + a_name.cmp(&b_name) + }); + Ok(files) +} + +pub(super) fn read_entries(path: &Path) -> Result<(Vec, Vec)> { + if !path.exists() { + return Ok((Vec::new(), Vec::new())); + } + let contents = + fs::read_to_string(path).with_context(|| format!("read file {}", path.display()))?; + let normalized = normalize_line_endings(&contents); + Ok(parse_entries(&normalized)) +} + +pub(super) fn parse_entries(contents: &str) -> (Vec, Vec) { + let mut preamble = Vec::new(); + let mut entries: Vec = Vec::new(); + let mut current: Vec = Vec::new(); + let mut in_entries = false; + + for line in contents.lines() { + if line.starts_with('-') { + if in_entries && !current.is_empty() { + entries.push(EntryBlock { lines: current }); + current = Vec::new(); + } + in_entries = true; + current.push(line.to_string()); + } else if in_entries { + current.push(line.to_string()); + } else { + preamble.push(line.to_string()); + } + } + + if in_entries && !current.is_empty() { + entries.push(EntryBlock { lines: current }); + } + + (preamble, entries) +} + +pub(super) fn write_entries(path: &Path, preamble: &[String], entries: &[EntryBlock]) -> Result<()> { + let mut lines: Vec = Vec::new(); + lines.extend_from_slice(preamble); + for entry in entries { + lines.extend(entry.lines.clone()); + } + let mut content = lines.join("\n"); + if !content.is_empty() { + content.push('\n'); + } + atomic_write(path, content.as_bytes()) +} + +pub(super) fn atomic_write(path: &Path, data: &[u8]) -> Result<()> { + let dir = path + .parent() + .ok_or_else(|| anyhow!("no parent dir for {}", path.display()))?; + fs::create_dir_all(dir).with_context(|| format!("create dir {}", dir.display()))?; + let mut tmp = tempfile::NamedTempFile::new_in(dir) + .with_context(|| format!("create temp file in {}", dir.display()))?; + tmp.write_all(data).context("write temp file")?; + tmp.flush().context("flush temp file")?; + tmp.as_file_mut().sync_all().context("sync temp file")?; + tmp.persist(path) + .map_err(|e| anyhow!("persist temp file: {}", e))?; + Ok(()) +} + +pub(super) fn add_entry_sync(path: &Path, entry: &EntryBlock) -> Result { + let (preamble, mut entries) = read_entries(path)?; + let block = entry.block_string(); + if entries.iter().any(|e| e.block_string() == block) { + return Ok(AddOutcome::Duplicate); + } + entries.insert(0, entry.clone()); + write_entries(path, &preamble, &entries)?; + Ok(AddOutcome::Added) +} + +pub(super) fn add_resource_entry_sync(path: &Path, entry_block: &str) -> Result { + let existing = if path.exists() { + fs::read_to_string(path).with_context(|| format!("read file {}", path.display()))? + } else { + String::new() + }; + let normalized = normalize_line_endings(&existing); + let (_, entries) = parse_entries(&normalized); + if entries.iter().any(|e| e.block_string() == entry_block) { + return Ok(AddOutcome::Duplicate); + } + + let mut preserved = normalized; + if !preserved.is_empty() && !preserved.ends_with('\n') { + preserved.push('\n'); + } + + let mut content = String::new(); + content.push_str(entry_block); + content.push('\n'); + content.push_str(&preserved); + if !content.ends_with('\n') { + content.push('\n'); + } + atomic_write(path, content.as_bytes())?; + Ok(AddOutcome::Added) +} + +pub(super) fn delete_entry_sync(path: &Path, entry_block: &str) -> Result { + let (preamble, mut entries) = read_entries(path)?; + let pos = entries.iter().position(|e| e.block_string() == entry_block); + let Some(pos) = pos else { + return Ok(ModifyOutcome::NotFound); + }; + entries.remove(pos); + write_entries(path, &preamble, &entries)?; + Ok(ModifyOutcome::Applied) +} + +pub(super) fn update_entry_sync( + path: &Path, + entry_block: &str, + updated_entry: &EntryBlock, +) -> Result { + let (preamble, mut entries) = read_entries(path)?; + let pos = entries.iter().position(|e| e.block_string() == entry_block); + let Some(pos) = pos else { + return Ok(ModifyOutcome::NotFound); + }; + entries[pos] = updated_entry.clone(); + write_entries(path, &preamble, &entries)?; + Ok(ModifyOutcome::Applied) +} + +pub(super) fn move_to_finished_sync( + read_later: &Path, + finished: &Path, + entry_block: &str, +) -> Result { + let (preamble_rl, mut entries_rl) = read_entries(read_later)?; + let pos = entries_rl + .iter() + .position(|e| e.block_string() == entry_block); + let Some(pos) = pos else { + return Ok(ModifyOutcome::NotFound); + }; + let entry = entries_rl.remove(pos); + + let (preamble_fin, mut entries_fin) = read_entries(finished)?; + entries_fin.insert(0, entry); + write_entries(finished, &preamble_fin, &entries_fin)?; + write_entries(read_later, &preamble_rl, &entries_rl)?; + Ok(ModifyOutcome::Applied) +} + +pub(super) fn move_to_finished_updated_sync( + read_later: &Path, + finished: &Path, + entry_block: &str, + updated_entry: &str, +) -> Result { + let (preamble_rl, mut entries_rl) = read_entries(read_later)?; + let pos = entries_rl + .iter() + .position(|e| e.block_string() == entry_block); + let Some(pos) = pos else { + return Ok(ModifyOutcome::NotFound); + }; + entries_rl.remove(pos); + + let (preamble_fin, mut entries_fin) = read_entries(finished)?; + let updated_entry = EntryBlock::from_block(updated_entry); + entries_fin.insert(0, updated_entry); + write_entries(finished, &preamble_fin, &entries_fin)?; + write_entries(read_later, &preamble_rl, &entries_rl)?; + Ok(ModifyOutcome::Applied) +} + +pub(super) fn move_to_read_later_sync( + read_later: &Path, + finished: &Path, + entry_block: &str, +) -> Result { + let (preamble_fin, mut entries_fin) = read_entries(finished)?; + let pos = entries_fin + .iter() + .position(|e| e.block_string() == entry_block); + let Some(pos) = pos else { + return Ok(ModifyOutcome::NotFound); + }; + let entry = entries_fin.remove(pos); + + let (preamble_rl, mut entries_rl) = read_entries(read_later)?; + entries_rl.insert(0, entry); + write_entries(read_later, &preamble_rl, &entries_rl)?; + write_entries(finished, &preamble_fin, &entries_fin)?; + Ok(ModifyOutcome::Applied) +} + +pub(super) fn load_queue(path: &Path) -> Result> { + if !path.exists() { + return Ok(Vec::new()); + } + let data = + fs::read_to_string(path).with_context(|| format!("read queue {}", path.display()))?; + let queue = serde_json::from_str(&data).context("parse queue")?; + Ok(queue) +} + +pub(super) fn save_queue(path: &Path, queue: &[QueuedOp]) -> Result<()> { + let data = serde_json::to_vec_pretty(queue).context("serialize queue")?; + atomic_write(path, &data) +} + +pub(super) fn load_undo(path: &Path) -> Result> { + if !path.exists() { + return Ok(Vec::new()); + } + let data = fs::read_to_string(path).with_context(|| format!("read undo {}", path.display()))?; + let undo = serde_json::from_str(&data).context("parse undo")?; + Ok(undo) +} + +pub(super) fn save_undo(path: &Path, undo: &[UndoRecord]) -> Result<()> { + let data = serde_json::to_vec_pretty(undo).context("serialize undo")?; + atomic_write(path, &data) +} + +pub(super) fn prune_undo(undo: &mut Vec) { + let now = now_ts(); + undo.retain(|r| r.expires_at > now); +} + +pub(super) fn normalize_line_endings(input: &str) -> String { + input.replace("\r\n", "\n").replace('\r', "\n") +} + +pub(super) fn resource_block_from_text(text: &str) -> String { + let normalized = normalize_line_endings(text); + let mut lines: Vec = normalized.lines().map(|s| s.to_string()).collect(); + if lines.is_empty() { + lines.push(String::new()); + } + if let Some(first) = lines.get_mut(0) { + *first = format!("- (Auto-Resource): {}", first); + } + lines.join("\n") +} + +pub(super) fn sanitize_resource_filename(input: &str) -> Result { + let trimmed = input.trim(); + let first_line = trimmed.lines().next().unwrap_or("").trim(); + if first_line.is_empty() { + return Err(anyhow!("Provide a filename.")); + } + if first_line == "." || first_line == ".." { + return Err(anyhow!("Invalid filename.")); + } + if first_line.contains('/') || first_line.contains('\\') { + return Err(anyhow!("Invalid filename.")); + } + let mut name = first_line.to_string(); + if !name.to_lowercase().ends_with(".md") { + name.push_str(".md"); + } + Ok(name) +} + +pub(super) fn sanitize_filename_with_default(input: &str, default_ext: Option<&str>) -> String { + let mut sanitized: String = input + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') { + c + } else { + '_' + } + }) + .collect(); + if sanitized.is_empty() { + sanitized = "file".to_string(); + } + if Path::new(&sanitized).extension().is_none() { + if let Some(ext) = default_ext { + sanitized.push('.'); + sanitized.push_str(ext); + } + } + sanitized +} + +pub(super) fn extension_from_mime(mime: &str) -> Option<&str> { + let (_, subtype) = mime.split_once('/')?; + if subtype.eq_ignore_ascii_case("jpeg") { + Some("jpg") + } else { + Some(subtype) + } +} + +pub(super) fn build_media_entry_text(filename: &str, caption: Option<&str>) -> String { + let mut text = format!("![[{}]]", filename); + if let Some(caption) = caption { + let normalized = normalize_line_endings(caption).trim().to_string(); + if !normalized.is_empty() { + text.push('\n'); + text.push_str(&normalized); + } + } + text +} + +pub(super) fn format_embedded_references_for_lines(lines: &[String], config: &Config) -> Vec { + let mut labels: HashMap = HashMap::new(); + let mut next_label = 1usize; + let mut output = Vec::with_capacity(lines.len()); + + for line in lines { + let mut formatted = String::with_capacity(line.len()); + let mut index = 0; + while let Some(start_rel) = line[index..].find("![[") { + let marker_start = index + start_rel; + formatted.push_str(&line[index..marker_start]); + + let marker_content_start = marker_start + 3; + let Some(end_rel) = line[marker_content_start..].find("]]") else { + formatted.push_str(&line[marker_start..]); + index = line.len(); + break; + }; + let marker_content_end = marker_content_start + end_rel; + let marker_end = marker_content_end + 2; + let marker_inner = &line[marker_content_start..marker_content_end]; + + if let Some(path) = resolve_embedded_path(marker_inner, config) { + let label = match labels.get(&path) { + Some(label) => *label, + None => { + let assigned = next_label; + labels.insert(path.clone(), assigned); + next_label += 1; + assigned + } + }; + if is_image_path(&path) { + formatted.push_str(&format!("image #{}", label)); + } else if is_video_path(&path) { + formatted.push_str(&format!("video #{}", label)); + } else { + formatted.push_str(&format!("file #{}", label)); + } + } else { + formatted.push_str(&line[marker_start..marker_end]); + } + + index = marker_end; + } + formatted.push_str(&line[index..]); + output.push(formatted); + } + + output +} + +pub(super) fn pick_best_photo(photos: &[teloxide::types::PhotoSize]) -> Option<&teloxide::types::PhotoSize> { + photos + .iter() + .max_by_key(|photo| photo.file.size.max((photo.width * photo.height) as u32) as u64) +} + +pub(super) async fn download_telegram_file(bot: &Bot, file_id: &str, dest_path: &Path) -> Result<()> { + let file = bot.get_file(file_id).await?; + let mut out = tokio::fs::File::create(dest_path).await?; + bot.download_file(&file.path, &mut out).await?; + Ok(()) +} + +pub(super) fn extract_embedded_paths(lines: &[String], config: &Config) -> Vec { + let mut paths = Vec::new(); + let mut seen = HashSet::new(); + for line in lines { + let mut index = 0; + while let Some(start_rel) = line[index..].find("![[") { + let start = index + start_rel + 3; + let Some(end_rel) = line[start..].find("]]") else { + break; + }; + let end = start + end_rel; + let inner = &line[start..end]; + if let Some(path) = resolve_embedded_path(inner, config) { + if seen.insert(path.clone()) { + paths.push(path); + } + } + index = end + 2; + } + } + paths +} + +pub(super) fn resolve_embedded_path(inner: &str, config: &Config) -> Option { + let mut inner = inner.trim(); + if let Some((path_part, _)) = inner.split_once('|') { + inner = path_part.trim(); + } + if inner.is_empty() { + return None; + } + + let vault_root = config + .read_later_path + .parent() + .unwrap_or_else(|| Path::new(".")); + let path = if Path::new(inner).is_absolute() { + PathBuf::from(inner) + } else if inner.contains('/') || inner.contains('\\') { + vault_root.join(inner) + } else { + config.media_dir.join(inner) + }; + + if path.exists() { + Some(path) + } else { + None + } +} + +pub(super) fn is_image_path(path: &Path) -> bool { + match path.extension().and_then(|ext| ext.to_str()) { + Some(ext) => matches!( + ext.to_ascii_lowercase().as_str(), + "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" + ), + None => false, + } +} + +pub(super) fn is_video_path(path: &Path) -> bool { + match path.extension().and_then(|ext| ext.to_str()) { + Some(ext) => matches!( + ext.to_ascii_lowercase().as_str(), + "mp4" | "mov" | "mkv" | "webm" | "avi" | "m4v" + ), + None => false, + } +} + +pub(super) fn parse_command(text: &str) -> Option<&str> { + let first = text.split_whitespace().next()?; + if !first.starts_with('/') { + return None; + } + let cmd = first.trim_start_matches('/'); + Some(cmd.split('@').next().unwrap_or(cmd)) +} + +pub(super) fn quick_select_index(entries_len: usize, mode: QuickSelectMode) -> Option { + if entries_len == 0 { + return None; + } + match mode { + QuickSelectMode::Top => Some(0), + QuickSelectMode::Last => Some(entries_len - 1), + QuickSelectMode::Random => { + let mut indices: Vec = (0..entries_len).collect(); + let mut rng = rand::thread_rng(); + indices.shuffle(&mut rng); + indices.first().copied() + } + } +} + +pub(super) fn short_id() -> String { + let id = Uuid::new_v4().to_string(); + id.split('-').next().unwrap_or(&id).to_string() +} + +pub(super) fn now_ts() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_else(|_| Duration::from_secs(0)) + .as_secs() +} + +pub(super) fn chat_id_from_user_id(user_id: u64) -> ChatId { + ChatId(user_id as i64) +} + +pub(super) fn start_retry_loop(state: std::sync::Arc, interval_secs: u64) { + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(interval_secs)); + loop { + interval.tick().await; + if let Err(err) = process_queue(state.clone()).await { + error!("queue processing failed: {:#}", err); + } + } + }); +} + +pub(super) async fn process_queue(state: std::sync::Arc) -> Result<()> { + let pending = { + let mut queue = state.queue.lock().await; + std::mem::take(&mut *queue) + }; + + if pending.is_empty() { + return Ok(()); + } + + let mut remaining = Vec::new(); + for op in pending { + match apply_op(&state, &op).await { + Ok(_) => {} + Err(err) => { + error!("queued op failed: {:#}", err); + remaining.push(op); + } + } + } + + let mut queue = state.queue.lock().await; + if !queue.is_empty() { + remaining.extend(queue.drain(..)); + } + *queue = remaining; + save_queue(&state.queue_path, &queue) +} diff --git a/src/integrations.rs b/src/integrations.rs new file mode 100644 index 0000000..a68cd76 --- /dev/null +++ b/src/integrations.rs @@ -0,0 +1,922 @@ +use super::*; + +pub(super) fn run_push(sync: &SyncConfig) -> Result { + ensure_git_available()?; + if !sync.repo_path.exists() { + return Err(anyhow!( + "Sync repo path not found: {}", + sync.repo_path.display() + )); + } + + let repo_check = run_git( + &sync.repo_path, + &["rev-parse", "--is-inside-work-tree"], + Vec::new(), + )?; + if !repo_check.status.success() || repo_check.stdout.trim() != "true" { + return Err(anyhow!( + "Sync repo path not found or not a git repository: {}", + sync.repo_path.display() + )); + } + + let token = read_token_file(&sync.token_file)?; + + let remotes = git_remote_names(&sync.repo_path)?; + let remote = if remotes.iter().any(|name| name == "origin") { + "origin".to_string() + } else { + remotes + .first() + .cloned() + .ok_or_else(|| anyhow!("Git remote not configured."))? + }; + let remote_url = git_remote_url(&sync.repo_path, &remote)?; + if !remote_url.starts_with("https://") { + return Err(anyhow!( + "Sync requires HTTPS remote for PAT auth. Remote is {}", + remote_url + )); + } + + let username = + extract_https_username(&remote_url).unwrap_or_else(|| "x-access-token".to_string()); + + let status_output = run_git(&sync.repo_path, &["status", "--porcelain"], Vec::new())?; + if !status_output.status.success() { + return Err(anyhow!(format_git_error("git status", &status_output))); + } + if status_output.stdout.trim().is_empty() { + return Ok(PushOutcome::NoChanges); + } + + let add_output = run_git(&sync.repo_path, &["add", "-A"], Vec::new())?; + if !add_output.status.success() { + return Err(anyhow!(format_git_error("git add", &add_output))); + } + + let commit_message = sync_commit_message(); + let commit_output = run_git( + &sync.repo_path, + &["commit", "-m", &commit_message], + Vec::new(), + )?; + if !commit_output.status.success() { + if is_nothing_to_commit(&commit_output) { + return Ok(PushOutcome::NoChanges); + } + return Err(anyhow!(format_git_error("git commit", &commit_output))); + } + + let branch = git_current_branch(&sync.repo_path)?; + if branch == "HEAD" { + return Err(anyhow!("Sync failed: detached HEAD.")); + } + + let askpass = create_askpass_script()?; + let askpass_path = askpass.to_string_lossy().to_string(); + let push_env = vec![ + ("GIT_TERMINAL_PROMPT", "0".to_string()), + ("GIT_ASKPASS", askpass_path), + ("GIT_SYNC_USERNAME", username), + ("GIT_SYNC_PAT", token), + ]; + let push_output = run_git( + &sync.repo_path, + &["push", &remote, &format!("HEAD:refs/heads/{}", branch)], + push_env, + )?; + if !push_output.status.success() { + return Err(anyhow!(format_git_error("git push", &push_output))); + } + + Ok(PushOutcome::Pushed) +} + +pub(super) fn run_pull(sync: &SyncConfig, mode: PullMode) -> Result { + ensure_git_available()?; + if !sync.repo_path.exists() { + return Err(anyhow!( + "Sync repo path not found: {}", + sync.repo_path.display() + )); + } + + let repo_check = run_git( + &sync.repo_path, + &["rev-parse", "--is-inside-work-tree"], + Vec::new(), + )?; + if !repo_check.status.success() || repo_check.stdout.trim() != "true" { + return Err(anyhow!( + "Sync repo path not found or not a git repository: {}", + sync.repo_path.display() + )); + } + + let token = read_token_file(&sync.token_file)?; + + let remotes = git_remote_names(&sync.repo_path)?; + let remote = if remotes.iter().any(|name| name == "origin") { + "origin".to_string() + } else { + remotes + .first() + .cloned() + .ok_or_else(|| anyhow!("Git remote not configured."))? + }; + let remote_url = git_remote_url(&sync.repo_path, &remote)?; + if !remote_url.starts_with("https://") { + return Err(anyhow!( + "Sync requires HTTPS remote for PAT auth. Remote is {}", + remote_url + )); + } + + let username = + extract_https_username(&remote_url).unwrap_or_else(|| "x-access-token".to_string()); + + let status_output = run_git(&sync.repo_path, &["status", "--porcelain"], Vec::new())?; + if !status_output.status.success() { + return Err(anyhow!(format_git_error("git status", &status_output))); + } + if !status_output.stdout.trim().is_empty() { + return Err(anyhow!( + "Working tree has uncommitted changes; commit or stash before pull." + )); + } + + let branch = git_current_branch(&sync.repo_path)?; + if branch == "HEAD" { + return Err(anyhow!("Sync failed: detached HEAD.")); + } + + let askpass = create_askpass_script()?; + let askpass_path = askpass.to_string_lossy().to_string(); + let pull_env = vec![ + ("GIT_TERMINAL_PROMPT", "0".to_string()), + ("GIT_ASKPASS", askpass_path), + ("GIT_SYNC_USERNAME", username), + ("GIT_SYNC_PAT", token), + ]; + + let pull_args: Vec = match mode { + PullMode::FastForward => vec!["pull".to_string(), "--ff-only".to_string(), remote, branch], + PullMode::Theirs => vec![ + "pull".to_string(), + "--no-edit".to_string(), + "-X".to_string(), + "theirs".to_string(), + remote, + branch, + ], + }; + let pull_args_ref: Vec<&str> = pull_args.iter().map(|arg| arg.as_str()).collect(); + let pull_output = run_git(&sync.repo_path, &pull_args_ref, pull_env)?; + if !pull_output.status.success() { + return Err(anyhow!(format_git_error("git pull", &pull_output))); + } + + if is_already_up_to_date(&pull_output) { + Ok(PullOutcome::UpToDate) + } else { + Ok(PullOutcome::Pulled) + } +} + +pub(super) fn run_sync(sync: &SyncConfig) -> Result { + ensure_git_available()?; + if !sync.repo_path.exists() { + return Err(anyhow!( + "Sync repo path not found: {}", + sync.repo_path.display() + )); + } + + let repo_check = run_git( + &sync.repo_path, + &["rev-parse", "--is-inside-work-tree"], + Vec::new(), + )?; + if !repo_check.status.success() || repo_check.stdout.trim() != "true" { + return Err(anyhow!( + "Sync repo path not found or not a git repository: {}", + sync.repo_path.display() + )); + } + + let token = read_token_file(&sync.token_file)?; + + let remotes = git_remote_names(&sync.repo_path)?; + let remote = if remotes.iter().any(|name| name == "origin") { + "origin".to_string() + } else { + remotes + .first() + .cloned() + .ok_or_else(|| anyhow!("Git remote not configured."))? + }; + let remote_url = git_remote_url(&sync.repo_path, &remote)?; + if !remote_url.starts_with("https://") { + return Err(anyhow!( + "Sync requires HTTPS remote for PAT auth. Remote is {}", + remote_url + )); + } + + let username = + extract_https_username(&remote_url).unwrap_or_else(|| "x-access-token".to_string()); + + let status_output = run_git(&sync.repo_path, &["status", "--porcelain"], Vec::new())?; + if !status_output.status.success() { + return Err(anyhow!(format_git_error("git status", &status_output))); + } + + let add_output = run_git(&sync.repo_path, &["add", "-A"], Vec::new())?; + if !add_output.status.success() { + return Err(anyhow!(format_git_error("git add", &add_output))); + } + + let commit_message = sync_commit_message(); + let commit_output = run_git( + &sync.repo_path, + &["commit", "-m", &commit_message], + Vec::new(), + )?; + let did_commit = if commit_output.status.success() { + true + } else if is_nothing_to_commit(&commit_output) { + false + } else { + return Err(anyhow!(format_git_error("git commit", &commit_output))); + }; + + let branch = git_current_branch(&sync.repo_path)?; + if branch == "HEAD" { + return Err(anyhow!("Sync failed: detached HEAD.")); + } + + let askpass = create_askpass_script()?; + let askpass_path = askpass.to_string_lossy().to_string(); + let auth_env = vec![ + ("GIT_TERMINAL_PROMPT", "0".to_string()), + ("GIT_ASKPASS", askpass_path), + ("GIT_SYNC_USERNAME", username), + ("GIT_SYNC_PAT", token), + ]; + + let pull_output = run_git( + &sync.repo_path, + &["pull", "--ff-only", &remote, &branch], + auth_env.clone(), + )?; + if !pull_output.status.success() { + return Err(anyhow!(format_git_error("git pull", &pull_output))); + } + let did_pull = !is_already_up_to_date(&pull_output); + + let push_output = run_git( + &sync.repo_path, + &["push", &remote, &format!("HEAD:refs/heads/{}", branch)], + auth_env, + )?; + if !push_output.status.success() { + return Err(anyhow!(format_git_error("git push", &push_output))); + } + let did_push = !is_push_up_to_date(&push_output); + + if did_commit || did_pull || did_push { + Ok(SyncOutcome::Synced) + } else { + Ok(SyncOutcome::NoChanges) + } +} + +pub(super) fn run_sync_x(config: &Config, cookie_header: &str) -> Result { + let sync_x = config + .sync_x + .as_ref() + .ok_or_else(|| anyhow!("sync_x is not configured."))?; + + let source_project = &sync_x.source_project_path; + if !source_project.exists() { + return Err(anyhow!( + "sync_x source project path not found: {}", + source_project.display() + )); + } + if !source_project.is_dir() { + return Err(anyhow!( + "sync_x source project path is not a directory: {}", + source_project.display() + )); + } + + let work_dir = sync_x + .work_dir + .clone() + .unwrap_or_else(|| config.data_dir.join("sync-x")); + prepare_sync_x_workspace(source_project, &work_dir)?; + + let python_bin = resolve_sync_x_python_bin(sync_x); + let creds_path = work_dir.join("creds.txt"); + let bookmarks_path = work_dir.join("bookmarks.txt"); + let _ = fs::remove_file(&creds_path); + let _ = fs::remove_file(&bookmarks_path); + + run_python_script( + &python_bin, + &work_dir, + "isolate_cookies.py", + &[], + Some(cookie_header), + )?; + run_python_script(&python_bin, &work_dir, "main.py", &["--mode", "a"], None)?; + + let urls = if bookmarks_path.exists() { + read_sync_x_urls(&bookmarks_path)? + } else { + Vec::new() + }; + let (added_count, duplicate_count) = + prepend_urls_to_read_later_sync(&config.read_later_path, &urls)?; + + let _ = fs::remove_file(&bookmarks_path); + let _ = fs::remove_file(&creds_path); + + Ok(SyncXOutcome { + extracted_count: urls.len(), + added_count, + duplicate_count, + }) +} + +pub(super) fn resolve_sync_x_python_bin(sync_x: &SyncXConfig) -> PathBuf { + if let Some(path) = &sync_x.python_bin { + return path.clone(); + } + let venv_python3 = sync_x.source_project_path.join(".venv/bin/python3"); + if venv_python3.exists() { + return venv_python3; + } + let venv_python = sync_x.source_project_path.join(".venv/bin/python"); + if venv_python.exists() { + return venv_python; + } + PathBuf::from("python3") +} + +pub(super) fn prepare_sync_x_workspace(source_project: &Path, work_dir: &Path) -> Result<()> { + fs::create_dir_all(work_dir) + .with_context(|| format!("create sync_x work dir {}", work_dir.display()))?; + + for file in [ + "main.py", + "isolate_cookies.py", + "requirements.txt", + "README.md", + "LICENSE", + ] { + let src = source_project.join(file); + let dest = work_dir.join(file); + if !src.exists() { + if matches!(file, "main.py" | "isolate_cookies.py") { + return Err(anyhow!( + "sync_x source is missing required file: {}", + src.display() + )); + } + continue; + } + fs::copy(&src, &dest) + .with_context(|| format!("copy {} to {}", src.display(), dest.display()))?; + } + + Ok(()) +} + +pub(super) fn run_python_script( + python_bin: &Path, + work_dir: &Path, + script: &str, + args: &[&str], + stdin_input: Option<&str>, +) -> Result<()> { + let mut cmd = Command::new(python_bin); + cmd.current_dir(work_dir) + .arg(script) + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + if stdin_input.is_some() { + cmd.stdin(Stdio::piped()); + } + + let mut child = cmd + .spawn() + .with_context(|| format!("run {} {}", python_bin.display(), script))?; + if let Some(input) = stdin_input { + if let Some(mut stdin) = child.stdin.take() { + stdin + .write_all(input.as_bytes()) + .context("write stdin to python script")?; + if !input.ends_with('\n') { + stdin + .write_all(b"\n") + .context("write newline to python script")?; + } + } + } + + let output = child.wait_with_output().context("wait for python script")?; + if !output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let tail = summarize_process_output(&stdout, &stderr); + return Err(anyhow!( + "{} {} failed (status {}):\n{}", + python_bin.display(), + script, + output.status, + tail + )); + } + Ok(()) +} + +pub(super) fn summarize_process_output(stdout: &str, stderr: &str) -> String { + let stderr_trimmed = stderr.trim(); + if !stderr_trimmed.is_empty() { + return trim_tail(stderr_trimmed, 1200); + } + let stdout_trimmed = stdout.trim(); + if !stdout_trimmed.is_empty() { + return trim_tail(stdout_trimmed, 1200); + } + "No output captured.".to_string() +} + +pub(super) fn trim_tail(text: &str, max_chars: usize) -> String { + if text.len() <= max_chars { + return text.to_string(); + } + let mut cutoff = 0usize; + for (idx, _) in text.char_indices() { + if idx >= text.len().saturating_sub(max_chars) { + cutoff = idx; + break; + } + } + format!("...{}", &text[cutoff..]) +} + +pub(super) fn read_sync_x_urls(path: &Path) -> Result> { + let contents = fs::read_to_string(path) + .with_context(|| format!("read bookmarks file {}", path.display()))?; + let mut seen = HashSet::new(); + let mut urls = Vec::new(); + for line in contents.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if !(trimmed.starts_with("http://") || trimmed.starts_with("https://")) { + continue; + } + if seen.insert(trimmed.to_string()) { + urls.push(trimmed.to_string()); + } + } + Ok(urls) +} + +pub(super) fn prepend_urls_to_read_later_sync(path: &Path, urls: &[String]) -> Result<(usize, usize)> { + let (preamble, mut entries) = read_entries(path)?; + let mut existing = HashSet::new(); + for entry in &entries { + existing.insert(entry.block_string()); + } + + let mut new_entries = Vec::new(); + let mut duplicate_count = 0usize; + for url in urls { + let entry = EntryBlock::from_text(url); + let block = entry.block_string(); + if existing.insert(block) { + new_entries.push(entry); + } else { + duplicate_count += 1; + } + } + + if !new_entries.is_empty() { + for entry in new_entries.iter().rev() { + entries.insert(0, entry.clone()); + } + write_entries(path, &preamble, &entries)?; + } + + Ok((new_entries.len(), duplicate_count)) +} + +pub(super) struct GitOutput { + pub(super) status: std::process::ExitStatus, + pub(super) stdout: String, + pub(super) stderr: String, +} + +pub(super) fn run_git(repo_path: &Path, args: &[&str], envs: Vec<(&str, String)>) -> Result { + let mut cmd = Command::new("git"); + cmd.current_dir(repo_path).args(args); + for (key, value) in envs { + cmd.env(key, value); + } + let output = cmd + .output() + .with_context(|| format!("run git command: git {}", args.join(" ")))?; + Ok(GitOutput { + status: output.status, + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + }) +} + +pub(super) fn ensure_git_available() -> Result<()> { + match Command::new("git").arg("--version").output() { + Ok(output) => { + if output.status.success() { + Ok(()) + } else { + Err(anyhow!("Git unavailable: git --version failed.")) + } + } + Err(_) => Err(anyhow!( + "Git is not available in PATH. Add git to the service path." + )), + } +} + +pub(super) fn format_git_error(action: &str, output: &GitOutput) -> String { + let mut message = format!("{} failed.", action); + let stdout = output.stdout.trim(); + let stderr = output.stderr.trim(); + if !stdout.is_empty() { + message.push_str("\nstdout:\n"); + message.push_str(stdout); + } + if !stderr.is_empty() { + message.push_str("\nstderr:\n"); + message.push_str(stderr); + } + message +} + +pub(super) fn git_remote_names(repo_path: &Path) -> Result> { + let output = run_git(repo_path, &["remote"], Vec::new())?; + if !output.status.success() { + return Err(anyhow!(format_git_error("git remote", &output))); + } + let names = output + .stdout + .lines() + .map(|line| line.trim().to_string()) + .filter(|line| !line.is_empty()) + .collect::>(); + Ok(names) +} + +pub(super) fn git_remote_url(repo_path: &Path, remote: &str) -> Result { + let output = run_git(repo_path, &["remote", "get-url", remote], Vec::new())?; + if !output.status.success() { + return Err(anyhow!(format_git_error("git remote get-url", &output))); + } + Ok(output.stdout.trim().to_string()) +} + +pub(super) fn git_current_branch(repo_path: &Path) -> Result { + let output = run_git( + repo_path, + &["rev-parse", "--abbrev-ref", "HEAD"], + Vec::new(), + )?; + if !output.status.success() { + return Err(anyhow!(format_git_error("git rev-parse", &output))); + } + Ok(output.stdout.trim().to_string()) +} + +pub(super) fn read_token_file(path: &Path) -> Result { + let token = match fs::read_to_string(path) { + Ok(token) => token, + Err(_) => { + return Err(anyhow!("Sync requires PAT in settings.sync.token_file.")); + } + }; + let token = token.trim().to_string(); + if token.is_empty() { + return Err(anyhow!("Sync requires PAT in settings.sync.token_file.")); + } + Ok(token) +} + +pub(super) fn extract_https_username(remote_url: &str) -> Option { + if !remote_url.starts_with("https://") { + return None; + } + let without_scheme = &remote_url["https://".len()..]; + let slash_pos = without_scheme.find('/').unwrap_or(without_scheme.len()); + let authority = &without_scheme[..slash_pos]; + let userinfo = authority.split('@').next()?; + if !authority.contains('@') { + return None; + } + let username = userinfo.split(':').next().unwrap_or(""); + if username.is_empty() { + None + } else { + Some(username.to_string()) + } +} + +pub(super) fn is_nothing_to_commit(output: &GitOutput) -> bool { + let combined = format!("{}\n{}", output.stdout, output.stderr).to_lowercase(); + combined.contains("nothing to commit") + || combined.contains("no changes added to commit") + || combined.contains("working tree clean") +} + +pub(super) fn is_already_up_to_date(output: &GitOutput) -> bool { + let combined = format!("{}\n{}", output.stdout, output.stderr).to_lowercase(); + combined.contains("already up to date") || combined.contains("already up-to-date") +} + +pub(super) fn is_push_up_to_date(output: &GitOutput) -> bool { + let combined = format!("{}\n{}", output.stdout, output.stderr).to_lowercase(); + combined.contains("everything up-to-date") || combined.contains("everything up to date") +} + +pub(super) fn parse_pull_mode(rest: &str) -> std::result::Result { + let option = rest.trim(); + if option.is_empty() { + return Ok(PullMode::FastForward); + } + if option.eq_ignore_ascii_case("theirs") { + return Ok(PullMode::Theirs); + } + Err("Unknown pull option. Use /pull or /pull theirs.".to_string()) +} + +pub(super) fn sync_commit_message() -> String { + format!("Bot sync {}", Local::now().format("%Y-%m-%d %H:%M:%S")) +} + +pub(super) fn create_askpass_script() -> Result { + let mut file = NamedTempFile::new().context("create askpass script")?; + file.write_all( + b"#!/bin/sh\ncase \"$1\" in\n*Username*) echo \"$GIT_SYNC_USERNAME\" ;;\n*Password*) echo \"$GIT_SYNC_PAT\" ;;\n*) echo \"\" ;;\nesac\n", + ) + .context("write askpass script")?; + let mut perms = file.as_file().metadata()?.permissions(); + perms.set_mode(0o700); + fs::set_permissions(file.path(), perms).context("chmod askpass script")?; + Ok(file.into_temp_path()) +} + +pub(super) fn split_items(text: &str) -> Vec { + text.split("---") + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect() +} + +pub(super) async fn download_and_send_link( + bot: &Bot, + chat_id: ChatId, + link: &str, + format_selector: &str, +) -> Result<()> { + let temp_dir = TempDir::new().context("create download temp dir")?; + let target_dir = temp_dir.path().to_path_buf(); + let link = link.to_string(); + let format_selector = format_selector.to_string(); + let path = tokio::task::spawn_blocking(move || { + run_ytdlp_download(&target_dir, &link, &format_selector) + }) + .await + .context("yt-dlp task failed")??; + bot.send_document(chat_id, InputFile::file(path)).await?; + Ok(()) +} + +pub(super) async fn download_and_save_link( + state: &std::sync::Arc, + link: &str, + format_selector: &str, +) -> Result { + let target_dir = state.config.media_dir.clone(); + fs::create_dir_all(&target_dir) + .with_context(|| format!("create media dir {}", target_dir.display()))?; + let link = link.to_string(); + let format_selector = format_selector.to_string(); + let path = tokio::task::spawn_blocking(move || { + run_ytdlp_download(&target_dir, &link, &format_selector) + }) + .await + .context("yt-dlp task failed")??; + if !path.exists() { + return Err(anyhow!("Download completed but file is missing.")); + } + Ok(path) +} + +pub(super) fn run_ytdlp_list_formats(link: &str) -> Result> { + let output = Command::new("yt-dlp") + .arg("--no-playlist") + .arg("-J") + .arg(link) + .output() + .context("run yt-dlp")?; + if !output.status.success() { + return Err(anyhow!(format_ytdlp_error(&output))); + } + let value: serde_json::Value = + serde_json::from_slice(&output.stdout).context("parse yt-dlp json")?; + let mut options = vec![DownloadQualityOption { + label: "Best".to_string(), + format_selector: "bestvideo+bestaudio/best".to_string(), + }]; + + let Some(formats) = value.get("formats").and_then(|v| v.as_array()) else { + return Ok(options); + }; + + let mut by_height: HashMap, bool)> = HashMap::new(); + let mut best_audio: Option<(String, String, Option, Option)> = None; + + for format in formats { + let Some(format_id) = format.get("format_id").and_then(|v| v.as_str()) else { + continue; + }; + let vcodec = format + .get("vcodec") + .and_then(|v| v.as_str()) + .unwrap_or("none"); + let acodec = format + .get("acodec") + .and_then(|v| v.as_str()) + .unwrap_or("none"); + let ext = format + .get("ext") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + let filesize = format + .get("filesize") + .and_then(|v| v.as_u64()) + .or_else(|| format.get("filesize_approx").and_then(|v| v.as_u64())); + + if vcodec == "none" && acodec != "none" { + let abr = format.get("abr").and_then(|v| v.as_f64()); + match &best_audio { + Some((_, _, existing_size, existing_abr)) => { + let better_abr = abr.unwrap_or(0.0) > existing_abr.unwrap_or(0.0); + let better_size = filesize.unwrap_or(0) > existing_size.unwrap_or(0); + if better_abr || better_size { + best_audio = Some((format_id.to_string(), ext, filesize, abr)); + } + } + None => { + best_audio = Some((format_id.to_string(), ext, filesize, abr)); + } + } + continue; + } + + if vcodec == "none" { + continue; + } + + let Some(height) = format.get("height").and_then(|v| v.as_i64()) else { + continue; + }; + if height <= 0 { + continue; + } + + let has_audio = acodec != "none"; + let selector = if has_audio { + format_id.to_string() + } else { + format!("{}+bestaudio/best", format_id) + }; + let candidate = (selector, ext, filesize, has_audio); + match by_height.get(&height) { + Some((_, _, existing_size, existing_has_audio)) => { + let better_audio = has_audio && !existing_has_audio; + let better_size = filesize.unwrap_or(0) > existing_size.unwrap_or(0); + if better_audio || better_size { + by_height.insert(height, candidate); + } + } + None => { + by_height.insert(height, candidate); + } + } + } + + let mut heights: Vec = by_height.keys().copied().collect(); + heights.sort_by(|a, b| b.cmp(a)); + for height in heights.into_iter().take(6) { + if let Some((selector, ext, size, has_audio)) = by_height.get(&height) { + let mut label = format!("{}p {}", height, ext); + if !has_audio { + label.push_str(" (video-only source)"); + } + if let Some(size) = size { + label.push_str(&format!(" ({})", human_size(*size))); + } + options.push(DownloadQualityOption { + label, + format_selector: selector.clone(), + }); + } + } + + if let Some((format_id, ext, size, _abr)) = best_audio { + let mut label = format!("Audio only ({})", ext); + if let Some(size) = size { + label.push_str(&format!(" ({})", human_size(size))); + } + options.push(DownloadQualityOption { + label, + format_selector: format_id, + }); + } + + Ok(options) +} + +pub(super) fn human_size(bytes: u64) -> String { + const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"]; + let mut value = bytes as f64; + let mut unit = 0usize; + while value >= 1024.0 && unit < UNITS.len() - 1 { + value /= 1024.0; + unit += 1; + } + if unit == 0 { + format!("{} {}", bytes, UNITS[unit]) + } else { + format!("{:.1} {}", value, UNITS[unit]) + } +} + +pub(super) fn run_ytdlp_download(target_dir: &Path, link: &str, format_selector: &str) -> Result { + let template = target_dir.join("%(title).200B-%(id)s.%(ext)s"); + let output = Command::new("yt-dlp") + .arg("--no-playlist") + .arg("-f") + .arg(format_selector) + .arg("--print") + .arg("after_move:filepath") + .arg("-o") + .arg(template.to_string_lossy().to_string()) + .arg(link) + .output() + .context("run yt-dlp")?; + if !output.status.success() { + return Err(anyhow!(format_ytdlp_error(&output))); + } + let stdout = String::from_utf8_lossy(&output.stdout); + let path_line = stdout + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .ok_or_else(|| anyhow!("yt-dlp did not return a filepath"))?; + let mut path = PathBuf::from(path_line.trim()); + if path.is_relative() { + path = target_dir.join(path); + } + if !path.exists() { + return Err(anyhow!("yt-dlp output not found: {}", path.display())); + } + Ok(path) +} + +pub(super) fn format_ytdlp_error(output: &std::process::Output) -> String { + let mut message = "yt-dlp failed.".to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if !stdout.is_empty() { + message.push_str("\nstdout:\n"); + message.push_str(&stdout); + } + if !stderr.is_empty() { + message.push_str("\nstderr:\n"); + message.push_str(&stderr); + } + message +} diff --git a/src/main.rs b/src/main.rs index 4ee0a76..c586320 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,6 +19,18 @@ use tempfile::{NamedTempFile, TempDir, TempPath}; use tokio::sync::Mutex; use uuid::Uuid; +mod callback_handlers; +mod helpers; +mod integrations; +mod message_handlers; +#[cfg(test)] +mod tests; + +use callback_handlers::handle_callback; +use helpers::*; +use integrations::*; +use message_handlers::handle_message; + const ACK_TTL_SECS: u64 = 5; const UNDO_TTL_SECS: u64 = 30 * 60; const DELETE_CONFIRM_TTL_SECS: u64 = 5 * 60; @@ -426,2609 +438,6 @@ async fn main() -> Result<()> { Ok(()) } -async fn handle_message(bot: Bot, msg: Message, state: std::sync::Arc) -> Result<()> { - let user_id = match msg.from() { - Some(user) => user.id.0, - None => return Ok(()), - }; - - if user_id != state.config.user_id { - return Ok(()); - } - - if handle_media_message(&bot, &msg, &state).await? { - return Ok(()); - } - - let text = match msg.text() { - Some(text) => text.to_string(), - None => return Ok(()), - }; - - let mut expired_finish_prompt: Option = None; - let pending_finish_prompt = { - let mut prompts = state.finish_title_prompts.lock().await; - if let Some(prompt) = prompts.remove(&msg.chat.id.0) { - if prompt.expires_at > now_ts() { - Some(prompt) - } else { - expired_finish_prompt = Some(prompt); - None - } - } else { - None - } - }; - - if let Some(prompt) = expired_finish_prompt { - let _ = bot - .delete_message(msg.chat.id, prompt.prompt_message_id) - .await; - } - - if let Some(prompt) = pending_finish_prompt { - handle_finish_title_response(&bot, msg.chat.id, msg.id, &state, &text, prompt).await?; - return Ok(()); - } - - let mut expired_resource_prompt: Option = None; - let pending_resource_prompt = { - let mut prompts = state.resource_filename_prompts.lock().await; - if let Some(prompt) = prompts.remove(&msg.chat.id.0) { - if prompt.expires_at > now_ts() { - Some(prompt) - } else { - expired_resource_prompt = Some(prompt); - None - } - } else { - None - } - }; - - if let Some(prompt) = expired_resource_prompt { - let _ = bot - .delete_message(msg.chat.id, prompt.prompt_message_id) - .await; - } - - if let Some(prompt) = pending_resource_prompt { - handle_resource_filename_response(&bot, msg.chat.id, msg.id, &state, &text, prompt).await?; - return Ok(()); - } - - let mut expired_download_prompt: Option = None; - let pending_download_prompt = { - let mut prompts = state.download_link_prompts.lock().await; - if let Some(prompt) = prompts.remove(&msg.chat.id.0) { - if prompt.expires_at > now_ts() { - Some(prompt) - } else { - expired_download_prompt = Some(prompt); - None - } - } else { - None - } - }; - - if let Some(prompt) = expired_download_prompt { - let _ = bot - .delete_message(msg.chat.id, prompt.prompt_message_id) - .await; - } - - if let Some(prompt) = pending_download_prompt { - handle_download_link_response(&bot, msg.chat.id, msg.id, &state, &text, prompt).await?; - return Ok(()); - } - - let mut expired_sync_x_prompt: Option = None; - let pending_sync_x_prompt = { - let mut prompts = state.sync_x_cookie_prompts.lock().await; - if let Some(prompt) = prompts.remove(&msg.chat.id.0) { - if prompt.expires_at > now_ts() { - Some(prompt) - } else { - expired_sync_x_prompt = Some(prompt); - None - } - } else { - None - } - }; - - if let Some(prompt) = expired_sync_x_prompt { - let _ = bot - .delete_message(msg.chat.id, prompt.prompt_message_id) - .await; - } - - if let Some(prompt) = pending_sync_x_prompt { - handle_sync_x_cookie_response(&bot, msg.chat.id, msg.id, &state, &text, prompt).await?; - return Ok(()); - } - - if let Some(cmd) = parse_command(&text) { - let rest = text - .splitn(2, |c: char| c.is_whitespace()) - .nth(1) - .unwrap_or("") - .trim(); - match cmd { - "start" | "help" => { - let help = "Send any text to save it. Commands: /add , /list, /top, /last, /random, /search , /download [url], /undos, /reset_peeked, /pull, /pull theirs, /push, /sync, /sync_x. Use --- to split a message into multiple items. In list views, use buttons for Mark Finished, Add Resource, Delete, Random. Quick actions: reply with del/delete to remove the current item, or send norm to normalize links."; - bot.send_message(msg.chat.id, help).await?; - return Ok(()); - } - "add" => { - if rest.is_empty() { - send_error(&bot, msg.chat.id, "Provide text to add.").await?; - } else { - handle_add_command(bot, msg, state, rest).await?; - } - return Ok(()); - } - "list" => { - handle_list_command(bot.clone(), msg.clone(), state).await?; - let _ = bot.delete_message(msg.chat.id, msg.id).await; - return Ok(()); - } - "search" | "delete" => { - if rest.is_empty() { - send_ephemeral(&bot, msg.chat.id, "Provide a search query.", ACK_TTL_SECS) - .await?; - } else { - handle_search_command(bot.clone(), msg.clone(), state, rest).await?; - } - let _ = bot.delete_message(msg.chat.id, msg.id).await; - return Ok(()); - } - "top" => { - handle_quick_select_command( - bot.clone(), - msg.clone(), - state, - QuickSelectMode::Top, - ) - .await?; - let _ = bot.delete_message(msg.chat.id, msg.id).await; - return Ok(()); - } - "last" => { - handle_quick_select_command( - bot.clone(), - msg.clone(), - state, - QuickSelectMode::Last, - ) - .await?; - let _ = bot.delete_message(msg.chat.id, msg.id).await; - return Ok(()); - } - "random" => { - handle_quick_select_command( - bot.clone(), - msg.clone(), - state, - QuickSelectMode::Random, - ) - .await?; - let _ = bot.delete_message(msg.chat.id, msg.id).await; - return Ok(()); - } - "download" => { - handle_download_command(bot.clone(), msg.clone(), state, rest).await?; - let _ = bot.delete_message(msg.chat.id, msg.id).await; - return Ok(()); - } - "reset_peeked" => { - reset_peeked(&state).await; - let _ = bot.delete_message(msg.chat.id, msg.id).await; - return Ok(()); - } - "undos" => { - handle_undos_command(bot.clone(), msg.clone(), state).await?; - let _ = bot.delete_message(msg.chat.id, msg.id).await; - return Ok(()); - } - "pull" => { - handle_pull_command(bot.clone(), msg.clone(), state, rest).await?; - let _ = bot.delete_message(msg.chat.id, msg.id).await; - return Ok(()); - } - "push" => { - handle_push_command(bot.clone(), msg.clone(), state).await?; - let _ = bot.delete_message(msg.chat.id, msg.id).await; - return Ok(()); - } - "sync" => { - handle_sync_command(bot.clone(), msg.clone(), state).await?; - let _ = bot.delete_message(msg.chat.id, msg.id).await; - return Ok(()); - } - "sync_x" => { - handle_sync_x_command(bot.clone(), msg.clone(), state).await?; - let _ = bot.delete_message(msg.chat.id, msg.id).await; - return Ok(()); - } - _ => { - // Unknown command, fall through as text. - } - } - } - - if is_instant_delete_message(&text) { - if handle_instant_delete_message(&bot, &msg, &state).await? { - return Ok(()); - } - } - - if is_norm_message(&text) { - if handle_norm_message(&bot, &msg, &state).await? { - return Ok(()); - } - } - - if text.contains("---") { - handle_multi_item(bot, msg.chat.id, msg.id, state, &text).await?; - } else { - handle_single_item(bot, msg.chat.id, state, &text, Some(msg.id)).await?; - } - - Ok(()) -} - -async fn handle_media_message( - bot: &Bot, - msg: &Message, - state: &std::sync::Arc, -) -> Result { - let chat_id = msg.chat.id; - let caption = msg.caption().map(|text| text.to_string()); - let media_dir = state.config.media_dir.clone(); - - if let Some(photos) = msg.photo() { - if let Some(photo) = pick_best_photo(photos) { - fs::create_dir_all(&media_dir) - .with_context(|| format!("create media dir {}", media_dir.display()))?; - let filename = format!("image-{}.jpg", Uuid::new_v4()); - let dest_path = media_dir.join(&filename); - download_telegram_file(bot, &photo.file.id, &dest_path).await?; - let entry_text = build_media_entry_text(&filename, caption.as_deref()); - handle_single_item( - bot.clone(), - chat_id, - state.clone(), - &entry_text, - Some(msg.id), - ) - .await?; - return Ok(true); - } - } - - if let Some(document) = msg.document() { - let mime = document.mime_type.as_ref().map(|m| m.essence_str()); - fs::create_dir_all(&media_dir) - .with_context(|| format!("create media dir {}", media_dir.display()))?; - let ext = mime.and_then(extension_from_mime); - let filename = if let Some(name) = document.file_name.as_deref() { - sanitize_filename_with_default(name, ext) - } else { - format!("file-{}.{}", Uuid::new_v4(), ext.unwrap_or("bin")) - }; - let dest_path = media_dir.join(&filename); - download_telegram_file(bot, &document.file.id, &dest_path).await?; - let entry_text = build_media_entry_text(&filename, caption.as_deref()); - handle_single_item( - bot.clone(), - chat_id, - state.clone(), - &entry_text, - Some(msg.id), - ) - .await?; - return Ok(true); - } - - if let Some(video) = msg.video() { - fs::create_dir_all(&media_dir) - .with_context(|| format!("create media dir {}", media_dir.display()))?; - let ext = video - .mime_type - .as_ref() - .map(|m| m.essence_str()) - .and_then(extension_from_mime); - let filename = if let Some(name) = video.file_name.as_deref() { - sanitize_filename_with_default(name, ext) - } else { - format!("video-{}.{}", Uuid::new_v4(), ext.unwrap_or("mp4")) - }; - let dest_path = media_dir.join(&filename); - download_telegram_file(bot, &video.file.id, &dest_path).await?; - let entry_text = build_media_entry_text(&filename, caption.as_deref()); - handle_single_item( - bot.clone(), - chat_id, - state.clone(), - &entry_text, - Some(msg.id), - ) - .await?; - return Ok(true); - } - - Ok(false) -} - -async fn handle_norm_message( - bot: &Bot, - msg: &Message, - state: &std::sync::Arc, -) -> Result { - let chat_id = msg.chat.id; - let session_id = { - let active = state.active_sessions.lock().await; - active.get(&chat_id.0).cloned() - }; - let Some(session_id) = session_id else { - return Ok(false); - }; - let mut session = { - let mut sessions = state.sessions.lock().await; - match sessions.remove(&session_id) { - Some(session) => session, - None => return Ok(false), - } - }; - if session.chat_id != chat_id.0 { - state.sessions.lock().await.insert(session_id, session); - return Ok(false); - } - - let peeked_snapshot = state.peeked.lock().await.clone(); - let target_index = match norm_target_index(&session, &peeked_snapshot) { - Some(index) => index, - None => { - state - .sessions - .lock() - .await - .insert(session.id.clone(), session); - let _ = bot.delete_message(chat_id, msg.id).await; - send_ephemeral(bot, chat_id, "Couldn't normalize.", ACK_TTL_SECS).await?; - return Ok(true); - } - }; - - let entry = match session.entries.get(target_index).cloned() { - Some(entry) => entry, - None => { - state - .sessions - .lock() - .await - .insert(session.id.clone(), session); - let _ = bot.delete_message(chat_id, msg.id).await; - send_ephemeral(bot, chat_id, "Couldn't normalize.", ACK_TTL_SECS).await?; - return Ok(true); - } - }; - - let Some(normalized_entry) = normalize_entry_markdown_links(&entry) else { - state - .sessions - .lock() - .await - .insert(session.id.clone(), session); - let _ = bot.delete_message(chat_id, msg.id).await; - send_ephemeral(bot, chat_id, "Couldn't normalize.", ACK_TTL_SECS).await?; - return Ok(true); - }; - - let op = QueuedOp { - kind: QueuedOpKind::UpdateEntry, - entry: entry.block_string(), - resource_path: None, - updated_entry: Some(normalized_entry.block_string()), - }; - - match apply_user_op(state, &op).await? { - UserOpOutcome::Applied(ApplyOutcome::Applied) => { - session.entries[target_index] = normalized_entry; - let (text, kb) = - render_list_view(&session.id, &session, &peeked_snapshot, &state.config); - if let Some(message_id) = session.message_id { - bot.edit_message_text(chat_id, message_id, text) - .reply_markup(kb) - .await?; - } else { - let sent = bot.send_message(chat_id, text).reply_markup(kb).await?; - session.message_id = Some(sent.id); - } - if let Err(err) = - refresh_embedded_media_for_view(bot, chat_id, state, &mut session, &peeked_snapshot) - .await - { - error!("send embedded media failed: {:#}", err); - } - } - UserOpOutcome::Applied(ApplyOutcome::NotFound) - | UserOpOutcome::Applied(ApplyOutcome::Duplicate) => { - send_ephemeral(bot, chat_id, "Couldn't normalize.", ACK_TTL_SECS).await?; - } - UserOpOutcome::Queued => { - send_error(bot, chat_id, "Write failed; queued for retry.").await?; - } - } - - state - .sessions - .lock() - .await - .insert(session.id.clone(), session); - let _ = bot.delete_message(chat_id, msg.id).await; - Ok(true) -} - -async fn handle_instant_delete_message( - bot: &Bot, - msg: &Message, - state: &std::sync::Arc, -) -> Result { - let chat_id = msg.chat.id; - let session_id = { - let active = state.active_sessions.lock().await; - active.get(&chat_id.0).cloned() - }; - let Some(session_id) = session_id else { - return Ok(false); - }; - let mut session = { - let mut sessions = state.sessions.lock().await; - match sessions.remove(&session_id) { - Some(session) => session, - None => return Ok(false), - } - }; - if session.chat_id != chat_id.0 { - state.sessions.lock().await.insert(session_id, session); - return Ok(false); - } - - let peeked_snapshot = state.peeked.lock().await.clone(); - let target_index = match norm_target_index(&session, &peeked_snapshot) { - Some(index) => index, - None => { - state - .sessions - .lock() - .await - .insert(session.id.clone(), session); - let _ = bot.delete_message(chat_id, msg.id).await; - send_ephemeral(bot, chat_id, "Couldn't delete.", ACK_TTL_SECS).await?; - return Ok(true); - } - }; - - let entry_block = match session.entries.get(target_index).map(|e| e.block_string()) { - Some(entry) => entry, - None => { - state - .sessions - .lock() - .await - .insert(session.id.clone(), session); - let _ = bot.delete_message(chat_id, msg.id).await; - send_ephemeral(bot, chat_id, "Couldn't delete.", ACK_TTL_SECS).await?; - return Ok(true); - } - }; - - let op = QueuedOp { - kind: QueuedOpKind::Delete, - entry: entry_block, - resource_path: None, - updated_entry: None, - }; - - match apply_user_op(state, &op).await? { - UserOpOutcome::Applied(ApplyOutcome::Applied) => { - session.entries.remove(target_index); - if let ListView::Selected { return_to, .. } = session.view.clone() { - session.view = *return_to; - } - let _ = add_undo(state, UndoKind::Delete, op.entry.clone()).await?; - normalize_peek_view(&mut session, &peeked_snapshot); - let (text, kb) = - render_list_view(&session.id, &session, &peeked_snapshot, &state.config); - if let Some(message_id) = session.message_id { - bot.edit_message_text(chat_id, message_id, text) - .reply_markup(kb) - .await?; - } else { - let sent = bot.send_message(chat_id, text).reply_markup(kb).await?; - session.message_id = Some(sent.id); - } - if let Err(err) = - refresh_embedded_media_for_view(bot, chat_id, state, &mut session, &peeked_snapshot) - .await - { - error!("send embedded media failed: {:#}", err); - } - } - UserOpOutcome::Applied(ApplyOutcome::NotFound) - | UserOpOutcome::Applied(ApplyOutcome::Duplicate) => { - send_ephemeral(bot, chat_id, "Couldn't delete.", ACK_TTL_SECS).await?; - } - UserOpOutcome::Queued => { - send_error(bot, chat_id, "Write failed; queued for retry.").await?; - } - } - - state - .sessions - .lock() - .await - .insert(session.id.clone(), session); - let _ = bot.delete_message(chat_id, msg.id).await; - Ok(true) -} - -fn is_instant_delete_message(text: &str) -> bool { - matches!(text.trim().to_lowercase().as_str(), "del" | "delete") -} - -fn is_norm_message(text: &str) -> bool { - text.trim().eq_ignore_ascii_case("norm") -} - -async fn handle_callback( - bot: Bot, - q: CallbackQuery, - state: std::sync::Arc, -) -> Result<()> { - let user_id = q.from.id.0; - if user_id != state.config.user_id { - return Ok(()); - } - - if let Some(data) = q.data.as_deref() { - if data.starts_with("ls:") { - handle_list_callback(bot, q, state).await?; - } else if data.starts_with("pick:") { - handle_picker_callback(bot, q, state).await?; - } else if data.starts_with("add:") { - handle_add_callback(bot, q, state).await?; - } else if data.starts_with("res:") { - handle_resource_callback(bot, q, state).await?; - } else if data.starts_with("dl:") { - handle_download_callback(bot, q, state).await?; - } else if data.starts_with("msgdel") { - handle_message_delete_callback(bot, q).await?; - } else if data.starts_with("undos:") { - handle_undos_callback(bot, q, state).await?; - } else if data.starts_with("undo:") { - handle_undo_callback(bot, q, state).await?; - } - } - - Ok(()) -} - -async fn handle_list_command( - bot: Bot, - msg: Message, - state: std::sync::Arc, -) -> Result<()> { - let entries = read_entries(&state.config.read_later_path)?.1; - let session_id = short_id(); - let mut session = ListSession { - id: session_id.clone(), - chat_id: msg.chat.id.0, - kind: SessionKind::List, - entries, - view: ListView::Menu, - seen_random: HashSet::new(), - message_id: None, - sent_media_message_ids: Vec::new(), - }; - - let (text, kb) = build_menu_view(&session_id, &session); - let sent = bot.send_message(msg.chat.id, text).reply_markup(kb).await?; - session.message_id = Some(sent.id); - state - .sessions - .lock() - .await - .insert(session_id.clone(), session); - state - .active_sessions - .lock() - .await - .insert(msg.chat.id.0, session_id); - Ok(()) -} - -async fn handle_quick_select_command( - bot: Bot, - msg: Message, - state: std::sync::Arc, - mode: QuickSelectMode, -) -> Result<()> { - let entries = read_entries(&state.config.read_later_path)?.1; - let Some(index) = quick_select_index(entries.len(), mode) else { - send_ephemeral(&bot, msg.chat.id, "Read Later is empty.", ACK_TTL_SECS).await?; - return Ok(()); - }; - - let session_id = short_id(); - let mut session = ListSession { - id: session_id.clone(), - chat_id: msg.chat.id.0, - kind: SessionKind::List, - entries, - view: ListView::Selected { - return_to: Box::new(ListView::Menu), - index, - }, - seen_random: HashSet::new(), - message_id: None, - sent_media_message_ids: Vec::new(), - }; - - if matches!(mode, QuickSelectMode::Random) { - session.seen_random.insert(index); - } - if let Some(entry) = session.entries.get(index) { - state.peeked.lock().await.insert(entry.block_string()); - } - - let peeked_snapshot = state.peeked.lock().await.clone(); - let (text, kb) = render_list_view(&session_id, &session, &peeked_snapshot, &state.config); - let sent = bot.send_message(msg.chat.id, text).reply_markup(kb).await?; - session.message_id = Some(sent.id); - if let Err(err) = - refresh_embedded_media_for_view(&bot, msg.chat.id, &state, &mut session, &peeked_snapshot) - .await - { - error!("send embedded media failed: {:#}", err); - } - state - .sessions - .lock() - .await - .insert(session_id.clone(), session); - state - .active_sessions - .lock() - .await - .insert(msg.chat.id.0, session_id); - Ok(()) -} - -async fn handle_search_command( - bot: Bot, - msg: Message, - state: std::sync::Arc, - query: &str, -) -> Result<()> { - let entries = read_entries(&state.config.read_later_path)?.1; - let matches = search_entries(&entries, query); - - if matches.is_empty() { - send_ephemeral(&bot, msg.chat.id, "No matches.", ACK_TTL_SECS).await?; - return Ok(()); - } - - let session_id = short_id(); - let mut session = ListSession { - id: session_id.clone(), - chat_id: msg.chat.id.0, - kind: SessionKind::Search { - query: query.to_string(), - }, - entries: matches, - view: ListView::Peek { - mode: ListMode::Top, - page: 0, - }, - seen_random: HashSet::new(), - message_id: None, - sent_media_message_ids: Vec::new(), - }; - - let peeked_snapshot = state.peeked.lock().await.clone(); - let (text, kb) = render_list_view(&session_id, &session, &peeked_snapshot, &state.config); - let sent = bot.send_message(msg.chat.id, text).reply_markup(kb).await?; - session.message_id = Some(sent.id); - state - .sessions - .lock() - .await - .insert(session_id.clone(), session); - state - .active_sessions - .lock() - .await - .insert(msg.chat.id.0, session_id); - Ok(()) -} - -async fn handle_download_command( - bot: Bot, - msg: Message, - state: std::sync::Arc, - rest: &str, -) -> Result<()> { - let links = if !rest.trim().is_empty() { - extract_links(rest) - } else { - match active_entry_text(&state, msg.chat.id.0).await { - Some(text) => extract_links(&text), - None => Vec::new(), - } - }; - - start_download_picker(&bot, msg.chat.id, &state, links).await?; - Ok(()) -} - -async fn active_entry_text(state: &std::sync::Arc, chat_id: i64) -> Option { - let session_id = { - let active = state.active_sessions.lock().await; - active.get(&chat_id).cloned() - }?; - let session = { - let sessions = state.sessions.lock().await; - sessions.get(&session_id).cloned() - }?; - if session.chat_id != chat_id { - return None; - } - let peeked_snapshot = state.peeked.lock().await.clone(); - match &session.view { - ListView::Selected { index, .. } => session - .entries - .get(*index) - .map(|entry| entry.display_lines().join("\n")), - ListView::Peek { mode, page } => { - let indices = peek_indices_for_session(&session, &peeked_snapshot, *mode, *page); - if indices.len() == 1 { - session - .entries - .get(indices[0]) - .map(|entry| entry.display_lines().join("\n")) - } else { - None - } - } - _ => None, - } -} - -async fn handle_push_command( - bot: Bot, - msg: Message, - state: std::sync::Arc, -) -> Result<()> { - let Some(sync) = state.config.sync.clone() else { - send_error( - &bot, - msg.chat.id, - "Sync not configured. Set settings.sync.repo_path and settings.sync.token_file.", - ) - .await?; - return Ok(()); - }; - - let chat_id = msg.chat.id; - let outcome = tokio::task::spawn_blocking(move || run_push(&sync)) - .await - .context("push task failed")?; - - match outcome { - Ok(PushOutcome::NoChanges) => { - send_ephemeral(&bot, chat_id, "Nothing to sync.", ACK_TTL_SECS).await?; - } - Ok(PushOutcome::Pushed) => { - send_ephemeral(&bot, chat_id, "Synced.", ACK_TTL_SECS).await?; - } - Err(err) => { - send_error(&bot, chat_id, &err.to_string()).await?; - } - } - - Ok(()) -} - -async fn handle_pull_command( - bot: Bot, - msg: Message, - state: std::sync::Arc, - rest: &str, -) -> Result<()> { - let Some(sync) = state.config.sync.clone() else { - send_error( - &bot, - msg.chat.id, - "Sync not configured. Set settings.sync.repo_path and settings.sync.token_file.", - ) - .await?; - return Ok(()); - }; - - let mode = match parse_pull_mode(rest) { - Ok(mode) => mode, - Err(message) => { - send_error(&bot, msg.chat.id, &message).await?; - return Ok(()); - } - }; - - let chat_id = msg.chat.id; - let outcome = tokio::task::spawn_blocking(move || run_pull(&sync, mode)) - .await - .context("pull task failed")?; - - match outcome { - Ok(PullOutcome::UpToDate) => { - send_ephemeral(&bot, chat_id, "Already up to date.", ACK_TTL_SECS).await?; - } - Ok(PullOutcome::Pulled) => { - send_ephemeral(&bot, chat_id, "Pulled.", ACK_TTL_SECS).await?; - } - Err(err) => { - send_error(&bot, chat_id, &err.to_string()).await?; - } - } - - Ok(()) -} - -async fn handle_sync_command( - bot: Bot, - msg: Message, - state: std::sync::Arc, -) -> Result<()> { - let Some(sync) = state.config.sync.clone() else { - send_error( - &bot, - msg.chat.id, - "Sync not configured. Set settings.sync.repo_path and settings.sync.token_file.", - ) - .await?; - return Ok(()); - }; - - let chat_id = msg.chat.id; - let outcome = tokio::task::spawn_blocking(move || run_sync(&sync)) - .await - .context("sync task failed")?; - - match outcome { - Ok(SyncOutcome::Synced) => { - send_ephemeral(&bot, chat_id, "Synced.", ACK_TTL_SECS).await?; - } - Ok(SyncOutcome::NoChanges) => { - send_ephemeral(&bot, chat_id, "Nothing to sync.", ACK_TTL_SECS).await?; - } - Err(err) => { - send_error(&bot, chat_id, &err.to_string()).await?; - } - } - - Ok(()) -} - -async fn handle_sync_x_command( - bot: Bot, - msg: Message, - state: std::sync::Arc, -) -> Result<()> { - if state.config.sync_x.is_none() { - send_error( - &bot, - msg.chat.id, - "sync_x not configured. Set settings.sync_x.source_project_path (and optionally settings.sync_x.python_bin/work_dir).", - ) - .await?; - return Ok(()); - } - - let prompt_text = - "Paste the Cloudflare cookie header string from x.com (must include auth_token and ct0)."; - let sent = bot.send_message(msg.chat.id, prompt_text).await?; - state.sync_x_cookie_prompts.lock().await.insert( - msg.chat.id.0, - SyncXCookiePrompt { - prompt_message_id: sent.id, - expires_at: now_ts() + SYNC_X_PROMPT_TTL_SECS, - }, - ); - Ok(()) -} - -async fn handle_sync_x_cookie_response( - bot: &Bot, - chat_id: ChatId, - message_id: MessageId, - state: &std::sync::Arc, - text: &str, - prompt: SyncXCookiePrompt, -) -> Result<()> { - let cookie_header = text.trim(); - if cookie_header.is_empty() { - send_error( - bot, - chat_id, - "Cookie header is empty. Paste the full header string.", - ) - .await?; - state.sync_x_cookie_prompts.lock().await.insert( - chat_id.0, - SyncXCookiePrompt { - prompt_message_id: prompt.prompt_message_id, - expires_at: now_ts() + SYNC_X_PROMPT_TTL_SECS, - }, - ); - let _ = bot.delete_message(chat_id, message_id).await; - return Ok(()); - } - - let _ = bot.delete_message(chat_id, prompt.prompt_message_id).await; - let _ = bot.delete_message(chat_id, message_id).await; - - let status_msg = bot.send_message(chat_id, "Syncing X bookmarks...").await?; - let config = state.config.clone(); - let cookie_header = cookie_header.to_string(); - let outcome = tokio::task::spawn_blocking(move || run_sync_x(&config, &cookie_header)) - .await - .context("sync_x task failed")?; - let _ = bot.delete_message(chat_id, status_msg.id).await; - - match outcome { - Ok(sync_outcome) => { - if sync_outcome.extracted_count == 0 { - send_ephemeral(bot, chat_id, "No X bookmarks found.", ACK_TTL_SECS).await?; - } else { - let text = format!( - "X sync complete: extracted {}, added {}, skipped {} duplicates.", - sync_outcome.extracted_count, - sync_outcome.added_count, - sync_outcome.duplicate_count - ); - let kb = InlineKeyboardMarkup::new(vec![vec![InlineKeyboardButton::callback( - "Delete message", - "msgdel", - )]]); - bot.send_message(chat_id, text).reply_markup(kb).await?; - } - } - Err(err) => { - send_error(bot, chat_id, &format!("sync_x failed: {}", err)).await?; - } - } - - Ok(()) -} - -async fn handle_undos_command( - bot: Bot, - msg: Message, - state: std::sync::Arc, -) -> Result<()> { - let (records, undo_snapshot) = { - let mut undo = state.undo.lock().await; - prune_undo(&mut undo); - let snapshot = undo.clone(); - (undo.clone(), snapshot) - }; - save_undo(&state.undo_path, &undo_snapshot)?; - - if records.is_empty() { - send_ephemeral(&bot, msg.chat.id, "No undos.", ACK_TTL_SECS).await?; - return Ok(()); - } - - let session_id = short_id(); - let (text, kb) = build_undos_view(&session_id, &records); - let sent = bot.send_message(msg.chat.id, text).reply_markup(kb).await?; - let session = UndoSession { - chat_id: msg.chat.id.0, - message_id: sent.id, - records, - }; - state.undo_sessions.lock().await.insert(session_id, session); - Ok(()) -} - -async fn handle_single_item( - bot: Bot, - chat_id: ChatId, - state: std::sync::Arc, - text: &str, - source_message_id: Option, -) -> Result<()> { - let entry = EntryBlock::from_text(text); - let op = QueuedOp { - kind: QueuedOpKind::Add, - entry: entry.block_string(), - resource_path: None, - updated_entry: None, - }; - - match apply_user_op(&state, &op).await? { - UserOpOutcome::Applied(ApplyOutcome::Applied) => { - send_ephemeral(&bot, chat_id, "Saved.", ACK_TTL_SECS).await?; - if let Some(message_id) = source_message_id { - let _ = bot.delete_message(chat_id, message_id).await; - } - } - UserOpOutcome::Applied(ApplyOutcome::Duplicate) => { - send_ephemeral(&bot, chat_id, "Already saved.", ACK_TTL_SECS).await?; - if let Some(message_id) = source_message_id { - let _ = bot.delete_message(chat_id, message_id).await; - } - } - UserOpOutcome::Applied(ApplyOutcome::NotFound) => { - // Not used for add. - } - UserOpOutcome::Queued => { - send_error(&bot, chat_id, "Write failed; queued for retry.").await?; - } - } - - Ok(()) -} - -async fn handle_multi_item( - bot: Bot, - chat_id: ChatId, - source_message_id: MessageId, - state: std::sync::Arc, - text: &str, -) -> Result<()> { - let items = split_items(text); - if items.is_empty() { - send_error(&bot, chat_id, "No items found.").await?; - return Ok(()); - } - - let picker_id = short_id(); - let selected = vec![false; items.len()]; - let view_text = build_picker_text(&items, &selected); - let kb = build_picker_keyboard(&picker_id, &selected); - let sent = bot - .send_message(chat_id, view_text) - .reply_markup(kb) - .await?; - - let picker = PickerState { - id: picker_id.clone(), - chat_id: chat_id.0, - message_id: sent.id, - items, - selected, - source_message_id, - }; - state.pickers.lock().await.insert(picker_id, picker); - Ok(()) -} - -async fn handle_add_command( - bot: Bot, - msg: Message, - state: std::sync::Arc, - text: &str, -) -> Result<()> { - let prompt_id = short_id(); - let kb = build_add_prompt_keyboard(&prompt_id); - let prompt_text = "Add to reading list or resources?"; - let sent = bot - .send_message(msg.chat.id, prompt_text) - .reply_markup(kb) - .await?; - - let prompt = AddPrompt { - chat_id: msg.chat.id.0, - message_id: sent.id, - text: text.to_string(), - source_message_id: msg.id, - }; - state.add_prompts.lock().await.insert(prompt_id, prompt); - Ok(()) -} - -async fn handle_add_callback( - bot: Bot, - q: CallbackQuery, - state: std::sync::Arc, -) -> Result<()> { - let Some(message) = q.message.clone() else { - return Ok(()); - }; - let Some(data) = q.data.as_deref() else { - return Ok(()); - }; - let mut parts = data.split(':'); - let _ = parts.next(); - let prompt_id = match parts.next() { - Some(id) => id.to_string(), - None => return Ok(()), - }; - let action = match parts.next() { - Some(action) => action, - None => return Ok(()), - }; - - let prompt = { - let mut prompts = state.add_prompts.lock().await; - let prompt = match prompts.remove(&prompt_id) { - Some(prompt) => prompt, - None => { - bot.answer_callback_query(q.id).await?; - return Ok(()); - } - }; - if prompt.chat_id != message.chat.id.0 || prompt.message_id != message.id { - prompts.insert(prompt_id.clone(), prompt); - bot.answer_callback_query(q.id).await?; - return Ok(()); - } - prompt - }; - - match action { - "normal" => { - handle_single_item( - bot.clone(), - message.chat.id, - state.clone(), - &prompt.text, - Some(prompt.source_message_id), - ) - .await?; - } - "resource" => { - start_resource_picker( - &bot, - message.chat.id, - &state, - &prompt.text, - Some(prompt.source_message_id), - ) - .await?; - } - "cancel" => {} - _ => { - let mut prompts = state.add_prompts.lock().await; - prompts.insert(prompt_id, prompt); - bot.answer_callback_query(q.id).await?; - return Ok(()); - } - } - - let _ = bot.delete_message(message.chat.id, message.id).await; - bot.answer_callback_query(q.id).await?; - Ok(()) -} - -async fn start_resource_picker( - bot: &Bot, - chat_id: ChatId, - state: &std::sync::Arc, - text: &str, - source_message_id: Option, -) -> Result<()> { - let files = list_resource_files(&state.config.resources_path)?; - let picker_id = short_id(); - let kb = build_resource_picker_keyboard(&picker_id, &files); - let prompt_text = if files.is_empty() { - "No resource files found. Create a new one?" - } else { - "Choose a resource file:" - }; - let sent = bot - .send_message(chat_id, prompt_text) - .reply_markup(kb) - .await?; - - let picker = ResourcePickerState { - chat_id: chat_id.0, - message_id: sent.id, - text: text.to_string(), - source_message_id, - files, - }; - state - .resource_pickers - .lock() - .await - .insert(picker_id, picker); - Ok(()) -} - -async fn handle_resource_callback( - bot: Bot, - q: CallbackQuery, - state: std::sync::Arc, -) -> Result<()> { - let Some(message) = q.message.clone() else { - return Ok(()); - }; - let Some(data) = q.data.as_deref() else { - return Ok(()); - }; - let mut parts = data.split(':'); - let _ = parts.next(); - let picker_id = match parts.next() { - Some(id) => id.to_string(), - None => return Ok(()), - }; - let action = match parts.next() { - Some(action) => action, - None => return Ok(()), - }; - - let picker = { - let mut pickers = state.resource_pickers.lock().await; - let picker = match pickers.remove(&picker_id) { - Some(picker) => picker, - None => { - bot.answer_callback_query(q.id).await?; - return Ok(()); - } - }; - if picker.chat_id != message.chat.id.0 || picker.message_id != message.id { - pickers.insert(picker_id.clone(), picker); - bot.answer_callback_query(q.id).await?; - return Ok(()); - } - picker - }; - - let mut reinsert = false; - match action { - "file" => { - let index = parts.next().and_then(|p| p.parse::().ok()); - if let Some(index) = index { - if let Some(path) = picker.files.get(index).cloned() { - add_resource_from_text( - &bot, - message.chat.id, - &state, - path, - &picker.text, - picker.source_message_id.clone(), - ) - .await?; - let _ = bot.delete_message(message.chat.id, message.id).await; - } else { - reinsert = true; - } - } else { - reinsert = true; - } - } - "new" => { - let prompt_text = "Send the new resource filename (example: Resources.md)."; - let sent = bot.send_message(message.chat.id, prompt_text).await?; - let prompt = ResourceFilenamePrompt { - text: picker.text.clone(), - source_message_id: picker.source_message_id.clone(), - prompt_message_id: sent.id, - expires_at: now_ts() + RESOURCE_PROMPT_TTL_SECS, - }; - let previous = state - .resource_filename_prompts - .lock() - .await - .insert(message.chat.id.0, prompt); - if let Some(previous) = previous { - let _ = bot - .delete_message(message.chat.id, previous.prompt_message_id) - .await; - } - let _ = bot.delete_message(message.chat.id, message.id).await; - } - "cancel" => { - let _ = bot.delete_message(message.chat.id, message.id).await; - } - _ => { - reinsert = true; - } - } - - if reinsert { - state - .resource_pickers - .lock() - .await - .insert(picker_id, picker); - } - - bot.answer_callback_query(q.id).await?; - Ok(()) -} - -async fn add_resource_from_text( - bot: &Bot, - chat_id: ChatId, - state: &std::sync::Arc, - resource_path: PathBuf, - text: &str, - source_message_id: Option, -) -> Result<()> { - let entry_block = resource_block_from_text(text); - let op = QueuedOp { - kind: QueuedOpKind::AddResource, - entry: entry_block, - resource_path: Some(resource_path), - updated_entry: None, - }; - - match apply_user_op(state, &op).await? { - UserOpOutcome::Applied(ApplyOutcome::Applied) => { - send_ephemeral(bot, chat_id, "Added to resources.", ACK_TTL_SECS).await?; - if let Some(message_id) = source_message_id { - let _ = bot.delete_message(chat_id, message_id).await; - } - } - UserOpOutcome::Applied(ApplyOutcome::Duplicate) => { - send_ephemeral(bot, chat_id, "Already in resources.", ACK_TTL_SECS).await?; - if let Some(message_id) = source_message_id { - let _ = bot.delete_message(chat_id, message_id).await; - } - } - UserOpOutcome::Applied(ApplyOutcome::NotFound) => {} - UserOpOutcome::Queued => { - send_error(bot, chat_id, "Write failed; queued for retry.").await?; - } - } - - Ok(()) -} - -async fn handle_resource_filename_response( - bot: &Bot, - chat_id: ChatId, - message_id: MessageId, - state: &std::sync::Arc, - text: &str, - prompt: ResourceFilenamePrompt, -) -> Result<()> { - let filename = match sanitize_resource_filename(text) { - Ok(name) => name, - Err(err) => { - send_error(bot, chat_id, &err.to_string()).await?; - let mut prompts = state.resource_filename_prompts.lock().await; - prompts.insert( - chat_id.0, - ResourceFilenamePrompt { - expires_at: now_ts() + RESOURCE_PROMPT_TTL_SECS, - ..prompt - }, - ); - let _ = bot.delete_message(chat_id, message_id).await; - return Ok(()); - } - }; - - let resource_path = state.config.resources_path.join(filename); - add_resource_from_text( - bot, - chat_id, - state, - resource_path, - &prompt.text, - prompt.source_message_id.clone(), - ) - .await?; - - let _ = bot.delete_message(chat_id, prompt.prompt_message_id).await; - let _ = bot.delete_message(chat_id, message_id).await; - Ok(()) -} - -async fn start_download_picker( - bot: &Bot, - chat_id: ChatId, - state: &std::sync::Arc, - links: Vec, -) -> Result<()> { - let picker_id = short_id(); - let text = build_download_picker_text(&links); - let kb = build_download_picker_keyboard(&picker_id, &links); - let sent = bot.send_message(chat_id, text).reply_markup(kb).await?; - let picker = DownloadPickerState { - chat_id: chat_id.0, - message_id: sent.id, - links, - mode: DownloadPickerMode::Links, - }; - state - .download_pickers - .lock() - .await - .insert(picker_id, picker); - Ok(()) -} - -async fn handle_download_callback( - bot: Bot, - q: CallbackQuery, - state: std::sync::Arc, -) -> Result<()> { - let Some(message) = q.message.clone() else { - return Ok(()); - }; - let Some(data) = q.data.as_deref() else { - return Ok(()); - }; - let mut parts = data.split(':'); - let _ = parts.next(); - let picker_id = match parts.next() { - Some(id) => id.to_string(), - None => return Ok(()), - }; - let action = match parts.next() { - Some(action) => action, - None => return Ok(()), - }; - - let mut picker = { - let mut pickers = state.download_pickers.lock().await; - let picker = match pickers.remove(&picker_id) { - Some(picker) => picker, - None => { - bot.answer_callback_query(q.id).await?; - return Ok(()); - } - }; - if picker.chat_id != message.chat.id.0 || picker.message_id != message.id { - pickers.insert(picker_id.clone(), picker); - bot.answer_callback_query(q.id).await?; - return Ok(()); - } - picker - }; - - let mut reinsert = false; - bot.answer_callback_query(q.id).await?; - - match action { - "send" => { - if !matches!(picker.mode, DownloadPickerMode::Links) { - reinsert = true; - } else { - let index = parts.next().and_then(|p| p.parse::().ok()); - if let Some(index) = index { - if let Some(link) = picker.links.get(index).cloned() { - let link_for_probe = link.clone(); - let options = tokio::task::spawn_blocking(move || { - run_ytdlp_list_formats(&link_for_probe) - }) - .await - .context("yt-dlp formats task failed")?; - match options { - Ok(options) => { - let text = build_download_quality_text( - &link, - DownloadAction::Send, - &options, - ); - let kb = build_download_quality_keyboard(&picker_id, &options); - bot.edit_message_text(message.chat.id, message.id, text) - .reply_markup(kb) - .await?; - picker.mode = DownloadPickerMode::Quality { - link_index: index, - action: DownloadAction::Send, - options, - }; - reinsert = true; - } - Err(err) => { - send_error(&bot, message.chat.id, &err.to_string()).await?; - reinsert = true; - } - } - } else { - reinsert = true; - } - } else { - reinsert = true; - } - } - } - "save" => { - if !matches!(picker.mode, DownloadPickerMode::Links) { - reinsert = true; - } else { - let index = parts.next().and_then(|p| p.parse::().ok()); - if let Some(index) = index { - if let Some(link) = picker.links.get(index).cloned() { - let link_for_probe = link.clone(); - let options = tokio::task::spawn_blocking(move || { - run_ytdlp_list_formats(&link_for_probe) - }) - .await - .context("yt-dlp formats task failed")?; - match options { - Ok(options) => { - let text = build_download_quality_text( - &link, - DownloadAction::Save, - &options, - ); - let kb = build_download_quality_keyboard(&picker_id, &options); - bot.edit_message_text(message.chat.id, message.id, text) - .reply_markup(kb) - .await?; - picker.mode = DownloadPickerMode::Quality { - link_index: index, - action: DownloadAction::Save, - options, - }; - reinsert = true; - } - Err(err) => { - send_error(&bot, message.chat.id, &err.to_string()).await?; - reinsert = true; - } - } - } else { - reinsert = true; - } - } else { - reinsert = true; - } - } - } - "quality" => { - let selected = parts.next().and_then(|p| p.parse::().ok()); - if let ( - Some(selected), - DownloadPickerMode::Quality { - link_index, - action, - options, - }, - ) = (selected, &picker.mode) - { - if let (Some(link), Some(option)) = ( - picker.links.get(*link_index).cloned(), - options.get(selected).cloned(), - ) { - match action { - DownloadAction::Send => { - match download_and_send_link( - &bot, - message.chat.id, - &link, - &option.format_selector, - ) - .await - { - Ok(()) => { - let _ = bot.delete_message(message.chat.id, message.id).await; - } - Err(err) => { - send_error(&bot, message.chat.id, &err.to_string()).await?; - reinsert = true; - } - } - } - DownloadAction::Save => { - match download_and_save_link(&state, &link, &option.format_selector) - .await - { - Ok(path) => { - let note = format!("Saved to {}", path.display()); - let kb = InlineKeyboardMarkup::new(vec![vec![ - InlineKeyboardButton::callback("Delete message", "msgdel"), - ]]); - bot.send_message(message.chat.id, note) - .reply_markup(kb) - .await?; - let _ = bot.delete_message(message.chat.id, message.id).await; - } - Err(err) => { - send_error(&bot, message.chat.id, &err.to_string()).await?; - reinsert = true; - } - } - } - } - } else { - reinsert = true; - } - } else { - reinsert = true; - } - } - "back" => { - if matches!(picker.mode, DownloadPickerMode::Quality { .. }) { - let text = build_download_picker_text(&picker.links); - let kb = build_download_picker_keyboard(&picker_id, &picker.links); - bot.edit_message_text(message.chat.id, message.id, text) - .reply_markup(kb) - .await?; - picker.mode = DownloadPickerMode::Links; - reinsert = true; - } else { - reinsert = true; - } - } - "add" => { - if matches!(picker.mode, DownloadPickerMode::Links) { - let prompt_text = "Send a link to add."; - let sent = bot.send_message(message.chat.id, prompt_text).await?; - let prompt = DownloadLinkPrompt { - links: picker.links.clone(), - prompt_message_id: sent.id, - expires_at: now_ts() + DOWNLOAD_PROMPT_TTL_SECS, - }; - let previous = state - .download_link_prompts - .lock() - .await - .insert(message.chat.id.0, prompt); - if let Some(previous) = previous { - let _ = bot - .delete_message(message.chat.id, previous.prompt_message_id) - .await; - } - let _ = bot.delete_message(message.chat.id, message.id).await; - } else { - reinsert = true; - } - } - "cancel" => { - let _ = bot.delete_message(message.chat.id, message.id).await; - } - _ => { - reinsert = true; - } - } - - if reinsert { - state - .download_pickers - .lock() - .await - .insert(picker_id, picker); - } - - Ok(()) -} - -async fn handle_download_link_response( - bot: &Bot, - chat_id: ChatId, - message_id: MessageId, - state: &std::sync::Arc, - text: &str, - prompt: DownloadLinkPrompt, -) -> Result<()> { - let new_links = extract_links(text); - if new_links.is_empty() { - send_error(bot, chat_id, "No links found. Send a URL.").await?; - let mut prompts = state.download_link_prompts.lock().await; - prompts.insert( - chat_id.0, - DownloadLinkPrompt { - expires_at: now_ts() + DOWNLOAD_PROMPT_TTL_SECS, - ..prompt - }, - ); - let _ = bot.delete_message(chat_id, message_id).await; - return Ok(()); - } - - let mut links = prompt.links.clone(); - for link in new_links { - if !links.contains(&link) { - links.push(link); - } - } - start_download_picker(bot, chat_id, state, links).await?; - let _ = bot.delete_message(chat_id, prompt.prompt_message_id).await; - let _ = bot.delete_message(chat_id, message_id).await; - Ok(()) -} - -async fn handle_message_delete_callback(bot: Bot, q: CallbackQuery) -> Result<()> { - if let Some(message) = q.message.clone() { - let _ = bot.delete_message(message.chat.id, message.id).await; - } - bot.answer_callback_query(q.id).await?; - Ok(()) -} - -async fn handle_finish_title_response( - bot: &Bot, - chat_id: ChatId, - message_id: MessageId, - state: &std::sync::Arc, - text: &str, - prompt: FinishTitlePrompt, -) -> Result<()> { - let title = text.lines().next().unwrap_or("").trim(); - if title.is_empty() { - send_error(bot, chat_id, "Provide a title.").await?; - let mut prompts = state.finish_title_prompts.lock().await; - prompts.insert( - chat_id.0, - FinishTitlePrompt { - expires_at: now_ts() + FINISH_TITLE_PROMPT_TTL_SECS, - ..prompt - }, - ); - let _ = bot.delete_message(chat_id, message_id).await; - return Ok(()); - } - - let updated_entry = entry_with_title(&prompt.entry, title, &prompt.link); - let mut session = { - let mut sessions = state.sessions.lock().await; - let session = match sessions.remove(&prompt.session_id) { - Some(session) => session, - None => { - let _ = bot.delete_message(chat_id, prompt.prompt_message_id).await; - let _ = bot.delete_message(chat_id, message_id).await; - return Ok(()); - } - }; - if session.chat_id != prompt.chat_id { - sessions.insert(prompt.session_id.clone(), session); - let _ = bot.delete_message(chat_id, prompt.prompt_message_id).await; - let _ = bot.delete_message(chat_id, message_id).await; - return Ok(()); - } - session - }; - - let entry_index = session - .entries - .iter() - .position(|entry| entry.block_string() == prompt.entry); - let Some(entry_index) = entry_index else { - state - .sessions - .lock() - .await - .insert(prompt.session_id.clone(), session); - send_error(bot, chat_id, "Item not found.").await?; - let _ = bot.delete_message(chat_id, prompt.prompt_message_id).await; - let _ = bot.delete_message(chat_id, message_id).await; - return Ok(()); - }; - - let op = QueuedOp { - kind: QueuedOpKind::MoveToFinishedUpdated, - entry: prompt.entry.clone(), - resource_path: None, - updated_entry: Some(updated_entry.clone()), - }; - - match apply_user_op(state, &op).await? { - UserOpOutcome::Applied(ApplyOutcome::Applied) => { - session.entries.remove(entry_index); - session.view = prompt.return_to.clone(); - let peeked_snapshot = state.peeked.lock().await.clone(); - normalize_peek_view(&mut session, &peeked_snapshot); - send_ephemeral(bot, chat_id, "Moved.", ACK_TTL_SECS).await?; - let _ = add_undo(state, UndoKind::MoveToFinished, updated_entry).await?; - } - UserOpOutcome::Applied(ApplyOutcome::NotFound) => { - send_error(bot, chat_id, "Item not found.").await?; - } - UserOpOutcome::Applied(ApplyOutcome::Duplicate) => {} - UserOpOutcome::Queued => { - send_error(bot, chat_id, "Write failed; queued for retry.").await?; - } - } - - let peeked_snapshot = state.peeked.lock().await.clone(); - let (text, kb) = render_list_view(&session.id, &session, &peeked_snapshot, &state.config); - if let Some(list_message_id) = session.message_id { - bot.edit_message_text(chat_id, list_message_id, text) - .reply_markup(kb) - .await?; - } else { - let sent = bot.send_message(chat_id, text).reply_markup(kb).await?; - session.message_id = Some(sent.id); - } - if let Err(err) = - refresh_embedded_media_for_view(bot, chat_id, state, &mut session, &peeked_snapshot).await - { - error!("send embedded media failed: {:#}", err); - } - state - .sessions - .lock() - .await - .insert(prompt.session_id.clone(), session); - state - .active_sessions - .lock() - .await - .insert(chat_id.0, prompt.session_id.clone()); - - let _ = bot.delete_message(chat_id, prompt.prompt_message_id).await; - let _ = bot.delete_message(chat_id, message_id).await; - Ok(()) -} - -async fn handle_list_callback( - bot: Bot, - q: CallbackQuery, - state: std::sync::Arc, -) -> Result<()> { - let Some(message) = q.message.clone() else { - return Ok(()); - }; - let Some(data) = q.data.as_deref() else { - return Ok(()); - }; - let mut parts = data.split(':'); - let _ = parts.next(); - let session_id = match parts.next() { - Some(id) => id.to_string(), - None => return Ok(()), - }; - let action = match parts.next() { - Some(action) => action, - None => return Ok(()), - }; - - let chat_id = message.chat.id.0; - let mut session = { - let mut sessions = state.sessions.lock().await; - let session = match sessions.remove(&session_id) { - Some(session) => session, - None => { - bot.answer_callback_query(q.id).await?; - return Ok(()); - } - }; - if session.chat_id != chat_id { - sessions.insert(session_id.clone(), session); - bot.answer_callback_query(q.id).await?; - return Ok(()); - } - session - }; - - let peeked_snapshot = state.peeked.lock().await.clone(); - let mut refresh_list_view = true; - let mut close_session = false; - - let action_result: Result<()> = async { - match action { - "menu" => { - if matches!(&session.kind, SessionKind::List) { - session.view = ListView::Menu; - } - } - "top" => { - let page = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0); - session.view = ListView::Peek { - mode: ListMode::Top, - page, - }; - } - "bottom" => { - let page = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0); - session.view = ListView::Peek { - mode: ListMode::Bottom, - page, - }; - } - "next" => { - if let ListView::Peek { mode, page } = session.view.clone() { - session.view = ListView::Peek { - mode, - page: page + 1, - }; - } - } - "prev" => { - if let ListView::Peek { mode, page } = session.view.clone() { - session.view = ListView::Peek { - mode, - page: page.saturating_sub(1), - }; - } - } - "back" => { - session.view = match session.view.clone() { - ListView::Selected { return_to, .. } => *return_to, - ListView::Peek { .. } => ListView::Menu, - other => other, - }; - } - "close" => { - if matches!(&session.kind, SessionKind::Search { .. }) { - delete_embedded_media_messages( - &bot, - message.chat.id, - &session.sent_media_message_ids, - ) - .await; - bot.delete_message(message.chat.id, message.id).await?; - let mut active = state.active_sessions.lock().await; - if active.get(&chat_id) == Some(&session.id) { - active.remove(&chat_id); - } - close_session = true; - refresh_list_view = false; - } - } - "random" => { - if matches!(&session.kind, SessionKind::List) { - if session.entries.is_empty() { - // Stay in place. - } else { - let mut remaining: Vec = (0..session.entries.len()) - .filter(|i| !session.seen_random.contains(i)) - .filter(|i| { - session - .entries - .get(*i) - .map(|entry| !peeked_snapshot.contains(&entry.block_string())) - .unwrap_or(false) - }) - .collect(); - if remaining.is_empty() { - send_ephemeral( - &bot, - message.chat.id, - "Everything's been peeked already.", - ACK_TTL_SECS, - ) - .await?; - // Stay in place. - session.view = ListView::Menu; - } else { - let index = { - let mut rng = rand::thread_rng(); - remaining.shuffle(&mut rng); - remaining.first().copied() - }; - if let Some(index) = index { - session.seen_random.insert(index); - let return_to = Box::new(session.view.clone()); - session.view = ListView::Selected { return_to, index }; - if let Some(entry) = session.entries.get(index) { - state.peeked.lock().await.insert(entry.block_string()); - } - } - } - } - } - } - "pick" => { - if let ListView::Peek { mode, page } = session.view.clone() { - let pick_index = parts.next().and_then(|p| p.parse::().ok()); - if let Some(pick_index) = pick_index { - if let Some(entry_index) = - peek_indices_for_session(&session, &peeked_snapshot, mode, page) - .get(pick_index.saturating_sub(1)) - .copied() - { - let return_to = Box::new(ListView::Peek { mode, page }); - session.view = ListView::Selected { - return_to, - index: entry_index, - }; - if matches!(&session.kind, SessionKind::List) { - if let Some(entry) = session.entries.get(entry_index) { - state.peeked.lock().await.insert(entry.block_string()); - } - } - } - } - } - } - "finish" => { - if let ListView::Selected { index, .. } = session.view.clone() { - session.view = ListView::FinishConfirm { - selected: Box::new(session.view.clone()), - index, - }; - } - } - "finish_now" => { - if let ListView::FinishConfirm { selected, index } = session.view.clone() { - let entry_block = session.entries.get(index).map(|e| e.block_string()); - if let Some(entry_block) = entry_block { - let op = QueuedOp { - kind: QueuedOpKind::MoveToFinished, - entry: entry_block.clone(), - resource_path: None, - updated_entry: None, - }; - match apply_user_op(&state, &op).await? { - UserOpOutcome::Applied(ApplyOutcome::Applied) => { - session.entries.remove(index); - if let ListView::Selected { return_to, .. } = *selected { - session.view = *return_to; - } else { - session.view = ListView::Menu; - } - normalize_peek_view(&mut session, &peeked_snapshot); - send_ephemeral(&bot, message.chat.id, "Moved.", ACK_TTL_SECS) - .await?; - let _ = - add_undo(&state, UndoKind::MoveToFinished, entry_block).await?; - } - UserOpOutcome::Applied(ApplyOutcome::NotFound) => { - send_error(&bot, message.chat.id, "Item not found.").await?; - session.view = *selected; - } - UserOpOutcome::Applied(ApplyOutcome::Duplicate) => { - session.view = *selected; - } - UserOpOutcome::Queued => { - send_error( - &bot, - message.chat.id, - "Write failed; queued for retry.", - ) - .await?; - session.view = *selected; - } - } - } - } - } - "finish_title" => { - if let ListView::FinishConfirm { selected, index } = session.view.clone() { - let selected_view = *selected; - if let Some(entry) = session.entries.get(index) { - let text = entry.display_lines().join("\n"); - let links = extract_links(&text); - if let Some(link) = links.first().cloned() { - let prompt_text = "Send a title for the finished item."; - let sent = bot.send_message(message.chat.id, prompt_text).await?; - let return_to = match selected_view.clone() { - ListView::Selected { return_to, .. } => *return_to, - _ => ListView::Menu, - }; - let prompt = FinishTitlePrompt { - session_id: session.id.clone(), - chat_id, - entry: entry.block_string(), - link, - return_to, - prompt_message_id: sent.id, - expires_at: now_ts() + FINISH_TITLE_PROMPT_TTL_SECS, - }; - let previous = state - .finish_title_prompts - .lock() - .await - .insert(chat_id, prompt); - if let Some(previous) = previous { - let _ = bot - .delete_message(message.chat.id, previous.prompt_message_id) - .await; - } - session.view = selected_view; - } else { - send_error(&bot, message.chat.id, "No link found for a title.").await?; - session.view = selected_view; - } - } else { - send_error(&bot, message.chat.id, "Item not found.").await?; - session.view = selected_view; - } - } - } - "finish_cancel" => { - if let ListView::FinishConfirm { selected, .. } = session.view.clone() { - session.view = *selected; - } - } - "resource" => { - if let ListView::Selected { index, .. } = session.view.clone() { - if let Some(entry) = session.entries.get(index) { - let text = entry.display_lines().join("\n"); - start_resource_picker(&bot, message.chat.id, &state, &text, None).await?; - refresh_list_view = false; - } else { - send_error(&bot, message.chat.id, "Item not found.").await?; - } - } - } - "delete" => { - if let ListView::Selected { index, .. } = session.view.clone() { - let expires_at = now_ts() + DELETE_CONFIRM_TTL_SECS; - session.view = ListView::DeleteConfirm { - selected: Box::new(session.view.clone()), - index, - step: 1, - expires_at, - }; - } - } - "del1" => { - if let ListView::DeleteConfirm { - selected, - index, - step: _, - expires_at, - } = session.view.clone() - { - if now_ts() > expires_at { - session.view = *selected; - send_error(&bot, message.chat.id, "Delete confirmation expired.").await?; - } else { - session.view = ListView::DeleteConfirm { - selected, - index, - step: 2, - expires_at, - }; - } - } - } - "del2" => { - if let ListView::DeleteConfirm { - selected, - index, - step: _, - expires_at, - } = session.view.clone() - { - if now_ts() > expires_at { - session.view = *selected; - send_error(&bot, message.chat.id, "Delete confirmation expired.").await?; - } else { - let entry_block = session.entries.get(index).map(|e| e.block_string()); - if let Some(entry_block) = entry_block { - let op = QueuedOp { - kind: QueuedOpKind::Delete, - entry: entry_block.clone(), - resource_path: None, - updated_entry: None, - }; - match apply_user_op(&state, &op).await? { - UserOpOutcome::Applied(ApplyOutcome::Applied) => { - session.entries.remove(index); - if let ListView::Selected { return_to, .. } = *selected { - session.view = *return_to; - } else { - session.view = ListView::Menu; - } - normalize_peek_view(&mut session, &peeked_snapshot); - let _ = add_undo(&state, UndoKind::Delete, entry_block).await?; - } - UserOpOutcome::Applied(ApplyOutcome::NotFound) => { - send_error(&bot, message.chat.id, "Item not found.").await?; - session.view = *selected; - } - UserOpOutcome::Applied(ApplyOutcome::Duplicate) => {} - UserOpOutcome::Queued => { - send_error( - &bot, - message.chat.id, - "Write failed; queued for retry.", - ) - .await?; - session.view = *selected; - } - } - } - } - } - } - "cancel_del" => { - if let ListView::DeleteConfirm { selected, .. } = session.view.clone() { - session.view = *selected; - } - } - _ => {} - } - - if close_session { - return Ok(()); - } - - if refresh_list_view { - session.message_id = Some(message.id); - let (text, kb) = - render_list_view(&session.id, &session, &peeked_snapshot, &state.config); - match bot - .edit_message_text(message.chat.id, message.id, text) - .reply_markup(kb) - .await - { - Ok(_) => {} - Err(err) if is_message_not_modified_error(&err) => {} - Err(err) => { - error!( - "list view edit failed; sending replacement message instead: {:#}", - err - ); - let (fallback_text, fallback_kb) = - render_list_view(&session.id, &session, &peeked_snapshot, &state.config); - let sent = bot - .send_message(message.chat.id, fallback_text) - .reply_markup(fallback_kb) - .await?; - session.message_id = Some(sent.id); - } - } - if let Err(err) = refresh_embedded_media_for_view( - &bot, - message.chat.id, - &state, - &mut session, - &peeked_snapshot, - ) - .await - { - error!("send embedded media failed: {:#}", err); - } - } - - Ok(()) - } - .await; - - if !close_session { - state - .sessions - .lock() - .await - .insert(session.id.clone(), session.clone()); - state - .active_sessions - .lock() - .await - .insert(chat_id, session.id.clone()); - } - - let answer_result = bot.answer_callback_query(q.id).await; - match action_result { - Ok(()) => { - answer_result?; - Ok(()) - } - Err(err) => { - if let Err(answer_err) = answer_result { - error!( - "answer callback query failed after list callback error: {:#}", - answer_err - ); - } - Err(err) - } - } -} - -fn is_message_not_modified_error(err: &teloxide::RequestError) -> bool { - err.to_string() - .to_ascii_lowercase() - .contains("message is not modified") -} - -async fn handle_picker_callback( - bot: Bot, - q: CallbackQuery, - state: std::sync::Arc, -) -> Result<()> { - let Some(message) = q.message.clone() else { - return Ok(()); - }; - let Some(data) = q.data.as_deref() else { - return Ok(()); - }; - let mut parts = data.split(':'); - let _ = parts.next(); - let picker_id = match parts.next() { - Some(id) => id.to_string(), - None => return Ok(()), - }; - let action = match parts.next() { - Some(action) => action, - None => return Ok(()), - }; - - let mut picker = { - let mut pickers = state.pickers.lock().await; - let picker = match pickers.remove(&picker_id) { - Some(picker) => picker, - None => { - bot.answer_callback_query(q.id).await?; - return Ok(()); - } - }; - if picker.chat_id != message.chat.id.0 || picker.message_id != message.id { - pickers.insert(picker_id.clone(), picker); - bot.answer_callback_query(q.id).await?; - return Ok(()); - } - picker - }; - - let mut reinsert = false; - - match action { - "toggle" => { - if let Some(index) = parts.next().and_then(|p| p.parse::().ok()) { - if index < picker.selected.len() { - picker.selected[index] = !picker.selected[index]; - } - } - let text = build_picker_text(&picker.items, &picker.selected); - let kb = build_picker_keyboard(&picker.id, &picker.selected); - bot.edit_message_text(message.chat.id, message.id, text) - .reply_markup(kb) - .await?; - reinsert = true; - } - "add" => { - let selected_items: Vec = picker - .items - .iter() - .zip(picker.selected.iter()) - .filter_map(|(item, selected)| if *selected { Some(item.clone()) } else { None }) - .collect(); - if selected_items.is_empty() { - bot.answer_callback_query(q.id) - .text("Select at least one item.") - .await?; - return Ok(()); - } - - let mut added = 0usize; - let mut duplicates = 0usize; - let mut queued = false; - for item in selected_items { - let entry = EntryBlock::from_text(&item); - let op = QueuedOp { - kind: QueuedOpKind::Add, - entry: entry.block_string(), - resource_path: None, - updated_entry: None, - }; - match apply_user_op(&state, &op).await? { - UserOpOutcome::Applied(ApplyOutcome::Applied) => added += 1, - UserOpOutcome::Applied(ApplyOutcome::Duplicate) => duplicates += 1, - UserOpOutcome::Applied(ApplyOutcome::NotFound) => {} - UserOpOutcome::Queued => queued = true, - } - } - - if queued { - send_error(&bot, message.chat.id, "Write failed; queued for retry.").await?; - } - - let summary = if duplicates > 0 { - format!( - "Saved {} item(s); {} duplicate(s) skipped.", - added, duplicates - ) - } else { - format!("Saved {} item(s).", added) - }; - send_ephemeral(&bot, message.chat.id, &summary, ACK_TTL_SECS).await?; - if !queued { - let _ = bot - .delete_message(ChatId(picker.chat_id), picker.source_message_id) - .await; - } - bot.delete_message(message.chat.id, message.id).await?; - } - "cancel" => { - bot.delete_message(message.chat.id, message.id).await?; - } - _ => {} - } - - if reinsert { - state.pickers.lock().await.insert(picker_id, picker); - } - - bot.answer_callback_query(q.id).await?; - Ok(()) -} - -async fn handle_undos_callback( - bot: Bot, - q: CallbackQuery, - state: std::sync::Arc, -) -> Result<()> { - let Some(message) = q.message.clone() else { - return Ok(()); - }; - let Some(data) = q.data.as_deref() else { - return Ok(()); - }; - - let mut parts = data.split(':'); - let _ = parts.next(); - let session_id = match parts.next() { - Some(id) => id.to_string(), - None => return Ok(()), - }; - let action = match parts.next() { - Some(action) => action, - None => return Ok(()), - }; - - let session = { - let mut sessions = state.undo_sessions.lock().await; - let session = match sessions.remove(&session_id) { - Some(session) => session, - None => { - bot.answer_callback_query(q.id).await?; - return Ok(()); - } - }; - if session.chat_id != message.chat.id.0 || session.message_id != message.id { - sessions.insert(session_id, session); - bot.answer_callback_query(q.id).await?; - return Ok(()); - } - session - }; - - match action { - "close" => { - let _ = bot.delete_message(message.chat.id, message.id).await; - bot.answer_callback_query(q.id).await?; - return Ok(()); - } - "undo" => { - let index = parts.next().and_then(|p| p.parse::().ok()); - let Some(index) = index else { - bot.answer_callback_query(q.id).await?; - return Ok(()); - }; - let Some(record) = session.records.get(index).cloned() else { - bot.answer_callback_query(q.id).await?; - return Ok(()); - }; - let op = match record.kind { - UndoKind::MoveToFinished => QueuedOp { - kind: QueuedOpKind::MoveToReadLater, - entry: record.entry, - resource_path: None, - updated_entry: None, - }, - UndoKind::Delete => QueuedOp { - kind: QueuedOpKind::Add, - entry: record.entry, - resource_path: None, - updated_entry: None, - }, - }; - - let mut undo = state.undo.lock().await; - prune_undo(&mut undo); - undo.retain(|r| r.id != record.id); - save_undo(&state.undo_path, &undo)?; - - match apply_user_op(&state, &op).await? { - UserOpOutcome::Applied(ApplyOutcome::Applied) - | UserOpOutcome::Applied(ApplyOutcome::Duplicate) - | UserOpOutcome::Applied(ApplyOutcome::NotFound) => { - send_ephemeral(&bot, message.chat.id, "Undone.", ACK_TTL_SECS).await?; - } - UserOpOutcome::Queued => { - send_error(&bot, message.chat.id, "Write failed; queued for retry.").await?; - } - } - } - "delete" => { - let index = parts.next().and_then(|p| p.parse::().ok()); - let Some(index) = index else { - bot.answer_callback_query(q.id).await?; - return Ok(()); - }; - let Some(record) = session.records.get(index).cloned() else { - bot.answer_callback_query(q.id).await?; - return Ok(()); - }; - let mut undo = state.undo.lock().await; - prune_undo(&mut undo); - undo.retain(|r| r.id != record.id); - save_undo(&state.undo_path, &undo)?; - } - _ => { - bot.answer_callback_query(q.id).await?; - return Ok(()); - } - } - - let _ = bot.delete_message(message.chat.id, message.id).await; - bot.answer_callback_query(q.id).await?; - Ok(()) -} - -async fn handle_undo_callback( - bot: Bot, - q: CallbackQuery, - state: std::sync::Arc, -) -> Result<()> { - let Some(data) = q.data.as_deref() else { - return Ok(()); - }; - let mut parts = data.trim_start_matches("undo:").split(':'); - let undo_id = parts.next().unwrap_or(""); - let action = parts.next().unwrap_or("undo"); - - let (record, undo_snapshot) = { - let mut undo = state.undo.lock().await; - prune_undo(&mut undo); - let pos = undo.iter().position(|r| r.id == undo_id); - let record = if let Some(pos) = pos { - Some(undo.remove(pos)) - } else { - None - }; - (record, undo.clone()) - }; - save_undo(&state.undo_path, &undo_snapshot)?; - - if action == "delete" { - if let Some(message) = q.message.clone() { - bot.delete_message(message.chat.id, message.id).await?; - } - bot.answer_callback_query(q.id).await?; - return Ok(()); - } - - if let Some(record) = record { - let chat_id = chat_id_from_user_id(q.from.id.0); - if record.expires_at < now_ts() { - send_error(&bot, chat_id, "Undo expired.").await?; - bot.answer_callback_query(q.id).await?; - return Ok(()); - } - - let op = match record.kind { - UndoKind::MoveToFinished => QueuedOp { - kind: QueuedOpKind::MoveToReadLater, - entry: record.entry, - resource_path: None, - updated_entry: None, - }, - UndoKind::Delete => QueuedOp { - kind: QueuedOpKind::Add, - entry: record.entry, - resource_path: None, - updated_entry: None, - }, - }; - - match apply_user_op(&state, &op).await? { - UserOpOutcome::Applied(ApplyOutcome::Applied) - | UserOpOutcome::Applied(ApplyOutcome::Duplicate) - | UserOpOutcome::Applied(ApplyOutcome::NotFound) => { - send_ephemeral(&bot, chat_id, "Undone.", ACK_TTL_SECS).await?; - } - UserOpOutcome::Queued => { - send_error(&bot, chat_id, "Write failed; queued for retry.").await?; - } - } - if let Some(message) = q.message.clone() { - let _ = bot.delete_message(message.chat.id, message.id).await; - } - } else { - send_error(&bot, chat_id_from_user_id(q.from.id.0), "Undo not found.").await?; - } - - bot.answer_callback_query(q.id).await?; - Ok(()) -} async fn apply_user_op(state: &std::sync::Arc, op: &QueuedOp) -> Result { match apply_op(state, op).await { @@ -3183,2908 +592,3 @@ async fn queue_op(state: &std::sync::Arc, op: QueuedOp) -> Result<()> save_queue(&state.queue_path, &queue) } -fn run_push(sync: &SyncConfig) -> Result { - ensure_git_available()?; - if !sync.repo_path.exists() { - return Err(anyhow!( - "Sync repo path not found: {}", - sync.repo_path.display() - )); - } - - let repo_check = run_git( - &sync.repo_path, - &["rev-parse", "--is-inside-work-tree"], - Vec::new(), - )?; - if !repo_check.status.success() || repo_check.stdout.trim() != "true" { - return Err(anyhow!( - "Sync repo path not found or not a git repository: {}", - sync.repo_path.display() - )); - } - - let token = read_token_file(&sync.token_file)?; - - let remotes = git_remote_names(&sync.repo_path)?; - let remote = if remotes.iter().any(|name| name == "origin") { - "origin".to_string() - } else { - remotes - .first() - .cloned() - .ok_or_else(|| anyhow!("Git remote not configured."))? - }; - let remote_url = git_remote_url(&sync.repo_path, &remote)?; - if !remote_url.starts_with("https://") { - return Err(anyhow!( - "Sync requires HTTPS remote for PAT auth. Remote is {}", - remote_url - )); - } - - let username = - extract_https_username(&remote_url).unwrap_or_else(|| "x-access-token".to_string()); - - let status_output = run_git(&sync.repo_path, &["status", "--porcelain"], Vec::new())?; - if !status_output.status.success() { - return Err(anyhow!(format_git_error("git status", &status_output))); - } - if status_output.stdout.trim().is_empty() { - return Ok(PushOutcome::NoChanges); - } - - let add_output = run_git(&sync.repo_path, &["add", "-A"], Vec::new())?; - if !add_output.status.success() { - return Err(anyhow!(format_git_error("git add", &add_output))); - } - - let commit_message = sync_commit_message(); - let commit_output = run_git( - &sync.repo_path, - &["commit", "-m", &commit_message], - Vec::new(), - )?; - if !commit_output.status.success() { - if is_nothing_to_commit(&commit_output) { - return Ok(PushOutcome::NoChanges); - } - return Err(anyhow!(format_git_error("git commit", &commit_output))); - } - - let branch = git_current_branch(&sync.repo_path)?; - if branch == "HEAD" { - return Err(anyhow!("Sync failed: detached HEAD.")); - } - - let askpass = create_askpass_script()?; - let askpass_path = askpass.to_string_lossy().to_string(); - let push_env = vec![ - ("GIT_TERMINAL_PROMPT", "0".to_string()), - ("GIT_ASKPASS", askpass_path), - ("GIT_SYNC_USERNAME", username), - ("GIT_SYNC_PAT", token), - ]; - let push_output = run_git( - &sync.repo_path, - &["push", &remote, &format!("HEAD:refs/heads/{}", branch)], - push_env, - )?; - if !push_output.status.success() { - return Err(anyhow!(format_git_error("git push", &push_output))); - } - - Ok(PushOutcome::Pushed) -} - -fn run_pull(sync: &SyncConfig, mode: PullMode) -> Result { - ensure_git_available()?; - if !sync.repo_path.exists() { - return Err(anyhow!( - "Sync repo path not found: {}", - sync.repo_path.display() - )); - } - - let repo_check = run_git( - &sync.repo_path, - &["rev-parse", "--is-inside-work-tree"], - Vec::new(), - )?; - if !repo_check.status.success() || repo_check.stdout.trim() != "true" { - return Err(anyhow!( - "Sync repo path not found or not a git repository: {}", - sync.repo_path.display() - )); - } - - let token = read_token_file(&sync.token_file)?; - - let remotes = git_remote_names(&sync.repo_path)?; - let remote = if remotes.iter().any(|name| name == "origin") { - "origin".to_string() - } else { - remotes - .first() - .cloned() - .ok_or_else(|| anyhow!("Git remote not configured."))? - }; - let remote_url = git_remote_url(&sync.repo_path, &remote)?; - if !remote_url.starts_with("https://") { - return Err(anyhow!( - "Sync requires HTTPS remote for PAT auth. Remote is {}", - remote_url - )); - } - - let username = - extract_https_username(&remote_url).unwrap_or_else(|| "x-access-token".to_string()); - - let status_output = run_git(&sync.repo_path, &["status", "--porcelain"], Vec::new())?; - if !status_output.status.success() { - return Err(anyhow!(format_git_error("git status", &status_output))); - } - if !status_output.stdout.trim().is_empty() { - return Err(anyhow!( - "Working tree has uncommitted changes; commit or stash before pull." - )); - } - - let branch = git_current_branch(&sync.repo_path)?; - if branch == "HEAD" { - return Err(anyhow!("Sync failed: detached HEAD.")); - } - - let askpass = create_askpass_script()?; - let askpass_path = askpass.to_string_lossy().to_string(); - let pull_env = vec![ - ("GIT_TERMINAL_PROMPT", "0".to_string()), - ("GIT_ASKPASS", askpass_path), - ("GIT_SYNC_USERNAME", username), - ("GIT_SYNC_PAT", token), - ]; - - let pull_args: Vec = match mode { - PullMode::FastForward => vec!["pull".to_string(), "--ff-only".to_string(), remote, branch], - PullMode::Theirs => vec![ - "pull".to_string(), - "--no-edit".to_string(), - "-X".to_string(), - "theirs".to_string(), - remote, - branch, - ], - }; - let pull_args_ref: Vec<&str> = pull_args.iter().map(|arg| arg.as_str()).collect(); - let pull_output = run_git(&sync.repo_path, &pull_args_ref, pull_env)?; - if !pull_output.status.success() { - return Err(anyhow!(format_git_error("git pull", &pull_output))); - } - - if is_already_up_to_date(&pull_output) { - Ok(PullOutcome::UpToDate) - } else { - Ok(PullOutcome::Pulled) - } -} - -fn run_sync(sync: &SyncConfig) -> Result { - ensure_git_available()?; - if !sync.repo_path.exists() { - return Err(anyhow!( - "Sync repo path not found: {}", - sync.repo_path.display() - )); - } - - let repo_check = run_git( - &sync.repo_path, - &["rev-parse", "--is-inside-work-tree"], - Vec::new(), - )?; - if !repo_check.status.success() || repo_check.stdout.trim() != "true" { - return Err(anyhow!( - "Sync repo path not found or not a git repository: {}", - sync.repo_path.display() - )); - } - - let token = read_token_file(&sync.token_file)?; - - let remotes = git_remote_names(&sync.repo_path)?; - let remote = if remotes.iter().any(|name| name == "origin") { - "origin".to_string() - } else { - remotes - .first() - .cloned() - .ok_or_else(|| anyhow!("Git remote not configured."))? - }; - let remote_url = git_remote_url(&sync.repo_path, &remote)?; - if !remote_url.starts_with("https://") { - return Err(anyhow!( - "Sync requires HTTPS remote for PAT auth. Remote is {}", - remote_url - )); - } - - let username = - extract_https_username(&remote_url).unwrap_or_else(|| "x-access-token".to_string()); - - let status_output = run_git(&sync.repo_path, &["status", "--porcelain"], Vec::new())?; - if !status_output.status.success() { - return Err(anyhow!(format_git_error("git status", &status_output))); - } - - let add_output = run_git(&sync.repo_path, &["add", "-A"], Vec::new())?; - if !add_output.status.success() { - return Err(anyhow!(format_git_error("git add", &add_output))); - } - - let commit_message = sync_commit_message(); - let commit_output = run_git( - &sync.repo_path, - &["commit", "-m", &commit_message], - Vec::new(), - )?; - let did_commit = if commit_output.status.success() { - true - } else if is_nothing_to_commit(&commit_output) { - false - } else { - return Err(anyhow!(format_git_error("git commit", &commit_output))); - }; - - let branch = git_current_branch(&sync.repo_path)?; - if branch == "HEAD" { - return Err(anyhow!("Sync failed: detached HEAD.")); - } - - let askpass = create_askpass_script()?; - let askpass_path = askpass.to_string_lossy().to_string(); - let auth_env = vec![ - ("GIT_TERMINAL_PROMPT", "0".to_string()), - ("GIT_ASKPASS", askpass_path), - ("GIT_SYNC_USERNAME", username), - ("GIT_SYNC_PAT", token), - ]; - - let pull_output = run_git( - &sync.repo_path, - &["pull", "--ff-only", &remote, &branch], - auth_env.clone(), - )?; - if !pull_output.status.success() { - return Err(anyhow!(format_git_error("git pull", &pull_output))); - } - let did_pull = !is_already_up_to_date(&pull_output); - - let push_output = run_git( - &sync.repo_path, - &["push", &remote, &format!("HEAD:refs/heads/{}", branch)], - auth_env, - )?; - if !push_output.status.success() { - return Err(anyhow!(format_git_error("git push", &push_output))); - } - let did_push = !is_push_up_to_date(&push_output); - - if did_commit || did_pull || did_push { - Ok(SyncOutcome::Synced) - } else { - Ok(SyncOutcome::NoChanges) - } -} - -fn run_sync_x(config: &Config, cookie_header: &str) -> Result { - let sync_x = config - .sync_x - .as_ref() - .ok_or_else(|| anyhow!("sync_x is not configured."))?; - - let source_project = &sync_x.source_project_path; - if !source_project.exists() { - return Err(anyhow!( - "sync_x source project path not found: {}", - source_project.display() - )); - } - if !source_project.is_dir() { - return Err(anyhow!( - "sync_x source project path is not a directory: {}", - source_project.display() - )); - } - - let work_dir = sync_x - .work_dir - .clone() - .unwrap_or_else(|| config.data_dir.join("sync-x")); - prepare_sync_x_workspace(source_project, &work_dir)?; - - let python_bin = resolve_sync_x_python_bin(sync_x); - let creds_path = work_dir.join("creds.txt"); - let bookmarks_path = work_dir.join("bookmarks.txt"); - let _ = fs::remove_file(&creds_path); - let _ = fs::remove_file(&bookmarks_path); - - run_python_script( - &python_bin, - &work_dir, - "isolate_cookies.py", - &[], - Some(cookie_header), - )?; - run_python_script(&python_bin, &work_dir, "main.py", &["--mode", "a"], None)?; - - let urls = if bookmarks_path.exists() { - read_sync_x_urls(&bookmarks_path)? - } else { - Vec::new() - }; - let (added_count, duplicate_count) = - prepend_urls_to_read_later_sync(&config.read_later_path, &urls)?; - - let _ = fs::remove_file(&bookmarks_path); - let _ = fs::remove_file(&creds_path); - - Ok(SyncXOutcome { - extracted_count: urls.len(), - added_count, - duplicate_count, - }) -} - -fn resolve_sync_x_python_bin(sync_x: &SyncXConfig) -> PathBuf { - if let Some(path) = &sync_x.python_bin { - return path.clone(); - } - let venv_python3 = sync_x.source_project_path.join(".venv/bin/python3"); - if venv_python3.exists() { - return venv_python3; - } - let venv_python = sync_x.source_project_path.join(".venv/bin/python"); - if venv_python.exists() { - return venv_python; - } - PathBuf::from("python3") -} - -fn prepare_sync_x_workspace(source_project: &Path, work_dir: &Path) -> Result<()> { - fs::create_dir_all(work_dir) - .with_context(|| format!("create sync_x work dir {}", work_dir.display()))?; - - for file in [ - "main.py", - "isolate_cookies.py", - "requirements.txt", - "README.md", - "LICENSE", - ] { - let src = source_project.join(file); - let dest = work_dir.join(file); - if !src.exists() { - if matches!(file, "main.py" | "isolate_cookies.py") { - return Err(anyhow!( - "sync_x source is missing required file: {}", - src.display() - )); - } - continue; - } - fs::copy(&src, &dest) - .with_context(|| format!("copy {} to {}", src.display(), dest.display()))?; - } - - Ok(()) -} - -fn run_python_script( - python_bin: &Path, - work_dir: &Path, - script: &str, - args: &[&str], - stdin_input: Option<&str>, -) -> Result<()> { - let mut cmd = Command::new(python_bin); - cmd.current_dir(work_dir) - .arg(script) - .args(args) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - - if stdin_input.is_some() { - cmd.stdin(Stdio::piped()); - } - - let mut child = cmd - .spawn() - .with_context(|| format!("run {} {}", python_bin.display(), script))?; - if let Some(input) = stdin_input { - if let Some(mut stdin) = child.stdin.take() { - stdin - .write_all(input.as_bytes()) - .context("write stdin to python script")?; - if !input.ends_with('\n') { - stdin - .write_all(b"\n") - .context("write newline to python script")?; - } - } - } - - let output = child.wait_with_output().context("wait for python script")?; - if !output.status.success() { - let stdout = String::from_utf8_lossy(&output.stdout).to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - let tail = summarize_process_output(&stdout, &stderr); - return Err(anyhow!( - "{} {} failed (status {}):\n{}", - python_bin.display(), - script, - output.status, - tail - )); - } - Ok(()) -} - -fn summarize_process_output(stdout: &str, stderr: &str) -> String { - let stderr_trimmed = stderr.trim(); - if !stderr_trimmed.is_empty() { - return trim_tail(stderr_trimmed, 1200); - } - let stdout_trimmed = stdout.trim(); - if !stdout_trimmed.is_empty() { - return trim_tail(stdout_trimmed, 1200); - } - "No output captured.".to_string() -} - -fn trim_tail(text: &str, max_chars: usize) -> String { - if text.len() <= max_chars { - return text.to_string(); - } - let mut cutoff = 0usize; - for (idx, _) in text.char_indices() { - if idx >= text.len().saturating_sub(max_chars) { - cutoff = idx; - break; - } - } - format!("...{}", &text[cutoff..]) -} - -fn read_sync_x_urls(path: &Path) -> Result> { - let contents = fs::read_to_string(path) - .with_context(|| format!("read bookmarks file {}", path.display()))?; - let mut seen = HashSet::new(); - let mut urls = Vec::new(); - for line in contents.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - if !(trimmed.starts_with("http://") || trimmed.starts_with("https://")) { - continue; - } - if seen.insert(trimmed.to_string()) { - urls.push(trimmed.to_string()); - } - } - Ok(urls) -} - -fn prepend_urls_to_read_later_sync(path: &Path, urls: &[String]) -> Result<(usize, usize)> { - let (preamble, mut entries) = read_entries(path)?; - let mut existing = HashSet::new(); - for entry in &entries { - existing.insert(entry.block_string()); - } - - let mut new_entries = Vec::new(); - let mut duplicate_count = 0usize; - for url in urls { - let entry = EntryBlock::from_text(url); - let block = entry.block_string(); - if existing.insert(block) { - new_entries.push(entry); - } else { - duplicate_count += 1; - } - } - - if !new_entries.is_empty() { - for entry in new_entries.iter().rev() { - entries.insert(0, entry.clone()); - } - write_entries(path, &preamble, &entries)?; - } - - Ok((new_entries.len(), duplicate_count)) -} - -struct GitOutput { - status: std::process::ExitStatus, - stdout: String, - stderr: String, -} - -fn run_git(repo_path: &Path, args: &[&str], envs: Vec<(&str, String)>) -> Result { - let mut cmd = Command::new("git"); - cmd.current_dir(repo_path).args(args); - for (key, value) in envs { - cmd.env(key, value); - } - let output = cmd - .output() - .with_context(|| format!("run git command: git {}", args.join(" ")))?; - Ok(GitOutput { - status: output.status, - stdout: String::from_utf8_lossy(&output.stdout).to_string(), - stderr: String::from_utf8_lossy(&output.stderr).to_string(), - }) -} - -fn ensure_git_available() -> Result<()> { - match Command::new("git").arg("--version").output() { - Ok(output) => { - if output.status.success() { - Ok(()) - } else { - Err(anyhow!("Git unavailable: git --version failed.")) - } - } - Err(_) => Err(anyhow!( - "Git is not available in PATH. Add git to the service path." - )), - } -} - -fn format_git_error(action: &str, output: &GitOutput) -> String { - let mut message = format!("{} failed.", action); - let stdout = output.stdout.trim(); - let stderr = output.stderr.trim(); - if !stdout.is_empty() { - message.push_str("\nstdout:\n"); - message.push_str(stdout); - } - if !stderr.is_empty() { - message.push_str("\nstderr:\n"); - message.push_str(stderr); - } - message -} - -fn git_remote_names(repo_path: &Path) -> Result> { - let output = run_git(repo_path, &["remote"], Vec::new())?; - if !output.status.success() { - return Err(anyhow!(format_git_error("git remote", &output))); - } - let names = output - .stdout - .lines() - .map(|line| line.trim().to_string()) - .filter(|line| !line.is_empty()) - .collect::>(); - Ok(names) -} - -fn git_remote_url(repo_path: &Path, remote: &str) -> Result { - let output = run_git(repo_path, &["remote", "get-url", remote], Vec::new())?; - if !output.status.success() { - return Err(anyhow!(format_git_error("git remote get-url", &output))); - } - Ok(output.stdout.trim().to_string()) -} - -fn git_current_branch(repo_path: &Path) -> Result { - let output = run_git( - repo_path, - &["rev-parse", "--abbrev-ref", "HEAD"], - Vec::new(), - )?; - if !output.status.success() { - return Err(anyhow!(format_git_error("git rev-parse", &output))); - } - Ok(output.stdout.trim().to_string()) -} - -fn read_token_file(path: &Path) -> Result { - let token = match fs::read_to_string(path) { - Ok(token) => token, - Err(_) => { - return Err(anyhow!("Sync requires PAT in settings.sync.token_file.")); - } - }; - let token = token.trim().to_string(); - if token.is_empty() { - return Err(anyhow!("Sync requires PAT in settings.sync.token_file.")); - } - Ok(token) -} - -fn extract_https_username(remote_url: &str) -> Option { - if !remote_url.starts_with("https://") { - return None; - } - let without_scheme = &remote_url["https://".len()..]; - let slash_pos = without_scheme.find('/').unwrap_or(without_scheme.len()); - let authority = &without_scheme[..slash_pos]; - let userinfo = authority.split('@').next()?; - if !authority.contains('@') { - return None; - } - let username = userinfo.split(':').next().unwrap_or(""); - if username.is_empty() { - None - } else { - Some(username.to_string()) - } -} - -fn is_nothing_to_commit(output: &GitOutput) -> bool { - let combined = format!("{}\n{}", output.stdout, output.stderr).to_lowercase(); - combined.contains("nothing to commit") - || combined.contains("no changes added to commit") - || combined.contains("working tree clean") -} - -fn is_already_up_to_date(output: &GitOutput) -> bool { - let combined = format!("{}\n{}", output.stdout, output.stderr).to_lowercase(); - combined.contains("already up to date") || combined.contains("already up-to-date") -} - -fn is_push_up_to_date(output: &GitOutput) -> bool { - let combined = format!("{}\n{}", output.stdout, output.stderr).to_lowercase(); - combined.contains("everything up-to-date") || combined.contains("everything up to date") -} - -fn parse_pull_mode(rest: &str) -> std::result::Result { - let option = rest.trim(); - if option.is_empty() { - return Ok(PullMode::FastForward); - } - if option.eq_ignore_ascii_case("theirs") { - return Ok(PullMode::Theirs); - } - Err("Unknown pull option. Use /pull or /pull theirs.".to_string()) -} - -fn sync_commit_message() -> String { - format!("Bot sync {}", Local::now().format("%Y-%m-%d %H:%M:%S")) -} - -fn create_askpass_script() -> Result { - let mut file = NamedTempFile::new().context("create askpass script")?; - file.write_all( - b"#!/bin/sh\ncase \"$1\" in\n*Username*) echo \"$GIT_SYNC_USERNAME\" ;;\n*Password*) echo \"$GIT_SYNC_PAT\" ;;\n*) echo \"\" ;;\nesac\n", - ) - .context("write askpass script")?; - let mut perms = file.as_file().metadata()?.permissions(); - perms.set_mode(0o700); - fs::set_permissions(file.path(), perms).context("chmod askpass script")?; - Ok(file.into_temp_path()) -} - -fn split_items(text: &str) -> Vec { - text.split("---") - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) - .collect() -} - -async fn download_and_send_link( - bot: &Bot, - chat_id: ChatId, - link: &str, - format_selector: &str, -) -> Result<()> { - let temp_dir = TempDir::new().context("create download temp dir")?; - let target_dir = temp_dir.path().to_path_buf(); - let link = link.to_string(); - let format_selector = format_selector.to_string(); - let path = tokio::task::spawn_blocking(move || { - run_ytdlp_download(&target_dir, &link, &format_selector) - }) - .await - .context("yt-dlp task failed")??; - bot.send_document(chat_id, InputFile::file(path)).await?; - Ok(()) -} - -async fn download_and_save_link( - state: &std::sync::Arc, - link: &str, - format_selector: &str, -) -> Result { - let target_dir = state.config.media_dir.clone(); - fs::create_dir_all(&target_dir) - .with_context(|| format!("create media dir {}", target_dir.display()))?; - let link = link.to_string(); - let format_selector = format_selector.to_string(); - let path = tokio::task::spawn_blocking(move || { - run_ytdlp_download(&target_dir, &link, &format_selector) - }) - .await - .context("yt-dlp task failed")??; - if !path.exists() { - return Err(anyhow!("Download completed but file is missing.")); - } - Ok(path) -} - -fn run_ytdlp_list_formats(link: &str) -> Result> { - let output = Command::new("yt-dlp") - .arg("--no-playlist") - .arg("-J") - .arg(link) - .output() - .context("run yt-dlp")?; - if !output.status.success() { - return Err(anyhow!(format_ytdlp_error(&output))); - } - let value: serde_json::Value = - serde_json::from_slice(&output.stdout).context("parse yt-dlp json")?; - let mut options = vec![DownloadQualityOption { - label: "Best".to_string(), - format_selector: "bestvideo+bestaudio/best".to_string(), - }]; - - let Some(formats) = value.get("formats").and_then(|v| v.as_array()) else { - return Ok(options); - }; - - let mut by_height: HashMap, bool)> = HashMap::new(); - let mut best_audio: Option<(String, String, Option, Option)> = None; - - for format in formats { - let Some(format_id) = format.get("format_id").and_then(|v| v.as_str()) else { - continue; - }; - let vcodec = format - .get("vcodec") - .and_then(|v| v.as_str()) - .unwrap_or("none"); - let acodec = format - .get("acodec") - .and_then(|v| v.as_str()) - .unwrap_or("none"); - let ext = format - .get("ext") - .and_then(|v| v.as_str()) - .unwrap_or("unknown") - .to_string(); - let filesize = format - .get("filesize") - .and_then(|v| v.as_u64()) - .or_else(|| format.get("filesize_approx").and_then(|v| v.as_u64())); - - if vcodec == "none" && acodec != "none" { - let abr = format.get("abr").and_then(|v| v.as_f64()); - match &best_audio { - Some((_, _, existing_size, existing_abr)) => { - let better_abr = abr.unwrap_or(0.0) > existing_abr.unwrap_or(0.0); - let better_size = filesize.unwrap_or(0) > existing_size.unwrap_or(0); - if better_abr || better_size { - best_audio = Some((format_id.to_string(), ext, filesize, abr)); - } - } - None => { - best_audio = Some((format_id.to_string(), ext, filesize, abr)); - } - } - continue; - } - - if vcodec == "none" { - continue; - } - - let Some(height) = format.get("height").and_then(|v| v.as_i64()) else { - continue; - }; - if height <= 0 { - continue; - } - - let has_audio = acodec != "none"; - let selector = if has_audio { - format_id.to_string() - } else { - format!("{}+bestaudio/best", format_id) - }; - let candidate = (selector, ext, filesize, has_audio); - match by_height.get(&height) { - Some((_, _, existing_size, existing_has_audio)) => { - let better_audio = has_audio && !existing_has_audio; - let better_size = filesize.unwrap_or(0) > existing_size.unwrap_or(0); - if better_audio || better_size { - by_height.insert(height, candidate); - } - } - None => { - by_height.insert(height, candidate); - } - } - } - - let mut heights: Vec = by_height.keys().copied().collect(); - heights.sort_by(|a, b| b.cmp(a)); - for height in heights.into_iter().take(6) { - if let Some((selector, ext, size, has_audio)) = by_height.get(&height) { - let mut label = format!("{}p {}", height, ext); - if !has_audio { - label.push_str(" (video-only source)"); - } - if let Some(size) = size { - label.push_str(&format!(" ({})", human_size(*size))); - } - options.push(DownloadQualityOption { - label, - format_selector: selector.clone(), - }); - } - } - - if let Some((format_id, ext, size, _abr)) = best_audio { - let mut label = format!("Audio only ({})", ext); - if let Some(size) = size { - label.push_str(&format!(" ({})", human_size(size))); - } - options.push(DownloadQualityOption { - label, - format_selector: format_id, - }); - } - - Ok(options) -} - -fn human_size(bytes: u64) -> String { - const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"]; - let mut value = bytes as f64; - let mut unit = 0usize; - while value >= 1024.0 && unit < UNITS.len() - 1 { - value /= 1024.0; - unit += 1; - } - if unit == 0 { - format!("{} {}", bytes, UNITS[unit]) - } else { - format!("{:.1} {}", value, UNITS[unit]) - } -} - -fn run_ytdlp_download(target_dir: &Path, link: &str, format_selector: &str) -> Result { - let template = target_dir.join("%(title).200B-%(id)s.%(ext)s"); - let output = Command::new("yt-dlp") - .arg("--no-playlist") - .arg("-f") - .arg(format_selector) - .arg("--print") - .arg("after_move:filepath") - .arg("-o") - .arg(template.to_string_lossy().to_string()) - .arg(link) - .output() - .context("run yt-dlp")?; - if !output.status.success() { - return Err(anyhow!(format_ytdlp_error(&output))); - } - let stdout = String::from_utf8_lossy(&output.stdout); - let path_line = stdout - .lines() - .rev() - .find(|line| !line.trim().is_empty()) - .ok_or_else(|| anyhow!("yt-dlp did not return a filepath"))?; - let mut path = PathBuf::from(path_line.trim()); - if path.is_relative() { - path = target_dir.join(path); - } - if !path.exists() { - return Err(anyhow!("yt-dlp output not found: {}", path.display())); - } - Ok(path) -} - -fn format_ytdlp_error(output: &std::process::Output) -> String { - let mut message = "yt-dlp failed.".to_string(); - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - if !stdout.is_empty() { - message.push_str("\nstdout:\n"); - message.push_str(&stdout); - } - if !stderr.is_empty() { - message.push_str("\nstderr:\n"); - message.push_str(&stderr); - } - message -} - -fn search_entries(entries: &[EntryBlock], query: &str) -> Vec { - entries - .iter() - .filter(|entry| matches_query(entry, query)) - .cloned() - .collect() -} - -fn matches_query(entry: &EntryBlock, query: &str) -> bool { - let needle = query.trim().to_lowercase(); - if needle.is_empty() { - return false; - } - let haystack = entry.display_lines().join("\n").to_lowercase(); - needle - .split_whitespace() - .all(|term| haystack.contains(term)) -} - -#[cfg(test)] -fn displayed_indices_for_view(session: &ListSession, peeked: &HashSet) -> Vec { - match session.view { - ListView::Peek { mode, page } => peek_indices_for_session(session, peeked, mode, page), - ListView::Selected { index, .. } => vec![index], - ListView::FinishConfirm { index, .. } => vec![index], - ListView::DeleteConfirm { index, .. } => vec![index], - _ => Vec::new(), - } -} - -fn embedded_lines_for_view(session: &ListSession, peeked: &HashSet) -> Vec { - match session.view { - ListView::Peek { mode, page } => peek_indices_for_session(session, peeked, mode, page) - .into_iter() - .filter_map(|index| session.entries.get(index)) - .flat_map(|entry| entry.preview_lines()) - .collect(), - ListView::Selected { index, .. } => session - .entries - .get(index) - .map(|entry| entry.display_lines()) - .unwrap_or_default(), - ListView::FinishConfirm { index, .. } | ListView::DeleteConfirm { index, .. } => session - .entries - .get(index) - .map(|entry| entry.preview_lines()) - .unwrap_or_default(), - _ => Vec::new(), - } -} - -fn norm_target_index(session: &ListSession, peeked: &HashSet) -> Option { - match &session.view { - ListView::Selected { index, .. } => Some(*index), - ListView::FinishConfirm { index, .. } => Some(*index), - ListView::Peek { mode, page } => { - let indices = peek_indices_for_session(session, peeked, *mode, *page); - if indices.len() == 1 { - indices.first().copied() - } else { - None - } - } - _ => None, - } -} - -fn normalize_entry_markdown_links(entry: &EntryBlock) -> Option { - let mut changed = false; - let mut lines = Vec::with_capacity(entry.lines.len()); - for line in &entry.lines { - let (normalized, line_changed) = normalize_markdown_links(line); - if line_changed { - changed = true; - } - lines.push(normalized); - } - if changed { - Some(EntryBlock { lines }) - } else { - None - } -} - -fn normalize_markdown_links(text: &str) -> (String, bool) { - if !text.contains('[') { - return (text.to_string(), false); - } - - let mut out = String::with_capacity(text.len()); - let mut index = 0; - let mut changed = false; - - while let Some(start_rel) = text[index..].find('[') { - let start = index + start_rel; - out.push_str(&text[index..start]); - - let label_start = start + 1; - let Some(label_end_rel) = text[label_start..].find(']') else { - out.push_str(&text[start..]); - return (out, changed); - }; - let label_end = label_start + label_end_rel; - let after_label = label_end + 1; - if after_label >= text.len() || !text[after_label..].starts_with('(') { - out.push_str(&text[start..after_label]); - index = after_label; - continue; - } - - let url_start = after_label + 1; - let Some(url_end_rel) = text[url_start..].find(')') else { - out.push_str(&text[start..]); - return (out, changed); - }; - let url_end = url_start + url_end_rel; - out.push_str(&text[url_start..url_end]); - changed = true; - index = url_end + 1; - } - - out.push_str(&text[index..]); - (out, changed) -} - -fn extract_links(text: &str) -> Vec { - let mut links = Vec::new(); - let mut seen: HashSet = HashSet::new(); - - let mut index = 0; - while let Some(start_rel) = text[index..].find('[') { - let start = index + start_rel; - let label_start = start + 1; - let Some(label_end_rel) = text[label_start..].find(']') else { - break; - }; - let label_end = label_start + label_end_rel; - let after_label = label_end + 1; - if after_label >= text.len() || !text[after_label..].starts_with('(') { - index = after_label; - continue; - } - let url_start = after_label + 1; - let Some(url_end_rel) = text[url_start..].find(')') else { - break; - }; - let url_end = url_start + url_end_rel; - let url = text[url_start..url_end].trim(); - if is_http_link(url) { - push_link(&mut links, &mut seen, url.to_string()); - } - index = url_end + 1; - } - - let mut scan = 0; - while scan < text.len() { - let slice = &text[scan..]; - let http_pos = slice.find("http://"); - let https_pos = slice.find("https://"); - let pos = match (http_pos, https_pos) { - (Some(a), Some(b)) => Some(a.min(b)), - (Some(a), None) => Some(a), - (None, Some(b)) => Some(b), - (None, None) => None, - }; - let Some(pos) = pos else { - break; - }; - let start = scan + pos; - let rest = &text[start..]; - let end_rel = rest.find(|c: char| c.is_whitespace()).unwrap_or(rest.len()); - let end = start + end_rel; - let mut url = text[start..end].to_string(); - url = trim_link(&url); - if is_http_link(&url) { - push_link(&mut links, &mut seen, url); - } - scan = end; - } - - links -} - -fn is_http_link(link: &str) -> bool { - link.starts_with("http://") || link.starts_with("https://") -} - -fn push_link(links: &mut Vec, seen: &mut HashSet, link: String) { - if seen.insert(link.clone()) { - links.push(link); - } -} - -fn trim_link(link: &str) -> String { - link.trim() - .trim_end_matches(|c: char| ")]}>\"'.,;:!?".contains(c)) - .to_string() -} - -fn entry_with_title(entry: &str, title: &str, link: &str) -> String { - let mut entry = EntryBlock::from_block(entry); - let line = format!("- [{}]({})", title.trim(), link); - if entry.lines.is_empty() { - entry.lines.push(line); - } else { - entry.lines[0] = line; - } - entry.block_string() -} - -fn build_picker_text(items: &[String], selected: &[bool]) -> String { - let mut text = String::from("Select items to save:\n\n"); - for (idx, item) in items.iter().enumerate() { - let marker = if selected.get(idx).copied().unwrap_or(false) { - "[x]" - } else { - "[ ]" - }; - let preview = preview_text(item); - text.push_str(&format!("{} {}\n", idx + 1, marker)); - if let Some(first) = preview.get(0) { - text.push_str(&format!("{}\n", first)); - } - if let Some(second) = preview.get(1) { - text.push_str(&format!("{}\n", second)); - } - text.push('\n'); - } - text.trim_end().to_string() -} - -fn build_picker_keyboard(picker_id: &str, selected: &[bool]) -> InlineKeyboardMarkup { - let mut rows = Vec::new(); - for (idx, is_selected) in selected.iter().enumerate() { - let label = if *is_selected { - format!("{} [x]", idx + 1) - } else { - format!("{} [ ]", idx + 1) - }; - let data = format!("pick:{}:toggle:{}", picker_id, idx); - rows.push(vec![InlineKeyboardButton::callback(label, data)]); - } - rows.push(vec![ - InlineKeyboardButton::callback("Add selected", format!("pick:{}:add", picker_id)), - InlineKeyboardButton::callback("Cancel", format!("pick:{}:cancel", picker_id)), - ]); - InlineKeyboardMarkup::new(rows) -} - -fn build_add_prompt_keyboard(prompt_id: &str) -> InlineKeyboardMarkup { - InlineKeyboardMarkup::new(vec![ - vec![ - InlineKeyboardButton::callback("Reading list", format!("add:{}:normal", prompt_id)), - InlineKeyboardButton::callback("Resource", format!("add:{}:resource", prompt_id)), - ], - vec![InlineKeyboardButton::callback( - "Cancel", - format!("add:{}:cancel", prompt_id), - )], - ]) -} - -fn build_resource_picker_keyboard(picker_id: &str, files: &[PathBuf]) -> InlineKeyboardMarkup { - let mut rows: Vec> = Vec::new(); - let mut current_row = Vec::new(); - for (idx, path) in files.iter().enumerate() { - let label = path - .file_name() - .and_then(|name| name.to_str()) - .map(|name| name.to_string()) - .unwrap_or_else(|| path.to_string_lossy().to_string()); - current_row.push(InlineKeyboardButton::callback( - label, - format!("res:{}:file:{}", picker_id, idx), - )); - if current_row.len() == 2 { - rows.push(std::mem::take(&mut current_row)); - } - } - if !current_row.is_empty() { - rows.push(current_row); - } - rows.push(vec![InlineKeyboardButton::callback( - "New file", - format!("res:{}:new", picker_id), - )]); - rows.push(vec![InlineKeyboardButton::callback( - "Cancel", - format!("res:{}:cancel", picker_id), - )]); - InlineKeyboardMarkup::new(rows) -} - -fn build_download_picker_text(links: &[String]) -> String { - if links.is_empty() { - return "No links found. Add one?".to_string(); - } - let mut text = String::from("Links:\n\n"); - for (idx, link) in links.iter().enumerate() { - text.push_str(&format!("{}: {}\n", idx + 1, link)); - } - text.trim_end().to_string() -} - -fn build_download_quality_text( - link: &str, - action: DownloadAction, - options: &[DownloadQualityOption], -) -> String { - let action_label = match action { - DownloadAction::Send => "send", - DownloadAction::Save => "save", - }; - let mut text = format!("Choose quality to {}:\n{}\n\n", action_label, link); - for (idx, option) in options.iter().enumerate() { - text.push_str(&format!("{}: {}\n", idx + 1, option.label)); - } - text.trim_end().to_string() -} - -fn build_download_picker_keyboard(picker_id: &str, links: &[String]) -> InlineKeyboardMarkup { - let mut rows = Vec::new(); - for (idx, _) in links.iter().enumerate() { - rows.push(vec![ - InlineKeyboardButton::callback( - format!("Send {}", idx + 1), - format!("dl:{}:send:{}", picker_id, idx), - ), - InlineKeyboardButton::callback( - format!("Save {}", idx + 1), - format!("dl:{}:save:{}", picker_id, idx), - ), - ]); - } - rows.push(vec![InlineKeyboardButton::callback( - "Add link", - format!("dl:{}:add", picker_id), - )]); - rows.push(vec![InlineKeyboardButton::callback( - "Cancel", - format!("dl:{}:cancel", picker_id), - )]); - InlineKeyboardMarkup::new(rows) -} - -fn build_download_quality_keyboard( - picker_id: &str, - options: &[DownloadQualityOption], -) -> InlineKeyboardMarkup { - let mut rows = Vec::new(); - for (idx, option) in options.iter().enumerate() { - rows.push(vec![InlineKeyboardButton::callback( - option.label.clone(), - format!("dl:{}:quality:{}", picker_id, idx), - )]); - } - rows.push(vec![InlineKeyboardButton::callback( - "Back", - format!("dl:{}:back", picker_id), - )]); - rows.push(vec![InlineKeyboardButton::callback( - "Cancel", - format!("dl:{}:cancel", picker_id), - )]); - InlineKeyboardMarkup::new(rows) -} - -fn render_list_view( - session_id: &str, - session: &ListSession, - peeked: &HashSet, - config: &Config, -) -> (String, InlineKeyboardMarkup) { - match &session.view { - ListView::Menu => build_menu_view(session_id, session), - ListView::Peek { mode, page } => { - build_peek_view(session_id, session, *mode, *page, peeked, config) - } - ListView::Selected { index, .. } => { - build_selected_view(session_id, session, *index, config) - } - ListView::FinishConfirm { index, .. } => { - build_finish_confirm_view(session_id, session, *index, config) - } - ListView::DeleteConfirm { step, index, .. } => { - build_delete_confirm_view(session_id, session, *index, *step, config) - } - } -} - -fn build_menu_view(session_id: &str, session: &ListSession) -> (String, InlineKeyboardMarkup) { - let count = session.entries.len(); - match &session.kind { - SessionKind::List => { - let text = if count == 0 { - "Read Later is empty.".to_string() - } else { - "Choose Top, Bottom, or Random.".to_string() - }; - - let mut rows = Vec::new(); - if count > 0 { - rows.push(vec![ - InlineKeyboardButton::callback( - format!("Top ({})", count), - format!("ls:{}:top:0", session_id), - ), - InlineKeyboardButton::callback( - format!("Bottom ({})", count), - format!("ls:{}:bottom:0", session_id), - ), - ]); - rows.push(vec![InlineKeyboardButton::callback( - "Random", - format!("ls:{}:random", session_id), - )]); - } - - (text, InlineKeyboardMarkup::new(rows)) - } - SessionKind::Search { query } => { - let text = if count == 0 { - format!("No matches for \"{}\".", query) - } else { - format!("Matches for \"{}\" ({}).", query, count) - }; - - let mut rows = Vec::new(); - if count > 0 { - rows.push(vec![InlineKeyboardButton::callback( - "Show", - format!("ls:{}:top:0", session_id), - )]); - } - rows.push(vec![InlineKeyboardButton::callback( - "Close", - format!("ls:{}:close", session_id), - )]); - - (text, InlineKeyboardMarkup::new(rows)) - } - } -} - -fn build_peek_view( - session_id: &str, - session: &ListSession, - mode: ListMode, - page: usize, - peeked: &HashSet, - config: &Config, -) -> (String, InlineKeyboardMarkup) { - let total_unpeeked = count_visible_entries(session, peeked); - let indices = peek_indices_for_session(session, peeked, mode, page); - let total_pages = if total_unpeeked == 0 { - 0 - } else { - (total_unpeeked + PAGE_SIZE - 1) / PAGE_SIZE - }; - let mut text = match &session.kind { - SessionKind::List => { - let title = match mode { - ListMode::Top => "Top view", - ListMode::Bottom => "Bottom view", - }; - let page_display = if total_pages == 0 { 0 } else { page + 1 }; - format!("{} (page {})\n", title, page_display) - } - SessionKind::Search { query } => { - if total_pages > 0 { - format!( - "Matches for \"{}\" (page {}/{})\n", - query, - page + 1, - total_pages - ) - } else { - format!("Matches for \"{}\"\n", query) - } - } - }; - if total_unpeeked == 0 { - text.push_str("Everything's been peeked already."); - } else if indices.is_empty() { - text.push_str("No items on this page."); - } else { - for (display_index, entry_index) in indices.iter().enumerate() { - if let Some(entry) = session.entries.get(*entry_index) { - let preview = format_embedded_references_for_lines(&entry.preview_lines(), config); - text.push_str(&format!("{}) ", display_index + 1)); - if let Some(first) = preview.get(0) { - text.push_str(first); - } - text.push('\n'); - if let Some(second) = preview.get(1) { - text.push_str(" "); - text.push_str(second); - text.push('\n'); - } - } - } - } - - let mut rows = Vec::new(); - if !indices.is_empty() { - let mut pick_row = Vec::new(); - for i in 0..indices.len() { - pick_row.push(InlineKeyboardButton::callback( - format!("{}", i + 1), - format!("ls:{}:pick:{}", session_id, i + 1), - )); - } - rows.push(pick_row); - } - - rows.push(vec![ - InlineKeyboardButton::callback("Prev", format!("ls:{}:prev", session_id)), - InlineKeyboardButton::callback("Next", format!("ls:{}:next", session_id)), - ]); - match &session.kind { - SessionKind::List => { - rows.push(vec![ - InlineKeyboardButton::callback("Back", format!("ls:{}:back", session_id)), - InlineKeyboardButton::callback("Random", format!("ls:{}:random", session_id)), - ]); - } - SessionKind::Search { .. } => { - rows.push(vec![InlineKeyboardButton::callback( - "Close", - format!("ls:{}:close", session_id), - )]); - } - } - - (text.trim_end().to_string(), InlineKeyboardMarkup::new(rows)) -} - -fn build_selected_view( - session_id: &str, - session: &ListSession, - index: usize, - config: &Config, -) -> (String, InlineKeyboardMarkup) { - let entry = session.entries.get(index); - let text = if let Some(entry) = entry { - let lines = format_embedded_references_for_lines(&entry.display_lines(), config); - format!("Selected item:\n\n{}", lines.join("\n")) - } else { - "Selected item not found.".to_string() - }; - - let rows = match &session.kind { - SessionKind::List => vec![ - vec![ - InlineKeyboardButton::callback( - "Mark Finished", - format!("ls:{}:finish", session_id), - ), - InlineKeyboardButton::callback( - "Add Resource", - format!("ls:{}:resource", session_id), - ), - ], - vec![ - InlineKeyboardButton::callback("Delete", format!("ls:{}:delete", session_id)), - InlineKeyboardButton::callback("Random", format!("ls:{}:random", session_id)), - ], - vec![InlineKeyboardButton::callback( - "Back", - format!("ls:{}:back", session_id), - )], - ], - SessionKind::Search { .. } => vec![ - vec![InlineKeyboardButton::callback( - "Add Resource", - format!("ls:{}:resource", session_id), - )], - vec![InlineKeyboardButton::callback( - "Delete", - format!("ls:{}:delete", session_id), - )], - vec![InlineKeyboardButton::callback( - "Back", - format!("ls:{}:back", session_id), - )], - ], - }; - - (text, InlineKeyboardMarkup::new(rows)) -} - -fn build_undos_view(session_id: &str, records: &[UndoRecord]) -> (String, InlineKeyboardMarkup) { - let mut text = format!("Undos ({})\n\n", records.len()); - for (idx, record) in records.iter().enumerate() { - let label = match record.kind { - UndoKind::MoveToFinished => "Moved to finished", - UndoKind::Delete => "Deleted", - }; - text.push_str(&format!("{}) {}\n", idx + 1, label)); - let preview = undo_preview(&record.entry); - if let Some(first) = preview.get(0) { - text.push_str(" "); - text.push_str(first); - text.push('\n'); - } - if let Some(second) = preview.get(1) { - text.push_str(" "); - text.push_str(second); - text.push('\n'); - } - text.push('\n'); - } - - let mut rows = Vec::new(); - for (idx, _) in records.iter().enumerate() { - rows.push(vec![ - InlineKeyboardButton::callback( - format!("Undo {}", idx + 1), - format!("undos:{}:undo:{}", session_id, idx), - ), - InlineKeyboardButton::callback( - format!("Delete {}", idx + 1), - format!("undos:{}:delete:{}", session_id, idx), - ), - ]); - } - rows.push(vec![InlineKeyboardButton::callback( - "Close", - format!("undos:{}:close", session_id), - )]); - - (text.trim_end().to_string(), InlineKeyboardMarkup::new(rows)) -} - -fn build_finish_confirm_view( - session_id: &str, - session: &ListSession, - index: usize, - config: &Config, -) -> (String, InlineKeyboardMarkup) { - let entry = session.entries.get(index); - let preview = entry - .map(|e| format_embedded_references_for_lines(&e.preview_lines(), config)) - .unwrap_or_default(); - let mut text = String::from("Finish this item?\n\n"); - if let Some(first) = preview.get(0) { - text.push_str(first); - text.push('\n'); - } - if let Some(second) = preview.get(1) { - text.push_str(second); - text.push('\n'); - } - - let rows = vec![ - vec![InlineKeyboardButton::callback( - "Finish", - format!("ls:{}:finish_now", session_id), - )], - vec![InlineKeyboardButton::callback( - "Finish + Title", - format!("ls:{}:finish_title", session_id), - )], - vec![InlineKeyboardButton::callback( - "Cancel", - format!("ls:{}:finish_cancel", session_id), - )], - ]; - - (text.trim_end().to_string(), InlineKeyboardMarkup::new(rows)) -} - -fn build_delete_confirm_view( - session_id: &str, - session: &ListSession, - index: usize, - step: u8, - config: &Config, -) -> (String, InlineKeyboardMarkup) { - let entry = session.entries.get(index); - let preview = entry - .map(|e| format_embedded_references_for_lines(&e.preview_lines(), config)) - .unwrap_or_default(); - let mut text = format!("Confirm delete ({}/2)?\n\n", step); - if let Some(first) = preview.get(0) { - text.push_str(first); - text.push('\n'); - } - if let Some(second) = preview.get(1) { - text.push_str(second); - text.push('\n'); - } - - let confirm_action = if step == 1 { "del1" } else { "del2" }; - let rows = vec![ - vec![InlineKeyboardButton::callback( - "Confirm", - format!("ls:{}:{}", session_id, confirm_action), - )], - vec![InlineKeyboardButton::callback( - "Cancel", - format!("ls:{}:cancel_del", session_id), - )], - ]; - - (text.trim_end().to_string(), InlineKeyboardMarkup::new(rows)) -} - -fn count_unpeeked_entries(entries: &[EntryBlock], peeked: &HashSet) -> usize { - entries - .iter() - .filter(|entry| !peeked.contains(&entry.block_string())) - .count() -} - -fn count_visible_entries(session: &ListSession, peeked: &HashSet) -> usize { - match session.kind { - SessionKind::Search { .. } => session.entries.len(), - SessionKind::List => count_unpeeked_entries(&session.entries, peeked), - } -} - -fn ordered_unpeeked_indices( - entries: &[EntryBlock], - peeked: &HashSet, - mode: ListMode, -) -> Vec { - let mut indices: Vec = entries - .iter() - .enumerate() - .filter(|(_, entry)| !peeked.contains(&entry.block_string())) - .map(|(idx, _)| idx) - .collect(); - if matches!(mode, ListMode::Bottom) { - indices.reverse(); - } - indices -} - -fn ordered_indices(entries: &[EntryBlock], mode: ListMode) -> Vec { - let mut indices: Vec = (0..entries.len()).collect(); - if matches!(mode, ListMode::Bottom) { - indices.reverse(); - } - indices -} - -fn peek_indices( - entries: &[EntryBlock], - peeked: &HashSet, - mode: ListMode, - page: usize, -) -> Vec { - let ordered = ordered_unpeeked_indices(entries, peeked, mode); - if ordered.is_empty() { - return Vec::new(); - } - let start = page * PAGE_SIZE; - if start >= ordered.len() { - return Vec::new(); - } - let end = (start + PAGE_SIZE).min(ordered.len()); - ordered[start..end].to_vec() -} - -fn peek_indices_all(entries: &[EntryBlock], mode: ListMode, page: usize) -> Vec { - let ordered = ordered_indices(entries, mode); - if ordered.is_empty() { - return Vec::new(); - } - let start = page * PAGE_SIZE; - if start >= ordered.len() { - return Vec::new(); - } - let end = (start + PAGE_SIZE).min(ordered.len()); - ordered[start..end].to_vec() -} - -fn peek_indices_for_session( - session: &ListSession, - peeked: &HashSet, - mode: ListMode, - page: usize, -) -> Vec { - match session.kind { - SessionKind::Search { .. } => peek_indices_all(&session.entries, mode, page), - SessionKind::List => peek_indices(&session.entries, peeked, mode, page), - } -} - -fn normalize_peek_view(session: &mut ListSession, peeked: &HashSet) { - if let ListView::Peek { mode, page } = session.view.clone() { - let indices = peek_indices_for_session(session, peeked, mode, page); - if indices.is_empty() && page > 0 { - session.view = ListView::Peek { - mode, - page: page.saturating_sub(1), - }; - } - } -} - -fn preview_text(text: &str) -> Vec { - let normalized = normalize_line_endings(text); - let lines: Vec<&str> = normalized.lines().collect(); - let mut out = Vec::new(); - if let Some(first) = lines.get(0) { - out.push(first.to_string()); - } - if let Some(second) = lines.get(1) { - out.push(second.to_string()); - } - if lines.len() > 2 { - if let Some(last) = out.last_mut() { - last.push_str("..."); - } - } - out -} - -fn undo_preview(entry: &str) -> Vec { - let entry = EntryBlock::from_block(entry); - entry.preview_lines() -} - -async fn send_ephemeral(bot: &Bot, chat_id: ChatId, text: &str, ttl_secs: u64) -> Result<()> { - let sent = bot.send_message(chat_id, text).await?; - let bot = bot.clone(); - tokio::spawn(async move { - tokio::time::sleep(Duration::from_secs(ttl_secs)).await; - let _ = bot.delete_message(chat_id, sent.id).await; - }); - Ok(()) -} - -async fn send_error(bot: &Bot, chat_id: ChatId, text: &str) -> Result<()> { - bot.send_message(chat_id, text).await?; - Ok(()) -} - -async fn send_embedded_media_for_view( - bot: &Bot, - chat_id: ChatId, - state: &std::sync::Arc, - session: &ListSession, - peeked: &HashSet, -) -> Result> { - let lines = embedded_lines_for_view(session, peeked); - let embeds = extract_embedded_paths(&lines, &state.config); - let mut sent_message_ids = Vec::new(); - for path in embeds { - if is_image_path(&path) { - let sent = bot.send_photo(chat_id, InputFile::file(path)).await?; - sent_message_ids.push(sent.id); - } else if is_video_path(&path) { - let sent = bot.send_video(chat_id, InputFile::file(path)).await?; - sent_message_ids.push(sent.id); - } else { - let sent = bot.send_document(chat_id, InputFile::file(path)).await?; - sent_message_ids.push(sent.id); - } - } - Ok(sent_message_ids) -} - -async fn delete_embedded_media_messages(bot: &Bot, chat_id: ChatId, message_ids: &[MessageId]) { - for message_id in message_ids { - let _ = bot.delete_message(chat_id, *message_id).await; - } -} - -async fn refresh_embedded_media_for_view( - bot: &Bot, - chat_id: ChatId, - state: &std::sync::Arc, - session: &mut ListSession, - peeked: &HashSet, -) -> Result<()> { - delete_embedded_media_messages(bot, chat_id, &session.sent_media_message_ids).await; - session.sent_media_message_ids = - send_embedded_media_for_view(bot, chat_id, state, session, peeked).await?; - Ok(()) -} - -async fn reset_peeked(state: &std::sync::Arc) { - let mut peeked = state.peeked.lock().await; - peeked.clear(); -} - -async fn add_undo( - state: &std::sync::Arc, - kind: UndoKind, - entry: String, -) -> Result { - let mut undo = state.undo.lock().await; - prune_undo(&mut undo); - let id = short_id(); - undo.push(UndoRecord { - id: id.clone(), - kind, - entry, - expires_at: now_ts() + UNDO_TTL_SECS, - }); - save_undo(&state.undo_path, &undo)?; - Ok(id) -} - -async fn with_retries(mut f: F) -> Result -where - F: FnMut() -> Result, -{ - let mut last_err = None; - for attempt in 0..3 { - match f() { - Ok(value) => return Ok(value), - Err(err) => last_err = Some(err), - } - if attempt < 2 { - tokio::time::sleep(Duration::from_millis(200)).await; - } - } - Err(last_err.unwrap_or_else(|| anyhow!("retry failed"))) -} - -fn resolve_user_id(input: UserIdInput, config_dir: &Path) -> Result { - match input { - UserIdInput::Number(value) => Ok(value), - UserIdInput::String(raw) => resolve_user_id_string(&raw, config_dir), - UserIdInput::File { file } => { - let path = resolve_user_id_path(&file, config_dir); - read_user_id_file(&path) - } - } -} - -fn resolve_user_id_string(raw: &str, config_dir: &Path) -> Result { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return Err(anyhow!("user_id is empty")); - } - if trimmed.chars().all(|c| c.is_ascii_digit()) { - return parse_user_id_value(trimmed).context("parse user_id"); - } - let path = resolve_user_id_path(Path::new(trimmed), config_dir); - read_user_id_file(&path) -} - -fn resolve_user_id_path(path: &Path, config_dir: &Path) -> PathBuf { - if path.is_relative() { - config_dir.join(path) - } else { - path.to_path_buf() - } -} - -fn read_user_id_file(path: &Path) -> Result { - let contents = fs::read_to_string(path) - .with_context(|| format!("read user_id file {}", path.display()))?; - parse_user_id_value(contents.trim()) - .with_context(|| format!("parse user_id from {}", path.display())) -} - -fn parse_user_id_value(raw: &str) -> Result { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return Err(anyhow!("user_id is empty")); - } - trimmed.parse::().context("parse user_id") -} - -fn load_config(path: &Path) -> Result { - let contents = - fs::read_to_string(path).with_context(|| format!("read config {}", path.display()))?; - let config_file: ConfigFile = toml::from_str(&contents).context("parse config")?; - let config_dir = path.parent().unwrap_or_else(|| Path::new(".")); - let user_id = resolve_user_id(config_file.user_id, config_dir)?; - let default_media_dir = config_file - .read_later_path - .parent() - .unwrap_or_else(|| Path::new(".")) - .join("Misc/images_misc"); - let media_dir = config_file.media_dir.unwrap_or(default_media_dir); - let sync_x = config_file.sync_x.map(|sync_x| SyncXConfig { - source_project_path: resolve_user_id_path(&sync_x.source_project_path, config_dir), - work_dir: sync_x - .work_dir - .as_ref() - .map(|p| resolve_user_id_path(p, config_dir)), - python_bin: sync_x - .python_bin - .as_ref() - .map(|p| resolve_user_id_path(p, config_dir)), - }); - Ok(Config { - token: config_file.token, - user_id, - read_later_path: config_file.read_later_path, - finished_path: config_file.finished_path, - resources_path: config_file.resources_path, - media_dir, - data_dir: config_file.data_dir, - retry_interval_seconds: config_file.retry_interval_seconds, - sync: config_file.sync, - sync_x, - }) -} - -fn list_resource_files(dir: &Path) -> Result> { - let mut files = Vec::new(); - if !dir.exists() { - return Ok(files); - } - let entries = fs::read_dir(dir).with_context(|| format!("read dir {}", dir.display()))?; - for entry in entries { - let entry = entry.with_context(|| format!("read dir entry {}", dir.display()))?; - let path = entry.path(); - let file_type = entry - .file_type() - .with_context(|| format!("read file type {}", path.display()))?; - if !file_type.is_file() { - continue; - } - let is_md = path - .extension() - .and_then(|ext| ext.to_str()) - .map(|ext| ext.eq_ignore_ascii_case("md")) - .unwrap_or(false); - if is_md { - files.push(path); - } - } - files.sort_by(|a, b| { - let a_name = a - .file_name() - .map(|n| n.to_string_lossy()) - .unwrap_or_default(); - let b_name = b - .file_name() - .map(|n| n.to_string_lossy()) - .unwrap_or_default(); - a_name.cmp(&b_name) - }); - Ok(files) -} - -fn read_entries(path: &Path) -> Result<(Vec, Vec)> { - if !path.exists() { - return Ok((Vec::new(), Vec::new())); - } - let contents = - fs::read_to_string(path).with_context(|| format!("read file {}", path.display()))?; - let normalized = normalize_line_endings(&contents); - Ok(parse_entries(&normalized)) -} - -fn parse_entries(contents: &str) -> (Vec, Vec) { - let mut preamble = Vec::new(); - let mut entries: Vec = Vec::new(); - let mut current: Vec = Vec::new(); - let mut in_entries = false; - - for line in contents.lines() { - if line.starts_with('-') { - if in_entries && !current.is_empty() { - entries.push(EntryBlock { lines: current }); - current = Vec::new(); - } - in_entries = true; - current.push(line.to_string()); - } else if in_entries { - current.push(line.to_string()); - } else { - preamble.push(line.to_string()); - } - } - - if in_entries && !current.is_empty() { - entries.push(EntryBlock { lines: current }); - } - - (preamble, entries) -} - -fn write_entries(path: &Path, preamble: &[String], entries: &[EntryBlock]) -> Result<()> { - let mut lines: Vec = Vec::new(); - lines.extend_from_slice(preamble); - for entry in entries { - lines.extend(entry.lines.clone()); - } - let mut content = lines.join("\n"); - if !content.is_empty() { - content.push('\n'); - } - atomic_write(path, content.as_bytes()) -} - -fn atomic_write(path: &Path, data: &[u8]) -> Result<()> { - let dir = path - .parent() - .ok_or_else(|| anyhow!("no parent dir for {}", path.display()))?; - fs::create_dir_all(dir).with_context(|| format!("create dir {}", dir.display()))?; - let mut tmp = tempfile::NamedTempFile::new_in(dir) - .with_context(|| format!("create temp file in {}", dir.display()))?; - tmp.write_all(data).context("write temp file")?; - tmp.flush().context("flush temp file")?; - tmp.as_file_mut().sync_all().context("sync temp file")?; - tmp.persist(path) - .map_err(|e| anyhow!("persist temp file: {}", e))?; - Ok(()) -} - -fn add_entry_sync(path: &Path, entry: &EntryBlock) -> Result { - let (preamble, mut entries) = read_entries(path)?; - let block = entry.block_string(); - if entries.iter().any(|e| e.block_string() == block) { - return Ok(AddOutcome::Duplicate); - } - entries.insert(0, entry.clone()); - write_entries(path, &preamble, &entries)?; - Ok(AddOutcome::Added) -} - -fn add_resource_entry_sync(path: &Path, entry_block: &str) -> Result { - let existing = if path.exists() { - fs::read_to_string(path).with_context(|| format!("read file {}", path.display()))? - } else { - String::new() - }; - let normalized = normalize_line_endings(&existing); - let (_, entries) = parse_entries(&normalized); - if entries.iter().any(|e| e.block_string() == entry_block) { - return Ok(AddOutcome::Duplicate); - } - - let mut preserved = normalized; - if !preserved.is_empty() && !preserved.ends_with('\n') { - preserved.push('\n'); - } - - let mut content = String::new(); - content.push_str(entry_block); - content.push('\n'); - content.push_str(&preserved); - if !content.ends_with('\n') { - content.push('\n'); - } - atomic_write(path, content.as_bytes())?; - Ok(AddOutcome::Added) -} - -fn delete_entry_sync(path: &Path, entry_block: &str) -> Result { - let (preamble, mut entries) = read_entries(path)?; - let pos = entries.iter().position(|e| e.block_string() == entry_block); - let Some(pos) = pos else { - return Ok(ModifyOutcome::NotFound); - }; - entries.remove(pos); - write_entries(path, &preamble, &entries)?; - Ok(ModifyOutcome::Applied) -} - -fn update_entry_sync( - path: &Path, - entry_block: &str, - updated_entry: &EntryBlock, -) -> Result { - let (preamble, mut entries) = read_entries(path)?; - let pos = entries.iter().position(|e| e.block_string() == entry_block); - let Some(pos) = pos else { - return Ok(ModifyOutcome::NotFound); - }; - entries[pos] = updated_entry.clone(); - write_entries(path, &preamble, &entries)?; - Ok(ModifyOutcome::Applied) -} - -fn move_to_finished_sync( - read_later: &Path, - finished: &Path, - entry_block: &str, -) -> Result { - let (preamble_rl, mut entries_rl) = read_entries(read_later)?; - let pos = entries_rl - .iter() - .position(|e| e.block_string() == entry_block); - let Some(pos) = pos else { - return Ok(ModifyOutcome::NotFound); - }; - let entry = entries_rl.remove(pos); - - let (preamble_fin, mut entries_fin) = read_entries(finished)?; - entries_fin.insert(0, entry); - write_entries(finished, &preamble_fin, &entries_fin)?; - write_entries(read_later, &preamble_rl, &entries_rl)?; - Ok(ModifyOutcome::Applied) -} - -fn move_to_finished_updated_sync( - read_later: &Path, - finished: &Path, - entry_block: &str, - updated_entry: &str, -) -> Result { - let (preamble_rl, mut entries_rl) = read_entries(read_later)?; - let pos = entries_rl - .iter() - .position(|e| e.block_string() == entry_block); - let Some(pos) = pos else { - return Ok(ModifyOutcome::NotFound); - }; - entries_rl.remove(pos); - - let (preamble_fin, mut entries_fin) = read_entries(finished)?; - let updated_entry = EntryBlock::from_block(updated_entry); - entries_fin.insert(0, updated_entry); - write_entries(finished, &preamble_fin, &entries_fin)?; - write_entries(read_later, &preamble_rl, &entries_rl)?; - Ok(ModifyOutcome::Applied) -} - -fn move_to_read_later_sync( - read_later: &Path, - finished: &Path, - entry_block: &str, -) -> Result { - let (preamble_fin, mut entries_fin) = read_entries(finished)?; - let pos = entries_fin - .iter() - .position(|e| e.block_string() == entry_block); - let Some(pos) = pos else { - return Ok(ModifyOutcome::NotFound); - }; - let entry = entries_fin.remove(pos); - - let (preamble_rl, mut entries_rl) = read_entries(read_later)?; - entries_rl.insert(0, entry); - write_entries(read_later, &preamble_rl, &entries_rl)?; - write_entries(finished, &preamble_fin, &entries_fin)?; - Ok(ModifyOutcome::Applied) -} - -fn load_queue(path: &Path) -> Result> { - if !path.exists() { - return Ok(Vec::new()); - } - let data = - fs::read_to_string(path).with_context(|| format!("read queue {}", path.display()))?; - let queue = serde_json::from_str(&data).context("parse queue")?; - Ok(queue) -} - -fn save_queue(path: &Path, queue: &[QueuedOp]) -> Result<()> { - let data = serde_json::to_vec_pretty(queue).context("serialize queue")?; - atomic_write(path, &data) -} - -fn load_undo(path: &Path) -> Result> { - if !path.exists() { - return Ok(Vec::new()); - } - let data = fs::read_to_string(path).with_context(|| format!("read undo {}", path.display()))?; - let undo = serde_json::from_str(&data).context("parse undo")?; - Ok(undo) -} - -fn save_undo(path: &Path, undo: &[UndoRecord]) -> Result<()> { - let data = serde_json::to_vec_pretty(undo).context("serialize undo")?; - atomic_write(path, &data) -} - -fn prune_undo(undo: &mut Vec) { - let now = now_ts(); - undo.retain(|r| r.expires_at > now); -} - -fn normalize_line_endings(input: &str) -> String { - input.replace("\r\n", "\n").replace('\r', "\n") -} - -fn resource_block_from_text(text: &str) -> String { - let normalized = normalize_line_endings(text); - let mut lines: Vec = normalized.lines().map(|s| s.to_string()).collect(); - if lines.is_empty() { - lines.push(String::new()); - } - if let Some(first) = lines.get_mut(0) { - *first = format!("- (Auto-Resource): {}", first); - } - lines.join("\n") -} - -fn sanitize_resource_filename(input: &str) -> Result { - let trimmed = input.trim(); - let first_line = trimmed.lines().next().unwrap_or("").trim(); - if first_line.is_empty() { - return Err(anyhow!("Provide a filename.")); - } - if first_line == "." || first_line == ".." { - return Err(anyhow!("Invalid filename.")); - } - if first_line.contains('/') || first_line.contains('\\') { - return Err(anyhow!("Invalid filename.")); - } - let mut name = first_line.to_string(); - if !name.to_lowercase().ends_with(".md") { - name.push_str(".md"); - } - Ok(name) -} - -fn sanitize_filename_with_default(input: &str, default_ext: Option<&str>) -> String { - let mut sanitized: String = input - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') { - c - } else { - '_' - } - }) - .collect(); - if sanitized.is_empty() { - sanitized = "file".to_string(); - } - if Path::new(&sanitized).extension().is_none() { - if let Some(ext) = default_ext { - sanitized.push('.'); - sanitized.push_str(ext); - } - } - sanitized -} - -fn extension_from_mime(mime: &str) -> Option<&str> { - let (_, subtype) = mime.split_once('/')?; - if subtype.eq_ignore_ascii_case("jpeg") { - Some("jpg") - } else { - Some(subtype) - } -} - -fn build_media_entry_text(filename: &str, caption: Option<&str>) -> String { - let mut text = format!("![[{}]]", filename); - if let Some(caption) = caption { - let normalized = normalize_line_endings(caption).trim().to_string(); - if !normalized.is_empty() { - text.push('\n'); - text.push_str(&normalized); - } - } - text -} - -fn format_embedded_references_for_lines(lines: &[String], config: &Config) -> Vec { - let mut labels: HashMap = HashMap::new(); - let mut next_label = 1usize; - let mut output = Vec::with_capacity(lines.len()); - - for line in lines { - let mut formatted = String::with_capacity(line.len()); - let mut index = 0; - while let Some(start_rel) = line[index..].find("![[") { - let marker_start = index + start_rel; - formatted.push_str(&line[index..marker_start]); - - let marker_content_start = marker_start + 3; - let Some(end_rel) = line[marker_content_start..].find("]]") else { - formatted.push_str(&line[marker_start..]); - index = line.len(); - break; - }; - let marker_content_end = marker_content_start + end_rel; - let marker_end = marker_content_end + 2; - let marker_inner = &line[marker_content_start..marker_content_end]; - - if let Some(path) = resolve_embedded_path(marker_inner, config) { - let label = match labels.get(&path) { - Some(label) => *label, - None => { - let assigned = next_label; - labels.insert(path.clone(), assigned); - next_label += 1; - assigned - } - }; - if is_image_path(&path) { - formatted.push_str(&format!("image #{}", label)); - } else if is_video_path(&path) { - formatted.push_str(&format!("video #{}", label)); - } else { - formatted.push_str(&format!("file #{}", label)); - } - } else { - formatted.push_str(&line[marker_start..marker_end]); - } - - index = marker_end; - } - formatted.push_str(&line[index..]); - output.push(formatted); - } - - output -} - -fn pick_best_photo(photos: &[teloxide::types::PhotoSize]) -> Option<&teloxide::types::PhotoSize> { - photos - .iter() - .max_by_key(|photo| photo.file.size.max((photo.width * photo.height) as u32) as u64) -} - -async fn download_telegram_file(bot: &Bot, file_id: &str, dest_path: &Path) -> Result<()> { - let file = bot.get_file(file_id).await?; - let mut out = tokio::fs::File::create(dest_path).await?; - bot.download_file(&file.path, &mut out).await?; - Ok(()) -} - -fn extract_embedded_paths(lines: &[String], config: &Config) -> Vec { - let mut paths = Vec::new(); - let mut seen = HashSet::new(); - for line in lines { - let mut index = 0; - while let Some(start_rel) = line[index..].find("![[") { - let start = index + start_rel + 3; - let Some(end_rel) = line[start..].find("]]") else { - break; - }; - let end = start + end_rel; - let inner = &line[start..end]; - if let Some(path) = resolve_embedded_path(inner, config) { - if seen.insert(path.clone()) { - paths.push(path); - } - } - index = end + 2; - } - } - paths -} - -fn resolve_embedded_path(inner: &str, config: &Config) -> Option { - let mut inner = inner.trim(); - if let Some((path_part, _)) = inner.split_once('|') { - inner = path_part.trim(); - } - if inner.is_empty() { - return None; - } - - let vault_root = config - .read_later_path - .parent() - .unwrap_or_else(|| Path::new(".")); - let path = if Path::new(inner).is_absolute() { - PathBuf::from(inner) - } else if inner.contains('/') || inner.contains('\\') { - vault_root.join(inner) - } else { - config.media_dir.join(inner) - }; - - if path.exists() { - Some(path) - } else { - None - } -} - -fn is_image_path(path: &Path) -> bool { - match path.extension().and_then(|ext| ext.to_str()) { - Some(ext) => matches!( - ext.to_ascii_lowercase().as_str(), - "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" - ), - None => false, - } -} - -fn is_video_path(path: &Path) -> bool { - match path.extension().and_then(|ext| ext.to_str()) { - Some(ext) => matches!( - ext.to_ascii_lowercase().as_str(), - "mp4" | "mov" | "mkv" | "webm" | "avi" | "m4v" - ), - None => false, - } -} - -fn parse_command(text: &str) -> Option<&str> { - let first = text.split_whitespace().next()?; - if !first.starts_with('/') { - return None; - } - let cmd = first.trim_start_matches('/'); - Some(cmd.split('@').next().unwrap_or(cmd)) -} - -fn quick_select_index(entries_len: usize, mode: QuickSelectMode) -> Option { - if entries_len == 0 { - return None; - } - match mode { - QuickSelectMode::Top => Some(0), - QuickSelectMode::Last => Some(entries_len - 1), - QuickSelectMode::Random => { - let mut indices: Vec = (0..entries_len).collect(); - let mut rng = rand::thread_rng(); - indices.shuffle(&mut rng); - indices.first().copied() - } - } -} - -fn short_id() -> String { - let id = Uuid::new_v4().to_string(); - id.split('-').next().unwrap_or(&id).to_string() -} - -fn now_ts() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_else(|_| Duration::from_secs(0)) - .as_secs() -} - -fn chat_id_from_user_id(user_id: u64) -> ChatId { - ChatId(user_id as i64) -} - -fn start_retry_loop(state: std::sync::Arc, interval_secs: u64) { - tokio::spawn(async move { - let mut interval = tokio::time::interval(Duration::from_secs(interval_secs)); - loop { - interval.tick().await; - if let Err(err) = process_queue(state.clone()).await { - error!("queue processing failed: {:#}", err); - } - } - }); -} - -async fn process_queue(state: std::sync::Arc) -> Result<()> { - let pending = { - let mut queue = state.queue.lock().await; - std::mem::take(&mut *queue) - }; - - if pending.is_empty() { - return Ok(()); - } - - let mut remaining = Vec::new(); - for op in pending { - match apply_op(&state, &op).await { - Ok(_) => {} - Err(err) => { - error!("queued op failed: {:#}", err); - remaining.push(op); - } - } - } - - let mut queue = state.queue.lock().await; - if !queue.is_empty() { - remaining.extend(queue.drain(..)); - } - *queue = remaining; - save_queue(&state.queue_path, &queue) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::collections::HashSet; - use std::os::unix::process::ExitStatusExt; - - fn entry(text: &str) -> EntryBlock { - EntryBlock::from_text(text) - } - - fn test_config() -> Config { - Config { - token: "token".to_string(), - user_id: 1, - read_later_path: PathBuf::from("/tmp/read-later.md"), - finished_path: PathBuf::from("/tmp/finished.md"), - resources_path: PathBuf::from("/tmp/resources"), - media_dir: PathBuf::from("/tmp/media"), - data_dir: PathBuf::from("/tmp/data"), - retry_interval_seconds: None, - sync: None, - sync_x: None, - } - } - - #[test] - fn normalize_markdown_links_replaces_single_link() { - let input = "See [post](https://example.com/post) now"; - let (out, changed) = normalize_markdown_links(input); - assert!(changed); - assert_eq!(out, "See https://example.com/post now"); - } - - #[test] - fn normalize_markdown_links_replaces_multiple_links() { - let input = "[a](one) and [b](two)"; - let (out, changed) = normalize_markdown_links(input); - assert!(changed); - assert_eq!(out, "one and two"); - } - - #[test] - fn normalize_markdown_links_ignores_invalid_markup() { - let input = "broken [link](missing"; - let (out, changed) = normalize_markdown_links(input); - assert!(!changed); - assert_eq!(out, input); - } - - #[test] - fn normalize_entry_markdown_links_updates_entry() { - let entry = EntryBlock::from_text("foo [x](url)\nbar"); - let normalized = normalize_entry_markdown_links(&entry).unwrap(); - let block = normalized.block_string(); - assert!(block.contains("foo url")); - assert!(!block.contains("[x]")); - } - - #[test] - fn peek_indices_filters_and_pages() { - let entries: Vec = (0..6).map(|i| entry(&format!("item {}", i))).collect(); - let mut peeked = HashSet::new(); - peeked.insert(entries[1].block_string()); - peeked.insert(entries[3].block_string()); - - assert_eq!(count_unpeeked_entries(&entries, &peeked), 4); - assert_eq!( - peek_indices(&entries, &peeked, ListMode::Top, 0), - vec![0, 2, 4] - ); - assert_eq!(peek_indices(&entries, &peeked, ListMode::Top, 1), vec![5]); - assert_eq!( - peek_indices(&entries, &peeked, ListMode::Bottom, 0), - vec![5, 4, 2] - ); - assert_eq!( - peek_indices(&entries, &peeked, ListMode::Bottom, 1), - vec![0] - ); - } - - #[test] - fn search_peek_indices_ignore_peeked_entries() { - let entries: Vec = (0..4).map(|i| entry(&format!("match {}", i))).collect(); - let session = ListSession { - id: "session".to_string(), - chat_id: 0, - kind: SessionKind::Search { - query: "match".to_string(), - }, - entries: entries.clone(), - view: ListView::Peek { - mode: ListMode::Top, - page: 0, - }, - seen_random: HashSet::new(), - message_id: None, - sent_media_message_ids: Vec::new(), - }; - let mut peeked = HashSet::new(); - for entry in &entries { - peeked.insert(entry.block_string()); - } - - assert_eq!(count_visible_entries(&session, &peeked), 4); - assert_eq!( - peek_indices_for_session(&session, &peeked, ListMode::Top, 0), - vec![0, 1, 2] - ); - assert_eq!( - peek_indices_for_session(&session, &peeked, ListMode::Top, 1), - vec![3] - ); - } - - #[test] - fn build_peek_view_shows_all_peeked_message() { - let entries = vec![entry("one"), entry("two")]; - let session = ListSession { - id: "session".to_string(), - chat_id: 0, - kind: SessionKind::List, - entries: entries.clone(), - view: ListView::Peek { - mode: ListMode::Top, - page: 0, - }, - seen_random: HashSet::new(), - message_id: None, - sent_media_message_ids: Vec::new(), - }; - let mut peeked = HashSet::new(); - for entry in &entries { - peeked.insert(entry.block_string()); - } - let config = test_config(); - let (text, _kb) = build_peek_view("session", &session, ListMode::Top, 0, &peeked, &config); - assert!(text.contains("Everything's been peeked already.")); - } - - #[test] - fn format_embedded_references_labels_images_and_files() { - let temp = TempDir::new().unwrap(); - let media_dir = temp.path().join("media"); - fs::create_dir_all(&media_dir).unwrap(); - fs::write(media_dir.join("image-1.jpg"), b"x").unwrap(); - fs::write(media_dir.join("doc-1.pdf"), b"x").unwrap(); - - let mut config = test_config(); - config.media_dir = media_dir; - - let lines = vec![ - "![[image-1.jpg]] and ![[doc-1.pdf]]".to_string(), - "repeat ![[image-1.jpg]]".to_string(), - ]; - let rendered = format_embedded_references_for_lines(&lines, &config); - - assert_eq!(rendered[0], "image #1 and file #2"); - assert_eq!(rendered[1], "repeat image #1"); - } - - #[test] - fn format_embedded_references_labels_videos() { - let temp = TempDir::new().unwrap(); - let media_dir = temp.path().join("media"); - fs::create_dir_all(&media_dir).unwrap(); - fs::write(media_dir.join("clip.mp4"), b"x").unwrap(); - - let mut config = test_config(); - config.media_dir = media_dir; - - let lines = vec!["Watch ![[clip.mp4]]".to_string()]; - let rendered = format_embedded_references_for_lines(&lines, &config); - - assert_eq!(rendered[0], "Watch video #1"); - } - - #[test] - fn human_size_formats_units() { - assert_eq!(human_size(999), "999 B"); - assert_eq!(human_size(2048), "2.0 KB"); - assert_eq!(human_size(5 * 1024 * 1024), "5.0 MB"); - } - - #[test] - fn build_download_quality_text_lists_options() { - let options = vec![ - DownloadQualityOption { - label: "Best".to_string(), - format_selector: "bestvideo+bestaudio/best".to_string(), - }, - DownloadQualityOption { - label: "720p mp4".to_string(), - format_selector: "22".to_string(), - }, - ]; - let text = build_download_quality_text( - "https://example.com/video", - DownloadAction::Send, - &options, - ); - assert!(text.contains("Choose quality to send")); - assert!(text.contains("1: Best")); - assert!(text.contains("2: 720p mp4")); - } - - #[test] - fn embedded_lines_for_peek_use_preview_only() { - let entry = EntryBlock::from_text("first line\nsecond line\n![[image-2.jpg]]"); - let session = ListSession { - id: "session".to_string(), - chat_id: 0, - kind: SessionKind::List, - entries: vec![entry], - view: ListView::Peek { - mode: ListMode::Top, - page: 0, - }, - seen_random: HashSet::new(), - message_id: None, - sent_media_message_ids: Vec::new(), - }; - - let lines = embedded_lines_for_view(&session, &HashSet::new()); - assert_eq!( - lines, - vec!["first line".to_string(), "second line...".to_string()] - ); - } - - #[test] - fn build_undos_view_includes_labels_and_previews() { - let record_one = UndoRecord { - id: "one".to_string(), - kind: UndoKind::Delete, - entry: entry("alpha").block_string(), - expires_at: now_ts() + 10, - }; - let record_two = UndoRecord { - id: "two".to_string(), - kind: UndoKind::MoveToFinished, - entry: entry("beta").block_string(), - expires_at: now_ts() + 10, - }; - let (text, _kb) = build_undos_view("session", &[record_one, record_two]); - assert!(text.contains("Undos (2)")); - assert!(text.contains("1) Deleted")); - assert!(text.contains("2) Moved to finished")); - assert!(text.contains("alpha")); - assert!(text.contains("beta")); - } - - #[test] - fn displayed_indices_for_selected_view() { - let entries = vec![entry("one"), entry("two"), entry("three")]; - let session = ListSession { - id: "session".to_string(), - chat_id: 0, - kind: SessionKind::List, - entries, - view: ListView::Selected { - return_to: Box::new(ListView::Menu), - index: 1, - }, - seen_random: HashSet::new(), - message_id: None, - sent_media_message_ids: Vec::new(), - }; - let peeked = HashSet::new(); - assert_eq!(displayed_indices_for_view(&session, &peeked), vec![1]); - } - - #[test] - fn norm_target_index_prefers_single_peek_item() { - let entries = vec![entry("one"), entry("two")]; - let mut peeked = HashSet::new(); - peeked.insert(entries[0].block_string()); - let session = ListSession { - id: "session".to_string(), - chat_id: 0, - kind: SessionKind::List, - entries: entries.clone(), - view: ListView::Peek { - mode: ListMode::Top, - page: 0, - }, - seen_random: HashSet::new(), - message_id: None, - sent_media_message_ids: Vec::new(), - }; - assert_eq!(norm_target_index(&session, &peeked), Some(1)); - - let session_multi = ListSession { entries, ..session }; - let empty_peeked = HashSet::new(); - assert_eq!(norm_target_index(&session_multi, &empty_peeked), None); - } - - #[test] - fn command_keywords_are_case_insensitive() { - assert!(is_norm_message("NoRm")); - assert!(is_instant_delete_message("DEL")); - assert!(is_instant_delete_message("Delete")); - assert!(!is_instant_delete_message("remove")); - } - - #[test] - fn quick_select_index_supports_top_last_random() { - assert_eq!(quick_select_index(0, QuickSelectMode::Top), None); - assert_eq!(quick_select_index(4, QuickSelectMode::Top), Some(0)); - assert_eq!(quick_select_index(4, QuickSelectMode::Last), Some(3)); - let random = quick_select_index(4, QuickSelectMode::Random).unwrap(); - assert!(random < 4); - } - - #[test] - fn extract_https_username_from_remote() { - assert_eq!( - extract_https_username("https://user@host/repo.git"), - Some("user".to_string()) - ); - assert_eq!( - extract_https_username("https://user:pass@host/repo.git"), - Some("user".to_string()) - ); - assert_eq!(extract_https_username("https://host/repo.git"), None); - assert_eq!(extract_https_username("git@host:repo.git"), None); - } - - #[test] - fn read_token_file_trims_whitespace() { - let mut file = NamedTempFile::new().unwrap(); - file.write_all(b" token\n").unwrap(); - let token = read_token_file(file.path()).unwrap(); - assert_eq!(token, "token"); - } - - #[test] - fn parse_pull_mode_accepts_theirs() { - assert!(matches!(parse_pull_mode(""), Ok(PullMode::FastForward))); - assert!(matches!(parse_pull_mode("theirs"), Ok(PullMode::Theirs))); - assert!(parse_pull_mode("unknown").is_err()); - } - - #[test] - fn is_already_up_to_date_detects_output() { - let output = GitOutput { - status: std::process::ExitStatus::from_raw(0), - stdout: "Already up to date.".to_string(), - stderr: String::new(), - }; - assert!(is_already_up_to_date(&output)); - } - - #[test] - fn is_push_up_to_date_detects_output() { - let output = GitOutput { - status: std::process::ExitStatus::from_raw(0), - stdout: "Everything up-to-date".to_string(), - stderr: String::new(), - }; - assert!(is_push_up_to_date(&output)); - } - - #[test] - fn read_sync_x_urls_keeps_unique_http_lines() { - let temp = TempDir::new().unwrap(); - let path = temp.path().join("bookmarks.txt"); - fs::write( - &path, - "https://a.example\n\nnot-a-url\nhttps://b.example\nhttps://a.example\n", - ) - .unwrap(); - let urls = read_sync_x_urls(&path).unwrap(); - assert_eq!( - urls, - vec![ - "https://a.example".to_string(), - "https://b.example".to_string() - ] - ); - } - - #[test] - fn prepend_urls_to_read_later_sync_preserves_input_order() { - let temp = TempDir::new().unwrap(); - let path = temp.path().join("read-later.md"); - fs::write(&path, "- https://already.example\n").unwrap(); - let urls = vec![ - "https://one.example".to_string(), - "https://two.example".to_string(), - "https://already.example".to_string(), - ]; - - let (added, duplicates) = prepend_urls_to_read_later_sync(&path, &urls).unwrap(); - assert_eq!(added, 2); - assert_eq!(duplicates, 1); - - let (_, entries) = read_entries(&path).unwrap(); - let blocks = entries - .iter() - .map(|entry| entry.block_string()) - .collect::>(); - assert_eq!( - blocks, - vec![ - "- https://one.example".to_string(), - "- https://two.example".to_string(), - "- https://already.example".to_string(), - ] - ); - } -} diff --git a/src/message_handlers.rs b/src/message_handlers.rs new file mode 100644 index 0000000..17a9b67 --- /dev/null +++ b/src/message_handlers.rs @@ -0,0 +1,1367 @@ +use super::*; + +pub(super) async fn handle_message(bot: Bot, msg: Message, state: std::sync::Arc) -> Result<()> { + let user_id = match msg.from() { + Some(user) => user.id.0, + None => return Ok(()), + }; + + if user_id != state.config.user_id { + return Ok(()); + } + + if handle_media_message(&bot, &msg, &state).await? { + return Ok(()); + } + + let text = match msg.text() { + Some(text) => text.to_string(), + None => return Ok(()), + }; + + let mut expired_finish_prompt: Option = None; + let pending_finish_prompt = { + let mut prompts = state.finish_title_prompts.lock().await; + if let Some(prompt) = prompts.remove(&msg.chat.id.0) { + if prompt.expires_at > now_ts() { + Some(prompt) + } else { + expired_finish_prompt = Some(prompt); + None + } + } else { + None + } + }; + + if let Some(prompt) = expired_finish_prompt { + let _ = bot + .delete_message(msg.chat.id, prompt.prompt_message_id) + .await; + } + + if let Some(prompt) = pending_finish_prompt { + handle_finish_title_response(&bot, msg.chat.id, msg.id, &state, &text, prompt).await?; + return Ok(()); + } + + let mut expired_resource_prompt: Option = None; + let pending_resource_prompt = { + let mut prompts = state.resource_filename_prompts.lock().await; + if let Some(prompt) = prompts.remove(&msg.chat.id.0) { + if prompt.expires_at > now_ts() { + Some(prompt) + } else { + expired_resource_prompt = Some(prompt); + None + } + } else { + None + } + }; + + if let Some(prompt) = expired_resource_prompt { + let _ = bot + .delete_message(msg.chat.id, prompt.prompt_message_id) + .await; + } + + if let Some(prompt) = pending_resource_prompt { + handle_resource_filename_response(&bot, msg.chat.id, msg.id, &state, &text, prompt).await?; + return Ok(()); + } + + let mut expired_download_prompt: Option = None; + let pending_download_prompt = { + let mut prompts = state.download_link_prompts.lock().await; + if let Some(prompt) = prompts.remove(&msg.chat.id.0) { + if prompt.expires_at > now_ts() { + Some(prompt) + } else { + expired_download_prompt = Some(prompt); + None + } + } else { + None + } + }; + + if let Some(prompt) = expired_download_prompt { + let _ = bot + .delete_message(msg.chat.id, prompt.prompt_message_id) + .await; + } + + if let Some(prompt) = pending_download_prompt { + handle_download_link_response(&bot, msg.chat.id, msg.id, &state, &text, prompt).await?; + return Ok(()); + } + + let mut expired_sync_x_prompt: Option = None; + let pending_sync_x_prompt = { + let mut prompts = state.sync_x_cookie_prompts.lock().await; + if let Some(prompt) = prompts.remove(&msg.chat.id.0) { + if prompt.expires_at > now_ts() { + Some(prompt) + } else { + expired_sync_x_prompt = Some(prompt); + None + } + } else { + None + } + }; + + if let Some(prompt) = expired_sync_x_prompt { + let _ = bot + .delete_message(msg.chat.id, prompt.prompt_message_id) + .await; + } + + if let Some(prompt) = pending_sync_x_prompt { + handle_sync_x_cookie_response(&bot, msg.chat.id, msg.id, &state, &text, prompt).await?; + return Ok(()); + } + + if let Some(cmd) = parse_command(&text) { + let rest = text + .splitn(2, |c: char| c.is_whitespace()) + .nth(1) + .unwrap_or("") + .trim(); + match cmd { + "start" | "help" => { + let help = "Send any text to save it. Commands: /start, /help, /add , /list, /top, /last, /random, /search , /delete , /download [url], /undos, /reset_peeked, /pull, /pull theirs, /push, /sync, /sync_x. Use --- to split a message into multiple items. In list views, use buttons for Mark Finished, Add Resource, Delete, Random. Quick actions: reply with del/delete to remove the current item, or send norm to normalize links."; + send_message_with_delete_button(&bot, msg.chat.id, help).await?; + return Ok(()); + } + "add" => { + if rest.is_empty() { + send_error(&bot, msg.chat.id, "Provide text to add.").await?; + } else { + handle_add_command(bot, msg, state, rest).await?; + } + return Ok(()); + } + "list" => { + handle_list_command(bot.clone(), msg.clone(), state).await?; + let _ = bot.delete_message(msg.chat.id, msg.id).await; + return Ok(()); + } + "search" | "delete" => { + if rest.is_empty() { + send_ephemeral(&bot, msg.chat.id, "Provide a search query.", ACK_TTL_SECS) + .await?; + } else { + handle_search_command(bot.clone(), msg.clone(), state, rest).await?; + } + let _ = bot.delete_message(msg.chat.id, msg.id).await; + return Ok(()); + } + "top" => { + handle_quick_select_command( + bot.clone(), + msg.clone(), + state, + QuickSelectMode::Top, + ) + .await?; + let _ = bot.delete_message(msg.chat.id, msg.id).await; + return Ok(()); + } + "last" => { + handle_quick_select_command( + bot.clone(), + msg.clone(), + state, + QuickSelectMode::Last, + ) + .await?; + let _ = bot.delete_message(msg.chat.id, msg.id).await; + return Ok(()); + } + "random" => { + handle_quick_select_command( + bot.clone(), + msg.clone(), + state, + QuickSelectMode::Random, + ) + .await?; + let _ = bot.delete_message(msg.chat.id, msg.id).await; + return Ok(()); + } + "download" => { + handle_download_command(bot.clone(), msg.clone(), state, rest).await?; + let _ = bot.delete_message(msg.chat.id, msg.id).await; + return Ok(()); + } + "reset_peeked" => { + reset_peeked(&state).await; + let _ = bot.delete_message(msg.chat.id, msg.id).await; + return Ok(()); + } + "undos" => { + handle_undos_command(bot.clone(), msg.clone(), state).await?; + let _ = bot.delete_message(msg.chat.id, msg.id).await; + return Ok(()); + } + "pull" => { + handle_pull_command(bot.clone(), msg.clone(), state, rest).await?; + let _ = bot.delete_message(msg.chat.id, msg.id).await; + return Ok(()); + } + "push" => { + handle_push_command(bot.clone(), msg.clone(), state).await?; + let _ = bot.delete_message(msg.chat.id, msg.id).await; + return Ok(()); + } + "sync" => { + handle_sync_command(bot.clone(), msg.clone(), state).await?; + let _ = bot.delete_message(msg.chat.id, msg.id).await; + return Ok(()); + } + "sync_x" => { + handle_sync_x_command(bot.clone(), msg.clone(), state).await?; + let _ = bot.delete_message(msg.chat.id, msg.id).await; + return Ok(()); + } + _ => { + // Unknown command, fall through as text. + } + } + } + + if is_instant_delete_message(&text) { + if handle_instant_delete_message(&bot, &msg, &state).await? { + return Ok(()); + } + } + + if is_norm_message(&text) { + if handle_norm_message(&bot, &msg, &state).await? { + return Ok(()); + } + } + + if text.contains("---") { + handle_multi_item(bot, msg.chat.id, msg.id, state, &text).await?; + } else { + handle_single_item(bot, msg.chat.id, state, &text, Some(msg.id)).await?; + } + + Ok(()) +} + +async fn handle_media_message( + bot: &Bot, + msg: &Message, + state: &std::sync::Arc, +) -> Result { + let chat_id = msg.chat.id; + let caption = msg.caption().map(|text| text.to_string()); + let media_dir = state.config.media_dir.clone(); + + if let Some(photos) = msg.photo() { + if let Some(photo) = pick_best_photo(photos) { + fs::create_dir_all(&media_dir) + .with_context(|| format!("create media dir {}", media_dir.display()))?; + let filename = format!("image-{}.jpg", Uuid::new_v4()); + let dest_path = media_dir.join(&filename); + download_telegram_file(bot, &photo.file.id, &dest_path).await?; + let entry_text = build_media_entry_text(&filename, caption.as_deref()); + handle_single_item( + bot.clone(), + chat_id, + state.clone(), + &entry_text, + Some(msg.id), + ) + .await?; + return Ok(true); + } + } + + if let Some(document) = msg.document() { + let mime = document.mime_type.as_ref().map(|m| m.essence_str()); + fs::create_dir_all(&media_dir) + .with_context(|| format!("create media dir {}", media_dir.display()))?; + let ext = mime.and_then(extension_from_mime); + let filename = if let Some(name) = document.file_name.as_deref() { + sanitize_filename_with_default(name, ext) + } else { + format!("file-{}.{}", Uuid::new_v4(), ext.unwrap_or("bin")) + }; + let dest_path = media_dir.join(&filename); + download_telegram_file(bot, &document.file.id, &dest_path).await?; + let entry_text = build_media_entry_text(&filename, caption.as_deref()); + handle_single_item( + bot.clone(), + chat_id, + state.clone(), + &entry_text, + Some(msg.id), + ) + .await?; + return Ok(true); + } + + if let Some(video) = msg.video() { + fs::create_dir_all(&media_dir) + .with_context(|| format!("create media dir {}", media_dir.display()))?; + let ext = video + .mime_type + .as_ref() + .map(|m| m.essence_str()) + .and_then(extension_from_mime); + let filename = if let Some(name) = video.file_name.as_deref() { + sanitize_filename_with_default(name, ext) + } else { + format!("video-{}.{}", Uuid::new_v4(), ext.unwrap_or("mp4")) + }; + let dest_path = media_dir.join(&filename); + download_telegram_file(bot, &video.file.id, &dest_path).await?; + let entry_text = build_media_entry_text(&filename, caption.as_deref()); + handle_single_item( + bot.clone(), + chat_id, + state.clone(), + &entry_text, + Some(msg.id), + ) + .await?; + return Ok(true); + } + + Ok(false) +} + +async fn handle_norm_message( + bot: &Bot, + msg: &Message, + state: &std::sync::Arc, +) -> Result { + let chat_id = msg.chat.id; + let session_id = { + let active = state.active_sessions.lock().await; + active.get(&chat_id.0).cloned() + }; + let Some(session_id) = session_id else { + return Ok(false); + }; + let mut session = { + let mut sessions = state.sessions.lock().await; + match sessions.remove(&session_id) { + Some(session) => session, + None => return Ok(false), + } + }; + if session.chat_id != chat_id.0 { + state.sessions.lock().await.insert(session_id, session); + return Ok(false); + } + + let peeked_snapshot = state.peeked.lock().await.clone(); + let target_index = match norm_target_index(&session, &peeked_snapshot) { + Some(index) => index, + None => { + state + .sessions + .lock() + .await + .insert(session.id.clone(), session); + let _ = bot.delete_message(chat_id, msg.id).await; + send_ephemeral(bot, chat_id, "Couldn't normalize.", ACK_TTL_SECS).await?; + return Ok(true); + } + }; + + let entry = match session.entries.get(target_index).cloned() { + Some(entry) => entry, + None => { + state + .sessions + .lock() + .await + .insert(session.id.clone(), session); + let _ = bot.delete_message(chat_id, msg.id).await; + send_ephemeral(bot, chat_id, "Couldn't normalize.", ACK_TTL_SECS).await?; + return Ok(true); + } + }; + + let Some(normalized_entry) = normalize_entry_markdown_links(&entry) else { + state + .sessions + .lock() + .await + .insert(session.id.clone(), session); + let _ = bot.delete_message(chat_id, msg.id).await; + send_ephemeral(bot, chat_id, "Couldn't normalize.", ACK_TTL_SECS).await?; + return Ok(true); + }; + + let op = QueuedOp { + kind: QueuedOpKind::UpdateEntry, + entry: entry.block_string(), + resource_path: None, + updated_entry: Some(normalized_entry.block_string()), + }; + + match apply_user_op(state, &op).await? { + UserOpOutcome::Applied(ApplyOutcome::Applied) => { + session.entries[target_index] = normalized_entry; + let (text, kb) = + render_list_view(&session.id, &session, &peeked_snapshot, &state.config); + if let Some(message_id) = session.message_id { + bot.edit_message_text(chat_id, message_id, text) + .reply_markup(kb) + .await?; + } else { + let sent = bot.send_message(chat_id, text).reply_markup(kb).await?; + session.message_id = Some(sent.id); + } + if let Err(err) = + refresh_embedded_media_for_view(bot, chat_id, state, &mut session, &peeked_snapshot) + .await + { + error!("send embedded media failed: {:#}", err); + } + } + UserOpOutcome::Applied(ApplyOutcome::NotFound) + | UserOpOutcome::Applied(ApplyOutcome::Duplicate) => { + send_ephemeral(bot, chat_id, "Couldn't normalize.", ACK_TTL_SECS).await?; + } + UserOpOutcome::Queued => { + send_error(bot, chat_id, "Write failed; queued for retry.").await?; + } + } + + state + .sessions + .lock() + .await + .insert(session.id.clone(), session); + let _ = bot.delete_message(chat_id, msg.id).await; + Ok(true) +} + +async fn handle_instant_delete_message( + bot: &Bot, + msg: &Message, + state: &std::sync::Arc, +) -> Result { + let chat_id = msg.chat.id; + let session_id = { + let active = state.active_sessions.lock().await; + active.get(&chat_id.0).cloned() + }; + let Some(session_id) = session_id else { + return Ok(false); + }; + let mut session = { + let mut sessions = state.sessions.lock().await; + match sessions.remove(&session_id) { + Some(session) => session, + None => return Ok(false), + } + }; + if session.chat_id != chat_id.0 { + state.sessions.lock().await.insert(session_id, session); + return Ok(false); + } + + let peeked_snapshot = state.peeked.lock().await.clone(); + let target_index = match norm_target_index(&session, &peeked_snapshot) { + Some(index) => index, + None => { + state + .sessions + .lock() + .await + .insert(session.id.clone(), session); + let _ = bot.delete_message(chat_id, msg.id).await; + send_ephemeral(bot, chat_id, "Couldn't delete.", ACK_TTL_SECS).await?; + return Ok(true); + } + }; + + let entry_block = match session.entries.get(target_index).map(|e| e.block_string()) { + Some(entry) => entry, + None => { + state + .sessions + .lock() + .await + .insert(session.id.clone(), session); + let _ = bot.delete_message(chat_id, msg.id).await; + send_ephemeral(bot, chat_id, "Couldn't delete.", ACK_TTL_SECS).await?; + return Ok(true); + } + }; + + let op = QueuedOp { + kind: QueuedOpKind::Delete, + entry: entry_block, + resource_path: None, + updated_entry: None, + }; + + match apply_user_op(state, &op).await? { + UserOpOutcome::Applied(ApplyOutcome::Applied) => { + session.entries.remove(target_index); + if let ListView::Selected { return_to, .. } = session.view.clone() { + session.view = *return_to; + } + let _ = add_undo(state, UndoKind::Delete, op.entry.clone()).await?; + normalize_peek_view(&mut session, &peeked_snapshot); + let (text, kb) = + render_list_view(&session.id, &session, &peeked_snapshot, &state.config); + if let Some(message_id) = session.message_id { + bot.edit_message_text(chat_id, message_id, text) + .reply_markup(kb) + .await?; + } else { + let sent = bot.send_message(chat_id, text).reply_markup(kb).await?; + session.message_id = Some(sent.id); + } + if let Err(err) = + refresh_embedded_media_for_view(bot, chat_id, state, &mut session, &peeked_snapshot) + .await + { + error!("send embedded media failed: {:#}", err); + } + } + UserOpOutcome::Applied(ApplyOutcome::NotFound) + | UserOpOutcome::Applied(ApplyOutcome::Duplicate) => { + send_ephemeral(bot, chat_id, "Couldn't delete.", ACK_TTL_SECS).await?; + } + UserOpOutcome::Queued => { + send_error(bot, chat_id, "Write failed; queued for retry.").await?; + } + } + + state + .sessions + .lock() + .await + .insert(session.id.clone(), session); + let _ = bot.delete_message(chat_id, msg.id).await; + Ok(true) +} + +pub(crate) fn is_instant_delete_message(text: &str) -> bool { + matches!(text.trim().to_lowercase().as_str(), "del" | "delete") +} + +pub(crate) fn is_norm_message(text: &str) -> bool { + text.trim().eq_ignore_ascii_case("norm") +} + +async fn handle_list_command( + bot: Bot, + msg: Message, + state: std::sync::Arc, +) -> Result<()> { + let entries = read_entries(&state.config.read_later_path)?.1; + let session_id = short_id(); + let mut session = ListSession { + id: session_id.clone(), + chat_id: msg.chat.id.0, + kind: SessionKind::List, + entries, + view: ListView::Menu, + seen_random: HashSet::new(), + message_id: None, + sent_media_message_ids: Vec::new(), + }; + + let (text, kb) = build_menu_view(&session_id, &session); + let sent = bot.send_message(msg.chat.id, text).reply_markup(kb).await?; + session.message_id = Some(sent.id); + state + .sessions + .lock() + .await + .insert(session_id.clone(), session); + state + .active_sessions + .lock() + .await + .insert(msg.chat.id.0, session_id); + Ok(()) +} + +async fn handle_quick_select_command( + bot: Bot, + msg: Message, + state: std::sync::Arc, + mode: QuickSelectMode, +) -> Result<()> { + let entries = read_entries(&state.config.read_later_path)?.1; + let Some(index) = quick_select_index(entries.len(), mode) else { + send_ephemeral(&bot, msg.chat.id, "Read Later is empty.", ACK_TTL_SECS).await?; + return Ok(()); + }; + + let session_id = short_id(); + let mut session = ListSession { + id: session_id.clone(), + chat_id: msg.chat.id.0, + kind: SessionKind::List, + entries, + view: ListView::Selected { + return_to: Box::new(ListView::Menu), + index, + }, + seen_random: HashSet::new(), + message_id: None, + sent_media_message_ids: Vec::new(), + }; + + if matches!(mode, QuickSelectMode::Random) { + session.seen_random.insert(index); + } + if let Some(entry) = session.entries.get(index) { + state.peeked.lock().await.insert(entry.block_string()); + } + + let peeked_snapshot = state.peeked.lock().await.clone(); + let (text, kb) = render_list_view(&session_id, &session, &peeked_snapshot, &state.config); + let sent = bot.send_message(msg.chat.id, text).reply_markup(kb).await?; + session.message_id = Some(sent.id); + if let Err(err) = + refresh_embedded_media_for_view(&bot, msg.chat.id, &state, &mut session, &peeked_snapshot) + .await + { + error!("send embedded media failed: {:#}", err); + } + state + .sessions + .lock() + .await + .insert(session_id.clone(), session); + state + .active_sessions + .lock() + .await + .insert(msg.chat.id.0, session_id); + Ok(()) +} + +async fn handle_search_command( + bot: Bot, + msg: Message, + state: std::sync::Arc, + query: &str, +) -> Result<()> { + let entries = read_entries(&state.config.read_later_path)?.1; + let matches = search_entries(&entries, query); + + if matches.is_empty() { + send_ephemeral(&bot, msg.chat.id, "No matches.", ACK_TTL_SECS).await?; + return Ok(()); + } + + let session_id = short_id(); + let mut session = ListSession { + id: session_id.clone(), + chat_id: msg.chat.id.0, + kind: SessionKind::Search { + query: query.to_string(), + }, + entries: matches, + view: ListView::Peek { + mode: ListMode::Top, + page: 0, + }, + seen_random: HashSet::new(), + message_id: None, + sent_media_message_ids: Vec::new(), + }; + + let peeked_snapshot = state.peeked.lock().await.clone(); + let (text, kb) = render_list_view(&session_id, &session, &peeked_snapshot, &state.config); + let sent = bot.send_message(msg.chat.id, text).reply_markup(kb).await?; + session.message_id = Some(sent.id); + state + .sessions + .lock() + .await + .insert(session_id.clone(), session); + state + .active_sessions + .lock() + .await + .insert(msg.chat.id.0, session_id); + Ok(()) +} + +async fn handle_download_command( + bot: Bot, + msg: Message, + state: std::sync::Arc, + rest: &str, +) -> Result<()> { + let links = if !rest.trim().is_empty() { + extract_links(rest) + } else { + match active_entry_text(&state, msg.chat.id.0).await { + Some(text) => extract_links(&text), + None => Vec::new(), + } + }; + + start_download_picker(&bot, msg.chat.id, &state, links).await?; + Ok(()) +} + +async fn active_entry_text(state: &std::sync::Arc, chat_id: i64) -> Option { + let session_id = { + let active = state.active_sessions.lock().await; + active.get(&chat_id).cloned() + }?; + let session = { + let sessions = state.sessions.lock().await; + sessions.get(&session_id).cloned() + }?; + if session.chat_id != chat_id { + return None; + } + let peeked_snapshot = state.peeked.lock().await.clone(); + match &session.view { + ListView::Selected { index, .. } => session + .entries + .get(*index) + .map(|entry| entry.display_lines().join("\n")), + ListView::Peek { mode, page } => { + let indices = peek_indices_for_session(&session, &peeked_snapshot, *mode, *page); + if indices.len() == 1 { + session + .entries + .get(indices[0]) + .map(|entry| entry.display_lines().join("\n")) + } else { + None + } + } + _ => None, + } +} + +async fn handle_push_command( + bot: Bot, + msg: Message, + state: std::sync::Arc, +) -> Result<()> { + let Some(sync) = state.config.sync.clone() else { + send_error( + &bot, + msg.chat.id, + "Sync not configured. Set settings.sync.repo_path and settings.sync.token_file.", + ) + .await?; + return Ok(()); + }; + + let chat_id = msg.chat.id; + let outcome = tokio::task::spawn_blocking(move || run_push(&sync)) + .await + .context("push task failed")?; + + match outcome { + Ok(PushOutcome::NoChanges) => { + send_ephemeral(&bot, chat_id, "Nothing to sync.", ACK_TTL_SECS).await?; + } + Ok(PushOutcome::Pushed) => { + send_ephemeral(&bot, chat_id, "Synced.", ACK_TTL_SECS).await?; + } + Err(err) => { + send_error(&bot, chat_id, &err.to_string()).await?; + } + } + + Ok(()) +} + +async fn handle_pull_command( + bot: Bot, + msg: Message, + state: std::sync::Arc, + rest: &str, +) -> Result<()> { + let Some(sync) = state.config.sync.clone() else { + send_error( + &bot, + msg.chat.id, + "Sync not configured. Set settings.sync.repo_path and settings.sync.token_file.", + ) + .await?; + return Ok(()); + }; + + let mode = match parse_pull_mode(rest) { + Ok(mode) => mode, + Err(message) => { + send_error(&bot, msg.chat.id, &message).await?; + return Ok(()); + } + }; + + let chat_id = msg.chat.id; + let outcome = tokio::task::spawn_blocking(move || run_pull(&sync, mode)) + .await + .context("pull task failed")?; + + match outcome { + Ok(PullOutcome::UpToDate) => { + send_ephemeral(&bot, chat_id, "Already up to date.", ACK_TTL_SECS).await?; + } + Ok(PullOutcome::Pulled) => { + send_ephemeral(&bot, chat_id, "Pulled.", ACK_TTL_SECS).await?; + } + Err(err) => { + send_error(&bot, chat_id, &err.to_string()).await?; + } + } + + Ok(()) +} + +async fn handle_sync_command( + bot: Bot, + msg: Message, + state: std::sync::Arc, +) -> Result<()> { + let Some(sync) = state.config.sync.clone() else { + send_error( + &bot, + msg.chat.id, + "Sync not configured. Set settings.sync.repo_path and settings.sync.token_file.", + ) + .await?; + return Ok(()); + }; + + let chat_id = msg.chat.id; + let outcome = tokio::task::spawn_blocking(move || run_sync(&sync)) + .await + .context("sync task failed")?; + + match outcome { + Ok(SyncOutcome::Synced) => { + send_ephemeral(&bot, chat_id, "Synced.", ACK_TTL_SECS).await?; + } + Ok(SyncOutcome::NoChanges) => { + send_ephemeral(&bot, chat_id, "Nothing to sync.", ACK_TTL_SECS).await?; + } + Err(err) => { + send_error(&bot, chat_id, &err.to_string()).await?; + } + } + + Ok(()) +} + +async fn handle_sync_x_command( + bot: Bot, + msg: Message, + state: std::sync::Arc, +) -> Result<()> { + if state.config.sync_x.is_none() { + send_error( + &bot, + msg.chat.id, + "sync_x not configured. Set settings.sync_x.source_project_path (and optionally settings.sync_x.python_bin/work_dir).", + ) + .await?; + return Ok(()); + } + + let prompt_text = + "Paste the Cloudflare cookie header string from x.com (must include auth_token and ct0)."; + let sent = bot.send_message(msg.chat.id, prompt_text).await?; + state.sync_x_cookie_prompts.lock().await.insert( + msg.chat.id.0, + SyncXCookiePrompt { + prompt_message_id: sent.id, + expires_at: now_ts() + SYNC_X_PROMPT_TTL_SECS, + }, + ); + Ok(()) +} + +async fn handle_sync_x_cookie_response( + bot: &Bot, + chat_id: ChatId, + message_id: MessageId, + state: &std::sync::Arc, + text: &str, + prompt: SyncXCookiePrompt, +) -> Result<()> { + let cookie_header = text.trim(); + if cookie_header.is_empty() { + send_error( + bot, + chat_id, + "Cookie header is empty. Paste the full header string.", + ) + .await?; + state.sync_x_cookie_prompts.lock().await.insert( + chat_id.0, + SyncXCookiePrompt { + prompt_message_id: prompt.prompt_message_id, + expires_at: now_ts() + SYNC_X_PROMPT_TTL_SECS, + }, + ); + let _ = bot.delete_message(chat_id, message_id).await; + return Ok(()); + } + + let _ = bot.delete_message(chat_id, prompt.prompt_message_id).await; + let _ = bot.delete_message(chat_id, message_id).await; + + let status_msg = bot.send_message(chat_id, "Syncing X bookmarks...").await?; + let config = state.config.clone(); + let cookie_header = cookie_header.to_string(); + let outcome = tokio::task::spawn_blocking(move || run_sync_x(&config, &cookie_header)) + .await + .context("sync_x task failed")?; + let _ = bot.delete_message(chat_id, status_msg.id).await; + + match outcome { + Ok(sync_outcome) => { + if sync_outcome.extracted_count == 0 { + send_ephemeral(bot, chat_id, "No X bookmarks found.", ACK_TTL_SECS).await?; + } else { + let text = format!( + "X sync complete: extracted {}, added {}, skipped {} duplicates.", + sync_outcome.extracted_count, + sync_outcome.added_count, + sync_outcome.duplicate_count + ); + send_message_with_delete_button(bot, chat_id, text).await?; + } + } + Err(err) => { + send_error(bot, chat_id, &format!("sync_x failed: {}", err)).await?; + } + } + + Ok(()) +} + +async fn handle_undos_command( + bot: Bot, + msg: Message, + state: std::sync::Arc, +) -> Result<()> { + let (records, undo_snapshot) = { + let mut undo = state.undo.lock().await; + prune_undo(&mut undo); + let snapshot = undo.clone(); + (undo.clone(), snapshot) + }; + save_undo(&state.undo_path, &undo_snapshot)?; + + if records.is_empty() { + send_ephemeral(&bot, msg.chat.id, "No undos.", ACK_TTL_SECS).await?; + return Ok(()); + } + + let session_id = short_id(); + let (text, kb) = build_undos_view(&session_id, &records); + let sent = bot.send_message(msg.chat.id, text).reply_markup(kb).await?; + let session = UndoSession { + chat_id: msg.chat.id.0, + message_id: sent.id, + records, + }; + state.undo_sessions.lock().await.insert(session_id, session); + Ok(()) +} + +pub(crate) async fn handle_single_item( + bot: Bot, + chat_id: ChatId, + state: std::sync::Arc, + text: &str, + source_message_id: Option, +) -> Result<()> { + let entry = EntryBlock::from_text(text); + let op = QueuedOp { + kind: QueuedOpKind::Add, + entry: entry.block_string(), + resource_path: None, + updated_entry: None, + }; + + match apply_user_op(&state, &op).await? { + UserOpOutcome::Applied(ApplyOutcome::Applied) => { + send_ephemeral(&bot, chat_id, "Saved.", ACK_TTL_SECS).await?; + if let Some(message_id) = source_message_id { + let _ = bot.delete_message(chat_id, message_id).await; + } + } + UserOpOutcome::Applied(ApplyOutcome::Duplicate) => { + send_ephemeral(&bot, chat_id, "Already saved.", ACK_TTL_SECS).await?; + if let Some(message_id) = source_message_id { + let _ = bot.delete_message(chat_id, message_id).await; + } + } + UserOpOutcome::Applied(ApplyOutcome::NotFound) => { + // Not used for add. + } + UserOpOutcome::Queued => { + send_error(&bot, chat_id, "Write failed; queued for retry.").await?; + } + } + + Ok(()) +} + +async fn handle_multi_item( + bot: Bot, + chat_id: ChatId, + source_message_id: MessageId, + state: std::sync::Arc, + text: &str, +) -> Result<()> { + let items = split_items(text); + if items.is_empty() { + send_error(&bot, chat_id, "No items found.").await?; + return Ok(()); + } + + let picker_id = short_id(); + let selected = vec![false; items.len()]; + let view_text = build_picker_text(&items, &selected); + let kb = build_picker_keyboard(&picker_id, &selected); + let sent = bot + .send_message(chat_id, view_text) + .reply_markup(kb) + .await?; + + let picker = PickerState { + id: picker_id.clone(), + chat_id: chat_id.0, + message_id: sent.id, + items, + selected, + source_message_id, + }; + state.pickers.lock().await.insert(picker_id, picker); + Ok(()) +} + +async fn handle_add_command( + bot: Bot, + msg: Message, + state: std::sync::Arc, + text: &str, +) -> Result<()> { + let prompt_id = short_id(); + let kb = build_add_prompt_keyboard(&prompt_id); + let prompt_text = "Add to reading list or resources?"; + let sent = bot + .send_message(msg.chat.id, prompt_text) + .reply_markup(kb) + .await?; + + let prompt = AddPrompt { + chat_id: msg.chat.id.0, + message_id: sent.id, + text: text.to_string(), + source_message_id: msg.id, + }; + state.add_prompts.lock().await.insert(prompt_id, prompt); + Ok(()) +} + +pub(crate) async fn start_resource_picker( + bot: &Bot, + chat_id: ChatId, + state: &std::sync::Arc, + text: &str, + source_message_id: Option, +) -> Result<()> { + let files = list_resource_files(&state.config.resources_path)?; + let picker_id = short_id(); + let kb = build_resource_picker_keyboard(&picker_id, &files); + let prompt_text = if files.is_empty() { + "No resource files found. Create a new one?" + } else { + "Choose a resource file:" + }; + let sent = bot + .send_message(chat_id, prompt_text) + .reply_markup(kb) + .await?; + + let picker = ResourcePickerState { + chat_id: chat_id.0, + message_id: sent.id, + text: text.to_string(), + source_message_id, + files, + }; + state + .resource_pickers + .lock() + .await + .insert(picker_id, picker); + Ok(()) +} + +pub(crate) async fn add_resource_from_text( + bot: &Bot, + chat_id: ChatId, + state: &std::sync::Arc, + resource_path: PathBuf, + text: &str, + source_message_id: Option, +) -> Result<()> { + let entry_block = resource_block_from_text(text); + let op = QueuedOp { + kind: QueuedOpKind::AddResource, + entry: entry_block, + resource_path: Some(resource_path), + updated_entry: None, + }; + + match apply_user_op(state, &op).await? { + UserOpOutcome::Applied(ApplyOutcome::Applied) => { + send_ephemeral(bot, chat_id, "Added to resources.", ACK_TTL_SECS).await?; + if let Some(message_id) = source_message_id { + let _ = bot.delete_message(chat_id, message_id).await; + } + } + UserOpOutcome::Applied(ApplyOutcome::Duplicate) => { + send_ephemeral(bot, chat_id, "Already in resources.", ACK_TTL_SECS).await?; + if let Some(message_id) = source_message_id { + let _ = bot.delete_message(chat_id, message_id).await; + } + } + UserOpOutcome::Applied(ApplyOutcome::NotFound) => {} + UserOpOutcome::Queued => { + send_error(bot, chat_id, "Write failed; queued for retry.").await?; + } + } + + Ok(()) +} + +async fn handle_resource_filename_response( + bot: &Bot, + chat_id: ChatId, + message_id: MessageId, + state: &std::sync::Arc, + text: &str, + prompt: ResourceFilenamePrompt, +) -> Result<()> { + let filename = match sanitize_resource_filename(text) { + Ok(name) => name, + Err(err) => { + send_error(bot, chat_id, &err.to_string()).await?; + let mut prompts = state.resource_filename_prompts.lock().await; + prompts.insert( + chat_id.0, + ResourceFilenamePrompt { + expires_at: now_ts() + RESOURCE_PROMPT_TTL_SECS, + ..prompt + }, + ); + let _ = bot.delete_message(chat_id, message_id).await; + return Ok(()); + } + }; + + let resource_path = state.config.resources_path.join(filename); + add_resource_from_text( + bot, + chat_id, + state, + resource_path, + &prompt.text, + prompt.source_message_id.clone(), + ) + .await?; + + let _ = bot.delete_message(chat_id, prompt.prompt_message_id).await; + let _ = bot.delete_message(chat_id, message_id).await; + Ok(()) +} + +async fn start_download_picker( + bot: &Bot, + chat_id: ChatId, + state: &std::sync::Arc, + links: Vec, +) -> Result<()> { + let picker_id = short_id(); + let text = build_download_picker_text(&links); + let kb = build_download_picker_keyboard(&picker_id, &links); + let sent = bot.send_message(chat_id, text).reply_markup(kb).await?; + let picker = DownloadPickerState { + chat_id: chat_id.0, + message_id: sent.id, + links, + mode: DownloadPickerMode::Links, + }; + state + .download_pickers + .lock() + .await + .insert(picker_id, picker); + Ok(()) +} + +async fn handle_download_link_response( + bot: &Bot, + chat_id: ChatId, + message_id: MessageId, + state: &std::sync::Arc, + text: &str, + prompt: DownloadLinkPrompt, +) -> Result<()> { + let new_links = extract_links(text); + if new_links.is_empty() { + send_error(bot, chat_id, "No links found. Send a URL.").await?; + let mut prompts = state.download_link_prompts.lock().await; + prompts.insert( + chat_id.0, + DownloadLinkPrompt { + expires_at: now_ts() + DOWNLOAD_PROMPT_TTL_SECS, + ..prompt + }, + ); + let _ = bot.delete_message(chat_id, message_id).await; + return Ok(()); + } + + let mut links = prompt.links.clone(); + for link in new_links { + if !links.contains(&link) { + links.push(link); + } + } + start_download_picker(bot, chat_id, state, links).await?; + let _ = bot.delete_message(chat_id, prompt.prompt_message_id).await; + let _ = bot.delete_message(chat_id, message_id).await; + Ok(()) +} + +async fn handle_finish_title_response( + bot: &Bot, + chat_id: ChatId, + message_id: MessageId, + state: &std::sync::Arc, + text: &str, + prompt: FinishTitlePrompt, +) -> Result<()> { + let title = text.lines().next().unwrap_or("").trim(); + if title.is_empty() { + send_error(bot, chat_id, "Provide a title.").await?; + let mut prompts = state.finish_title_prompts.lock().await; + prompts.insert( + chat_id.0, + FinishTitlePrompt { + expires_at: now_ts() + FINISH_TITLE_PROMPT_TTL_SECS, + ..prompt + }, + ); + let _ = bot.delete_message(chat_id, message_id).await; + return Ok(()); + } + + let updated_entry = entry_with_title(&prompt.entry, title, &prompt.link); + let mut session = { + let mut sessions = state.sessions.lock().await; + let session = match sessions.remove(&prompt.session_id) { + Some(session) => session, + None => { + let _ = bot.delete_message(chat_id, prompt.prompt_message_id).await; + let _ = bot.delete_message(chat_id, message_id).await; + return Ok(()); + } + }; + if session.chat_id != prompt.chat_id { + sessions.insert(prompt.session_id.clone(), session); + let _ = bot.delete_message(chat_id, prompt.prompt_message_id).await; + let _ = bot.delete_message(chat_id, message_id).await; + return Ok(()); + } + session + }; + + let entry_index = session + .entries + .iter() + .position(|entry| entry.block_string() == prompt.entry); + let Some(entry_index) = entry_index else { + state + .sessions + .lock() + .await + .insert(prompt.session_id.clone(), session); + send_error(bot, chat_id, "Item not found.").await?; + let _ = bot.delete_message(chat_id, prompt.prompt_message_id).await; + let _ = bot.delete_message(chat_id, message_id).await; + return Ok(()); + }; + + let op = QueuedOp { + kind: QueuedOpKind::MoveToFinishedUpdated, + entry: prompt.entry.clone(), + resource_path: None, + updated_entry: Some(updated_entry.clone()), + }; + + match apply_user_op(state, &op).await? { + UserOpOutcome::Applied(ApplyOutcome::Applied) => { + session.entries.remove(entry_index); + session.view = prompt.return_to.clone(); + let peeked_snapshot = state.peeked.lock().await.clone(); + normalize_peek_view(&mut session, &peeked_snapshot); + send_ephemeral(bot, chat_id, "Moved.", ACK_TTL_SECS).await?; + let _ = add_undo(state, UndoKind::MoveToFinished, updated_entry).await?; + } + UserOpOutcome::Applied(ApplyOutcome::NotFound) => { + send_error(bot, chat_id, "Item not found.").await?; + } + UserOpOutcome::Applied(ApplyOutcome::Duplicate) => {} + UserOpOutcome::Queued => { + send_error(bot, chat_id, "Write failed; queued for retry.").await?; + } + } + + let peeked_snapshot = state.peeked.lock().await.clone(); + let (text, kb) = render_list_view(&session.id, &session, &peeked_snapshot, &state.config); + if let Some(list_message_id) = session.message_id { + bot.edit_message_text(chat_id, list_message_id, text) + .reply_markup(kb) + .await?; + } else { + let sent = bot.send_message(chat_id, text).reply_markup(kb).await?; + session.message_id = Some(sent.id); + } + if let Err(err) = + refresh_embedded_media_for_view(bot, chat_id, state, &mut session, &peeked_snapshot).await + { + error!("send embedded media failed: {:#}", err); + } + state + .sessions + .lock() + .await + .insert(prompt.session_id.clone(), session); + state + .active_sessions + .lock() + .await + .insert(chat_id.0, prompt.session_id.clone()); + + let _ = bot.delete_message(chat_id, prompt.prompt_message_id).await; + let _ = bot.delete_message(chat_id, message_id).await; + Ok(()) +} diff --git a/src/tests.rs b/src/tests.rs new file mode 100644 index 0000000..f006694 --- /dev/null +++ b/src/tests.rs @@ -0,0 +1,408 @@ +use super::*; +use std::collections::HashSet; +use std::os::unix::process::ExitStatusExt; + +fn entry(text: &str) -> EntryBlock { + EntryBlock::from_text(text) +} + +fn test_config() -> Config { + Config { + token: "token".to_string(), + user_id: 1, + read_later_path: PathBuf::from("/tmp/read-later.md"), + finished_path: PathBuf::from("/tmp/finished.md"), + resources_path: PathBuf::from("/tmp/resources"), + media_dir: PathBuf::from("/tmp/media"), + data_dir: PathBuf::from("/tmp/data"), + retry_interval_seconds: None, + sync: None, + sync_x: None, + } +} + +#[test] +fn normalize_markdown_links_replaces_single_link() { + let input = "See [post](https://example.com/post) now"; + let (out, changed) = normalize_markdown_links(input); + assert!(changed); + assert_eq!(out, "See https://example.com/post now"); +} + +#[test] +fn normalize_markdown_links_replaces_multiple_links() { + let input = "[a](one) and [b](two)"; + let (out, changed) = normalize_markdown_links(input); + assert!(changed); + assert_eq!(out, "one and two"); +} + +#[test] +fn normalize_markdown_links_ignores_invalid_markup() { + let input = "broken [link](missing"; + let (out, changed) = normalize_markdown_links(input); + assert!(!changed); + assert_eq!(out, input); +} + +#[test] +fn normalize_entry_markdown_links_updates_entry() { + let entry = EntryBlock::from_text("foo [x](url)\nbar"); + let normalized = normalize_entry_markdown_links(&entry).unwrap(); + let block = normalized.block_string(); + assert!(block.contains("foo url")); + assert!(!block.contains("[x]")); +} + +#[test] +fn peek_indices_filters_and_pages() { + let entries: Vec = (0..6).map(|i| entry(&format!("item {}", i))).collect(); + let mut peeked = HashSet::new(); + peeked.insert(entries[1].block_string()); + peeked.insert(entries[3].block_string()); + + assert_eq!(count_unpeeked_entries(&entries, &peeked), 4); + assert_eq!( + peek_indices(&entries, &peeked, ListMode::Top, 0), + vec![0, 2, 4] + ); + assert_eq!(peek_indices(&entries, &peeked, ListMode::Top, 1), vec![5]); + assert_eq!( + peek_indices(&entries, &peeked, ListMode::Bottom, 0), + vec![5, 4, 2] + ); + assert_eq!( + peek_indices(&entries, &peeked, ListMode::Bottom, 1), + vec![0] + ); +} + +#[test] +fn search_peek_indices_ignore_peeked_entries() { + let entries: Vec = (0..4).map(|i| entry(&format!("match {}", i))).collect(); + let session = ListSession { + id: "session".to_string(), + chat_id: 0, + kind: SessionKind::Search { + query: "match".to_string(), + }, + entries: entries.clone(), + view: ListView::Peek { + mode: ListMode::Top, + page: 0, + }, + seen_random: HashSet::new(), + message_id: None, + sent_media_message_ids: Vec::new(), + }; + let mut peeked = HashSet::new(); + for entry in &entries { + peeked.insert(entry.block_string()); + } + + assert_eq!(count_visible_entries(&session, &peeked), 4); + assert_eq!( + peek_indices_for_session(&session, &peeked, ListMode::Top, 0), + vec![0, 1, 2] + ); + assert_eq!( + peek_indices_for_session(&session, &peeked, ListMode::Top, 1), + vec![3] + ); +} + +#[test] +fn build_peek_view_shows_all_peeked_message() { + let entries = vec![entry("one"), entry("two")]; + let session = ListSession { + id: "session".to_string(), + chat_id: 0, + kind: SessionKind::List, + entries: entries.clone(), + view: ListView::Peek { + mode: ListMode::Top, + page: 0, + }, + seen_random: HashSet::new(), + message_id: None, + sent_media_message_ids: Vec::new(), + }; + let mut peeked = HashSet::new(); + for entry in &entries { + peeked.insert(entry.block_string()); + } + let config = test_config(); + let (text, _kb) = build_peek_view("session", &session, ListMode::Top, 0, &peeked, &config); + assert!(text.contains("Everything's been peeked already.")); +} + +#[test] +fn format_embedded_references_labels_images_and_files() { + let temp = TempDir::new().unwrap(); + let media_dir = temp.path().join("media"); + fs::create_dir_all(&media_dir).unwrap(); + fs::write(media_dir.join("image-1.jpg"), b"x").unwrap(); + fs::write(media_dir.join("doc-1.pdf"), b"x").unwrap(); + + let mut config = test_config(); + config.media_dir = media_dir; + + let lines = vec![ + "![[image-1.jpg]] and ![[doc-1.pdf]]".to_string(), + "repeat ![[image-1.jpg]]".to_string(), + ]; + let rendered = format_embedded_references_for_lines(&lines, &config); + + assert_eq!(rendered[0], "image #1 and file #2"); + assert_eq!(rendered[1], "repeat image #1"); +} + +#[test] +fn format_embedded_references_labels_videos() { + let temp = TempDir::new().unwrap(); + let media_dir = temp.path().join("media"); + fs::create_dir_all(&media_dir).unwrap(); + fs::write(media_dir.join("clip.mp4"), b"x").unwrap(); + + let mut config = test_config(); + config.media_dir = media_dir; + + let lines = vec!["Watch ![[clip.mp4]]".to_string()]; + let rendered = format_embedded_references_for_lines(&lines, &config); + + assert_eq!(rendered[0], "Watch video #1"); +} + +#[test] +fn human_size_formats_units() { + assert_eq!(human_size(999), "999 B"); + assert_eq!(human_size(2048), "2.0 KB"); + assert_eq!(human_size(5 * 1024 * 1024), "5.0 MB"); +} + +#[test] +fn build_download_quality_text_lists_options() { + let options = vec![ + DownloadQualityOption { + label: "Best".to_string(), + format_selector: "bestvideo+bestaudio/best".to_string(), + }, + DownloadQualityOption { + label: "720p mp4".to_string(), + format_selector: "22".to_string(), + }, + ]; + let text = + build_download_quality_text("https://example.com/video", DownloadAction::Send, &options); + assert!(text.contains("Choose quality to send")); + assert!(text.contains("1: Best")); + assert!(text.contains("2: 720p mp4")); +} + +#[test] +fn embedded_lines_for_peek_use_preview_only() { + let entry = EntryBlock::from_text("first line\nsecond line\n![[image-2.jpg]]"); + let session = ListSession { + id: "session".to_string(), + chat_id: 0, + kind: SessionKind::List, + entries: vec![entry], + view: ListView::Peek { + mode: ListMode::Top, + page: 0, + }, + seen_random: HashSet::new(), + message_id: None, + sent_media_message_ids: Vec::new(), + }; + + let lines = embedded_lines_for_view(&session, &HashSet::new()); + assert_eq!( + lines, + vec!["first line".to_string(), "second line...".to_string()] + ); +} + +#[test] +fn build_undos_view_includes_labels_and_previews() { + let record_one = UndoRecord { + id: "one".to_string(), + kind: UndoKind::Delete, + entry: entry("alpha").block_string(), + expires_at: now_ts() + 10, + }; + let record_two = UndoRecord { + id: "two".to_string(), + kind: UndoKind::MoveToFinished, + entry: entry("beta").block_string(), + expires_at: now_ts() + 10, + }; + let (text, _kb) = build_undos_view("session", &[record_one, record_two]); + assert!(text.contains("Undos (2)")); + assert!(text.contains("1) Deleted")); + assert!(text.contains("2) Moved to finished")); + assert!(text.contains("alpha")); + assert!(text.contains("beta")); +} + +#[test] +fn displayed_indices_for_selected_view() { + let entries = vec![entry("one"), entry("two"), entry("three")]; + let session = ListSession { + id: "session".to_string(), + chat_id: 0, + kind: SessionKind::List, + entries, + view: ListView::Selected { + return_to: Box::new(ListView::Menu), + index: 1, + }, + seen_random: HashSet::new(), + message_id: None, + sent_media_message_ids: Vec::new(), + }; + let peeked = HashSet::new(); + assert_eq!(displayed_indices_for_view(&session, &peeked), vec![1]); +} + +#[test] +fn norm_target_index_prefers_single_peek_item() { + let entries = vec![entry("one"), entry("two")]; + let mut peeked = HashSet::new(); + peeked.insert(entries[0].block_string()); + let session = ListSession { + id: "session".to_string(), + chat_id: 0, + kind: SessionKind::List, + entries: entries.clone(), + view: ListView::Peek { + mode: ListMode::Top, + page: 0, + }, + seen_random: HashSet::new(), + message_id: None, + sent_media_message_ids: Vec::new(), + }; + assert_eq!(norm_target_index(&session, &peeked), Some(1)); + + let session_multi = ListSession { entries, ..session }; + let empty_peeked = HashSet::new(); + assert_eq!(norm_target_index(&session_multi, &empty_peeked), None); +} + +#[test] +fn command_keywords_are_case_insensitive() { + assert!(crate::message_handlers::is_norm_message("NoRm")); + assert!(crate::message_handlers::is_instant_delete_message("DEL")); + assert!(crate::message_handlers::is_instant_delete_message("Delete")); + assert!(!crate::message_handlers::is_instant_delete_message( + "remove" + )); +} + +#[test] +fn quick_select_index_supports_top_last_random() { + assert_eq!(quick_select_index(0, QuickSelectMode::Top), None); + assert_eq!(quick_select_index(4, QuickSelectMode::Top), Some(0)); + assert_eq!(quick_select_index(4, QuickSelectMode::Last), Some(3)); + let random = quick_select_index(4, QuickSelectMode::Random).unwrap(); + assert!(random < 4); +} + +#[test] +fn extract_https_username_from_remote() { + assert_eq!( + extract_https_username("https://user@host/repo.git"), + Some("user".to_string()) + ); + assert_eq!( + extract_https_username("https://user:pass@host/repo.git"), + Some("user".to_string()) + ); + assert_eq!(extract_https_username("https://host/repo.git"), None); + assert_eq!(extract_https_username("git@host:repo.git"), None); +} + +#[test] +fn read_token_file_trims_whitespace() { + let mut file = NamedTempFile::new().unwrap(); + file.write_all(b" token\n").unwrap(); + let token = read_token_file(file.path()).unwrap(); + assert_eq!(token, "token"); +} + +#[test] +fn parse_pull_mode_accepts_theirs() { + assert!(matches!(parse_pull_mode(""), Ok(PullMode::FastForward))); + assert!(matches!(parse_pull_mode("theirs"), Ok(PullMode::Theirs))); + assert!(parse_pull_mode("unknown").is_err()); +} + +#[test] +fn is_already_up_to_date_detects_output() { + let output = GitOutput { + status: std::process::ExitStatus::from_raw(0), + stdout: "Already up to date.".to_string(), + stderr: String::new(), + }; + assert!(is_already_up_to_date(&output)); +} + +#[test] +fn is_push_up_to_date_detects_output() { + let output = GitOutput { + status: std::process::ExitStatus::from_raw(0), + stdout: "Everything up-to-date".to_string(), + stderr: String::new(), + }; + assert!(is_push_up_to_date(&output)); +} + +#[test] +fn read_sync_x_urls_keeps_unique_http_lines() { + let temp = TempDir::new().unwrap(); + let path = temp.path().join("bookmarks.txt"); + fs::write( + &path, + "https://a.example\n\nnot-a-url\nhttps://b.example\nhttps://a.example\n", + ) + .unwrap(); + let urls = read_sync_x_urls(&path).unwrap(); + assert_eq!( + urls, + vec![ + "https://a.example".to_string(), + "https://b.example".to_string() + ] + ); +} + +#[test] +fn prepend_urls_to_read_later_sync_preserves_input_order() { + let temp = TempDir::new().unwrap(); + let path = temp.path().join("read-later.md"); + fs::write(&path, "- https://already.example\n").unwrap(); + let urls = vec![ + "https://one.example".to_string(), + "https://two.example".to_string(), + "https://already.example".to_string(), + ]; + + let (added, duplicates) = prepend_urls_to_read_later_sync(&path, &urls).unwrap(); + assert_eq!(added, 2); + assert_eq!(duplicates, 1); + + let (_, entries) = read_entries(&path).unwrap(); + let blocks = entries + .iter() + .map(|entry| entry.block_string()) + .collect::>(); + assert_eq!( + blocks, + vec![ + "- https://one.example".to_string(), + "- https://two.example".to_string(), + "- https://already.example".to_string(), + ] + ); +}