Fix /list callback session loss on no-op edits

This commit is contained in:
TheGeneralist 2026-02-18 16:47:23 +01:00
parent b385ac5f21
commit 6e3b950083
Signed by: thegeneralist01
SSH key fingerprint: SHA256:pp9qddbCNmVNoSjevdvQvM5z0DHN7LTa8qBMbcMq/R4

View file

@ -15,8 +15,8 @@ use serde::{Deserialize, Serialize};
use teloxide::net::Download; use teloxide::net::Download;
use teloxide::prelude::*; use teloxide::prelude::*;
use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup, InputFile, Message, MessageId}; use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup, InputFile, Message, MessageId};
use tokio::sync::Mutex;
use tempfile::{NamedTempFile, TempDir, TempPath}; use tempfile::{NamedTempFile, TempDir, TempPath};
use tokio::sync::Mutex;
use uuid::Uuid; use uuid::Uuid;
const ACK_TTL_SECS: u64 = 5; const ACK_TTL_SECS: u64 = 5;
@ -303,8 +303,14 @@ struct ListSession {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
enum ListView { enum ListView {
Menu, Menu,
Peek { mode: ListMode, page: usize }, Peek {
Selected { return_to: Box<ListView>, index: usize }, mode: ListMode,
page: usize,
},
Selected {
return_to: Box<ListView>,
index: usize,
},
FinishConfirm { FinishConfirm {
selected: Box<ListView>, selected: Box<ListView>,
index: usize, index: usize,
@ -413,11 +419,7 @@ async fn main() -> Result<()> {
Ok(()) Ok(())
} }
async fn handle_message( async fn handle_message(bot: Bot, msg: Message, state: std::sync::Arc<AppState>) -> Result<()> {
bot: Bot,
msg: Message,
state: std::sync::Arc<AppState>,
) -> Result<()> {
let user_id = match msg.from() { let user_id = match msg.from() {
Some(user) => user.id.0, Some(user) => user.id.0,
None => return Ok(()), None => return Ok(()),
@ -484,8 +486,7 @@ async fn handle_message(
} }
if let Some(prompt) = pending_resource_prompt { if let Some(prompt) = pending_resource_prompt {
handle_resource_filename_response(&bot, msg.chat.id, msg.id, &state, &text, prompt) handle_resource_filename_response(&bot, msg.chat.id, msg.id, &state, &text, prompt).await?;
.await?;
return Ok(()); return Ok(());
} }
@ -654,7 +655,14 @@ async fn handle_media_message(
let dest_path = media_dir.join(&filename); let dest_path = media_dir.join(&filename);
download_telegram_file(bot, &photo.file.id, &dest_path).await?; download_telegram_file(bot, &photo.file.id, &dest_path).await?;
let entry_text = build_media_entry_text(&filename, caption.as_deref()); 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?; handle_single_item(
bot.clone(),
chat_id,
state.clone(),
&entry_text,
Some(msg.id),
)
.await?;
return Ok(true); return Ok(true);
} }
} }
@ -672,7 +680,14 @@ async fn handle_media_message(
let dest_path = media_dir.join(&filename); let dest_path = media_dir.join(&filename);
download_telegram_file(bot, &document.file.id, &dest_path).await?; download_telegram_file(bot, &document.file.id, &dest_path).await?;
let entry_text = build_media_entry_text(&filename, caption.as_deref()); 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?; handle_single_item(
bot.clone(),
chat_id,
state.clone(),
&entry_text,
Some(msg.id),
)
.await?;
return Ok(true); return Ok(true);
} }
@ -692,7 +707,14 @@ async fn handle_media_message(
let dest_path = media_dir.join(&filename); let dest_path = media_dir.join(&filename);
download_telegram_file(bot, &video.file.id, &dest_path).await?; download_telegram_file(bot, &video.file.id, &dest_path).await?;
let entry_text = build_media_entry_text(&filename, caption.as_deref()); 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?; handle_single_item(
bot.clone(),
chat_id,
state.clone(),
&entry_text,
Some(msg.id),
)
.await?;
return Ok(true); return Ok(true);
} }
@ -728,7 +750,8 @@ async fn handle_norm_message(
let target_index = match norm_target_index(&session, &peeked_snapshot) { let target_index = match norm_target_index(&session, &peeked_snapshot) {
Some(index) => index, Some(index) => index,
None => { None => {
state.sessions state
.sessions
.lock() .lock()
.await .await
.insert(session.id.clone(), session); .insert(session.id.clone(), session);
@ -741,7 +764,8 @@ async fn handle_norm_message(
let entry = match session.entries.get(target_index).cloned() { let entry = match session.entries.get(target_index).cloned() {
Some(entry) => entry, Some(entry) => entry,
None => { None => {
state.sessions state
.sessions
.lock() .lock()
.await .await
.insert(session.id.clone(), session); .insert(session.id.clone(), session);
@ -752,7 +776,8 @@ async fn handle_norm_message(
}; };
let Some(normalized_entry) = normalize_entry_markdown_links(&entry) else { let Some(normalized_entry) = normalize_entry_markdown_links(&entry) else {
state.sessions state
.sessions
.lock() .lock()
.await .await
.insert(session.id.clone(), session); .insert(session.id.clone(), session);
@ -797,7 +822,8 @@ async fn handle_norm_message(
} }
} }
state.sessions state
.sessions
.lock() .lock()
.await .await
.insert(session.id.clone(), session); .insert(session.id.clone(), session);
@ -834,7 +860,8 @@ async fn handle_instant_delete_message(
let target_index = match norm_target_index(&session, &peeked_snapshot) { let target_index = match norm_target_index(&session, &peeked_snapshot) {
Some(index) => index, Some(index) => index,
None => { None => {
state.sessions state
.sessions
.lock() .lock()
.await .await
.insert(session.id.clone(), session); .insert(session.id.clone(), session);
@ -847,7 +874,8 @@ async fn handle_instant_delete_message(
let entry_block = match session.entries.get(target_index).map(|e| e.block_string()) { let entry_block = match session.entries.get(target_index).map(|e| e.block_string()) {
Some(entry) => entry, Some(entry) => entry,
None => { None => {
state.sessions state
.sessions
.lock() .lock()
.await .await
.insert(session.id.clone(), session); .insert(session.id.clone(), session);
@ -898,7 +926,8 @@ async fn handle_instant_delete_message(
} }
} }
state.sessions state
.sessions
.lock() .lock()
.await .await
.insert(session.id.clone(), session); .insert(session.id.clone(), session);
@ -966,10 +995,7 @@ async fn handle_list_command(
}; };
let (text, kb) = build_menu_view(&session_id, &session); let (text, kb) = build_menu_view(&session_id, &session);
let sent = bot let sent = bot.send_message(msg.chat.id, text).reply_markup(kb).await?;
.send_message(msg.chat.id, text)
.reply_markup(kb)
.await?;
session.message_id = Some(sent.id); session.message_id = Some(sent.id);
state state
.sessions .sessions
@ -1017,10 +1043,7 @@ async fn handle_search_command(
let peeked_snapshot = state.peeked.lock().await.clone(); let peeked_snapshot = state.peeked.lock().await.clone();
let (text, kb) = render_list_view(&session_id, &session, &peeked_snapshot, &state.config); let (text, kb) = render_list_view(&session_id, &session, &peeked_snapshot, &state.config);
let sent = bot let sent = bot.send_message(msg.chat.id, text).reply_markup(kb).await?;
.send_message(msg.chat.id, text)
.reply_markup(kb)
.await?;
session.message_id = Some(sent.id); session.message_id = Some(sent.id);
state state
.sessions .sessions
@ -1216,7 +1239,8 @@ async fn handle_sync_x_command(
return Ok(()); return Ok(());
} }
let prompt_text = "Paste the Cloudflare cookie header string from x.com (must include auth_token and ct0)."; 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?; let sent = bot.send_message(msg.chat.id, prompt_text).await?;
state.sync_x_cookie_prompts.lock().await.insert( state.sync_x_cookie_prompts.lock().await.insert(
msg.chat.id.0, msg.chat.id.0,
@ -1238,7 +1262,12 @@ async fn handle_sync_x_cookie_response(
) -> Result<()> { ) -> Result<()> {
let cookie_header = text.trim(); let cookie_header = text.trim();
if cookie_header.is_empty() { if cookie_header.is_empty() {
send_error(bot, chat_id, "Cookie header is empty. Paste the full header string.").await?; send_error(
bot,
chat_id,
"Cookie header is empty. Paste the full header string.",
)
.await?;
state.sync_x_cookie_prompts.lock().await.insert( state.sync_x_cookie_prompts.lock().await.insert(
chat_id.0, chat_id.0,
SyncXCookiePrompt { SyncXCookiePrompt {
@ -1268,7 +1297,9 @@ async fn handle_sync_x_cookie_response(
} else { } else {
let text = format!( let text = format!(
"X sync complete: extracted {}, added {}, skipped {} duplicates.", "X sync complete: extracted {}, added {}, skipped {} duplicates.",
sync_outcome.extracted_count, sync_outcome.added_count, sync_outcome.duplicate_count sync_outcome.extracted_count,
sync_outcome.added_count,
sync_outcome.duplicate_count
); );
bot.send_message(chat_id, text).await?; bot.send_message(chat_id, text).await?;
} }
@ -1307,11 +1338,7 @@ async fn handle_undos_command(
message_id: sent.id, message_id: sent.id,
records, records,
}; };
state state.undo_sessions.lock().await.insert(session_id, session);
.undo_sessions
.lock()
.await
.insert(session_id, session);
Ok(()) Ok(())
} }
@ -1371,7 +1398,10 @@ async fn handle_multi_item(
let selected = vec![false; items.len()]; let selected = vec![false; items.len()];
let view_text = build_picker_text(&items, &selected); let view_text = build_picker_text(&items, &selected);
let kb = build_picker_keyboard(&picker_id, &selected); let kb = build_picker_keyboard(&picker_id, &selected);
let sent = bot.send_message(chat_id, view_text).reply_markup(kb).await?; let sent = bot
.send_message(chat_id, view_text)
.reply_markup(kb)
.await?;
let picker = PickerState { let picker = PickerState {
id: picker_id.clone(), id: picker_id.clone(),
@ -1394,7 +1424,10 @@ async fn handle_add_command(
let prompt_id = short_id(); let prompt_id = short_id();
let kb = build_add_prompt_keyboard(&prompt_id); let kb = build_add_prompt_keyboard(&prompt_id);
let prompt_text = "Add to reading list or resources?"; let prompt_text = "Add to reading list or resources?";
let sent = bot.send_message(msg.chat.id, prompt_text).reply_markup(kb).await?; let sent = bot
.send_message(msg.chat.id, prompt_text)
.reply_markup(kb)
.await?;
let prompt = AddPrompt { let prompt = AddPrompt {
chat_id: msg.chat.id.0, chat_id: msg.chat.id.0,
@ -1495,7 +1528,10 @@ async fn start_resource_picker(
} else { } else {
"Choose a resource file:" "Choose a resource file:"
}; };
let sent = bot.send_message(chat_id, prompt_text).reply_markup(kb).await?; let sent = bot
.send_message(chat_id, prompt_text)
.reply_markup(kb)
.await?;
let picker = ResourcePickerState { let picker = ResourcePickerState {
chat_id: chat_id.0, chat_id: chat_id.0,
@ -1689,9 +1725,7 @@ async fn handle_resource_filename_response(
) )
.await?; .await?;
let _ = bot let _ = bot.delete_message(chat_id, prompt.prompt_message_id).await;
.delete_message(chat_id, prompt.prompt_message_id)
.await;
let _ = bot.delete_message(chat_id, message_id).await; let _ = bot.delete_message(chat_id, message_id).await;
Ok(()) Ok(())
} }
@ -1783,8 +1817,7 @@ async fn handle_download_callback(
DownloadAction::Send, DownloadAction::Send,
&options, &options,
); );
let kb = let kb = build_download_quality_keyboard(&picker_id, &options);
build_download_quality_keyboard(&picker_id, &options);
bot.edit_message_text(message.chat.id, message.id, text) bot.edit_message_text(message.chat.id, message.id, text)
.reply_markup(kb) .reply_markup(kb)
.await?; .await?;
@ -1828,8 +1861,7 @@ async fn handle_download_callback(
DownloadAction::Save, DownloadAction::Save,
&options, &options,
); );
let kb = let kb = build_download_quality_keyboard(&picker_id, &options);
build_download_quality_keyboard(&picker_id, &options);
bot.edit_message_text(message.chat.id, message.id, text) bot.edit_message_text(message.chat.id, message.id, text)
.reply_markup(kb) .reply_markup(kb)
.await?; .await?;
@ -1864,9 +1896,10 @@ async fn handle_download_callback(
}, },
) = (selected, &picker.mode) ) = (selected, &picker.mode)
{ {
if let (Some(link), Some(option)) = if let (Some(link), Some(option)) = (
(picker.links.get(*link_index).cloned(), options.get(selected).cloned()) picker.links.get(*link_index).cloned(),
{ options.get(selected).cloned(),
) {
match action { match action {
DownloadAction::Send => { DownloadAction::Send => {
match download_and_send_link( match download_and_send_link(
@ -1887,20 +1920,13 @@ async fn handle_download_callback(
} }
} }
DownloadAction::Save => { DownloadAction::Save => {
match download_and_save_link( match download_and_save_link(&state, &link, &option.format_selector)
&state,
&link,
&option.format_selector,
)
.await .await
{ {
Ok(path) => { Ok(path) => {
let note = format!("Saved to {}", path.display()); let note = format!("Saved to {}", path.display());
let kb = InlineKeyboardMarkup::new(vec![vec![ let kb = InlineKeyboardMarkup::new(vec![vec![
InlineKeyboardButton::callback( InlineKeyboardButton::callback("Delete message", "msgdel"),
"Delete message",
"msgdel",
),
]]); ]]);
bot.send_message(message.chat.id, note) bot.send_message(message.chat.id, note)
.reply_markup(kb) .reply_markup(kb)
@ -2007,9 +2033,7 @@ async fn handle_download_link_response(
} }
} }
start_download_picker(bot, chat_id, state, links).await?; start_download_picker(bot, chat_id, state, links).await?;
let _ = bot let _ = bot.delete_message(chat_id, prompt.prompt_message_id).await;
.delete_message(chat_id, prompt.prompt_message_id)
.await;
let _ = bot.delete_message(chat_id, message_id).await; let _ = bot.delete_message(chat_id, message_id).await;
Ok(()) Ok(())
} }
@ -2051,18 +2075,14 @@ async fn handle_finish_title_response(
let session = match sessions.remove(&prompt.session_id) { let session = match sessions.remove(&prompt.session_id) {
Some(session) => session, Some(session) => session,
None => { None => {
let _ = bot let _ = bot.delete_message(chat_id, prompt.prompt_message_id).await;
.delete_message(chat_id, prompt.prompt_message_id)
.await;
let _ = bot.delete_message(chat_id, message_id).await; let _ = bot.delete_message(chat_id, message_id).await;
return Ok(()); return Ok(());
} }
}; };
if session.chat_id != prompt.chat_id { if session.chat_id != prompt.chat_id {
sessions.insert(prompt.session_id.clone(), session); sessions.insert(prompt.session_id.clone(), session);
let _ = bot let _ = bot.delete_message(chat_id, prompt.prompt_message_id).await;
.delete_message(chat_id, prompt.prompt_message_id)
.await;
let _ = bot.delete_message(chat_id, message_id).await; let _ = bot.delete_message(chat_id, message_id).await;
return Ok(()); return Ok(());
} }
@ -2080,9 +2100,7 @@ async fn handle_finish_title_response(
.await .await
.insert(prompt.session_id.clone(), session); .insert(prompt.session_id.clone(), session);
send_error(bot, chat_id, "Item not found.").await?; send_error(bot, chat_id, "Item not found.").await?;
let _ = bot let _ = bot.delete_message(chat_id, prompt.prompt_message_id).await;
.delete_message(chat_id, prompt.prompt_message_id)
.await;
let _ = bot.delete_message(chat_id, message_id).await; let _ = bot.delete_message(chat_id, message_id).await;
return Ok(()); return Ok(());
}; };
@ -2138,9 +2156,7 @@ async fn handle_finish_title_response(
.await .await
.insert(chat_id.0, prompt.session_id.clone()); .insert(chat_id.0, prompt.session_id.clone());
let _ = bot let _ = bot.delete_message(chat_id, prompt.prompt_message_id).await;
.delete_message(chat_id, prompt.prompt_message_id)
.await;
let _ = bot.delete_message(chat_id, message_id).await; let _ = bot.delete_message(chat_id, message_id).await;
Ok(()) Ok(())
} }
@ -2186,7 +2202,10 @@ async fn handle_list_callback(
}; };
let peeked_snapshot = state.peeked.lock().await.clone(); 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 { match action {
"menu" => { "menu" => {
if matches!(&session.kind, SessionKind::List) { if matches!(&session.kind, SessionKind::List) {
@ -2232,15 +2251,19 @@ async fn handle_list_callback(
} }
"close" => { "close" => {
if matches!(&session.kind, SessionKind::Search { .. }) { if matches!(&session.kind, SessionKind::Search { .. }) {
delete_embedded_media_messages(&bot, message.chat.id, &session.sent_media_message_ids) delete_embedded_media_messages(
&bot,
message.chat.id,
&session.sent_media_message_ids,
)
.await; .await;
bot.delete_message(message.chat.id, message.id).await?; bot.delete_message(message.chat.id, message.id).await?;
let mut active = state.active_sessions.lock().await; let mut active = state.active_sessions.lock().await;
if active.get(&chat_id) == Some(&session.id) { if active.get(&chat_id) == Some(&session.id) {
active.remove(&chat_id); active.remove(&chat_id);
} }
bot.answer_callback_query(q.id).await?; close_session = true;
return Ok(()); refresh_list_view = false;
} }
} }
"random" => { "random" => {
@ -2338,7 +2361,8 @@ async fn handle_list_callback(
normalize_peek_view(&mut session, &peeked_snapshot); normalize_peek_view(&mut session, &peeked_snapshot);
send_ephemeral(&bot, message.chat.id, "Moved.", ACK_TTL_SECS) send_ephemeral(&bot, message.chat.id, "Moved.", ACK_TTL_SECS)
.await?; .await?;
let _ = add_undo(&state, UndoKind::MoveToFinished, entry_block).await?; let _ =
add_undo(&state, UndoKind::MoveToFinished, entry_block).await?;
} }
UserOpOutcome::Applied(ApplyOutcome::NotFound) => { UserOpOutcome::Applied(ApplyOutcome::NotFound) => {
send_error(&bot, message.chat.id, "Item not found.").await?; send_error(&bot, message.chat.id, "Item not found.").await?;
@ -2348,7 +2372,11 @@ async fn handle_list_callback(
session.view = *selected; session.view = *selected;
} }
UserOpOutcome::Queued => { UserOpOutcome::Queued => {
send_error(&bot, message.chat.id, "Write failed; queued for retry.") send_error(
&bot,
message.chat.id,
"Write failed; queued for retry.",
)
.await?; .await?;
session.view = *selected; session.view = *selected;
} }
@ -2409,6 +2437,7 @@ async fn handle_list_callback(
if let Some(entry) = session.entries.get(index) { if let Some(entry) = session.entries.get(index) {
let text = entry.display_lines().join("\n"); let text = entry.display_lines().join("\n");
start_resource_picker(&bot, message.chat.id, &state, &text, None).await?; start_resource_picker(&bot, message.chat.id, &state, &text, None).await?;
refresh_list_view = false;
} else { } else {
send_error(&bot, message.chat.id, "Item not found.").await?; send_error(&bot, message.chat.id, "Item not found.").await?;
} }
@ -2435,8 +2464,7 @@ async fn handle_list_callback(
{ {
if now_ts() > expires_at { if now_ts() > expires_at {
session.view = *selected; session.view = *selected;
send_error(&bot, message.chat.id, "Delete confirmation expired.") send_error(&bot, message.chat.id, "Delete confirmation expired.").await?;
.await?;
} else { } else {
session.view = ListView::DeleteConfirm { session.view = ListView::DeleteConfirm {
selected, selected,
@ -2457,8 +2485,7 @@ async fn handle_list_callback(
{ {
if now_ts() > expires_at { if now_ts() > expires_at {
session.view = *selected; session.view = *selected;
send_error(&bot, message.chat.id, "Delete confirmation expired.") send_error(&bot, message.chat.id, "Delete confirmation expired.").await?;
.await?;
} else { } else {
let entry_block = session.entries.get(index).map(|e| e.block_string()); let entry_block = session.entries.get(index).map(|e| e.block_string());
if let Some(entry_block) = entry_block { if let Some(entry_block) = entry_block {
@ -2506,17 +2533,53 @@ async fn handle_list_callback(
_ => {} _ => {}
} }
if close_session {
return Ok(());
}
if refresh_list_view {
session.message_id = Some(message.id); session.message_id = Some(message.id);
let (text, kb) = render_list_view(&session.id, &session, &peeked_snapshot, &state.config); let (text, kb) =
bot.edit_message_text(message.chat.id, message.id, text) render_list_view(&session.id, &session, &peeked_snapshot, &state.config);
match bot
.edit_message_text(message.chat.id, message.id, text)
.reply_markup(kb) .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?; .await?;
if let Err(err) = session.message_id = Some(sent.id);
refresh_embedded_media_for_view(&bot, message.chat.id, &state, &mut session, &peeked_snapshot) }
}
if let Err(err) = refresh_embedded_media_for_view(
&bot,
message.chat.id,
&state,
&mut session,
&peeked_snapshot,
)
.await .await
{ {
error!("send embedded media failed: {:#}", err); error!("send embedded media failed: {:#}", err);
} }
}
Ok(())
}
.await;
if !close_session {
state state
.sessions .sessions
.lock() .lock()
@ -2527,8 +2590,30 @@ async fn handle_list_callback(
.lock() .lock()
.await .await
.insert(chat_id, session.id.clone()); .insert(chat_id, session.id.clone());
bot.answer_callback_query(q.id).await?; }
let answer_result = bot.answer_callback_query(q.id).await;
match action_result {
Ok(()) => {
answer_result?;
Ok(()) 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( async fn handle_picker_callback(
@ -2620,12 +2705,14 @@ async fn handle_picker_callback(
} }
if queued { if queued {
send_error(&bot, message.chat.id, "Write failed; queued for retry.") send_error(&bot, message.chat.id, "Write failed; queued for retry.").await?;
.await?;
} }
let summary = if duplicates > 0 { let summary = if duplicates > 0 {
format!("Saved {} item(s); {} duplicate(s) skipped.", added, duplicates) format!(
"Saved {} item(s); {} duplicate(s) skipped.",
added, duplicates
)
} else { } else {
format!("Saved {} item(s).", added) format!("Saved {} item(s).", added)
}; };
@ -2734,8 +2821,7 @@ async fn handle_undos_callback(
send_ephemeral(&bot, message.chat.id, "Undone.", ACK_TTL_SECS).await?; send_ephemeral(&bot, message.chat.id, "Undone.", ACK_TTL_SECS).await?;
} }
UserOpOutcome::Queued => { UserOpOutcome::Queued => {
send_error(&bot, message.chat.id, "Write failed; queued for retry.") send_error(&bot, message.chat.id, "Write failed; queued for retry.").await?;
.await?;
} }
} }
} }
@ -2858,8 +2944,8 @@ async fn apply_op(state: &std::sync::Arc<AppState>, op: &QueuedOp) -> Result<App
match op.kind { match op.kind {
QueuedOpKind::Add => { QueuedOpKind::Add => {
let entry = EntryBlock::from_block(&op.entry); let entry = EntryBlock::from_block(&op.entry);
let outcome = with_retries(|| add_entry_sync(&state.config.read_later_path, &entry)) let outcome =
.await?; with_retries(|| add_entry_sync(&state.config.read_later_path, &entry)).await?;
Ok(match outcome { Ok(match outcome {
AddOutcome::Added => ApplyOutcome::Applied, AddOutcome::Added => ApplyOutcome::Applied,
AddOutcome::Duplicate => ApplyOutcome::Duplicate, AddOutcome::Duplicate => ApplyOutcome::Duplicate,
@ -2877,9 +2963,8 @@ async fn apply_op(state: &std::sync::Arc<AppState>, op: &QueuedOp) -> Result<App
}) })
} }
QueuedOpKind::Delete => { QueuedOpKind::Delete => {
let outcome = with_retries(|| { let outcome =
delete_entry_sync(&state.config.read_later_path, &op.entry) with_retries(|| delete_entry_sync(&state.config.read_later_path, &op.entry))
})
.await?; .await?;
Ok(match outcome { Ok(match outcome {
ModifyOutcome::Applied => ApplyOutcome::Applied, ModifyOutcome::Applied => ApplyOutcome::Applied,
@ -3036,7 +3121,8 @@ fn run_push(sync: &SyncConfig) -> Result<PushOutcome> {
)); ));
} }
let username = extract_https_username(&remote_url).unwrap_or_else(|| "x-access-token".to_string()); 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())?; let status_output = run_git(&sync.repo_path, &["status", "--porcelain"], Vec::new())?;
if !status_output.status.success() { if !status_output.status.success() {
@ -3157,12 +3243,7 @@ fn run_pull(sync: &SyncConfig, mode: PullMode) -> Result<PullOutcome> {
]; ];
let pull_args: Vec<String> = match mode { let pull_args: Vec<String> = match mode {
PullMode::FastForward => vec![ PullMode::FastForward => vec!["pull".to_string(), "--ff-only".to_string(), remote, branch],
"pull".to_string(),
"--ff-only".to_string(),
remote,
branch,
],
PullMode::Theirs => vec![ PullMode::Theirs => vec![
"pull".to_string(), "pull".to_string(),
"--no-edit".to_string(), "--no-edit".to_string(),
@ -3339,7 +3420,8 @@ fn run_sync_x(config: &Config, cookie_header: &str) -> Result<SyncXOutcome> {
} else { } else {
Vec::new() Vec::new()
}; };
let (added_count, duplicate_count) = prepend_urls_to_read_later_sync(&config.read_later_path, &urls)?; 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(&bookmarks_path);
let _ = fs::remove_file(&creds_path); let _ = fs::remove_file(&creds_path);
@ -3472,8 +3554,8 @@ fn trim_tail(text: &str, max_chars: usize) -> String {
} }
fn read_sync_x_urls(path: &Path) -> Result<Vec<String>> { fn read_sync_x_urls(path: &Path) -> Result<Vec<String>> {
let contents = let contents = fs::read_to_string(path)
fs::read_to_string(path).with_context(|| format!("read bookmarks file {}", path.display()))?; .with_context(|| format!("read bookmarks file {}", path.display()))?;
let mut seen = HashSet::new(); let mut seen = HashSet::new();
let mut urls = Vec::new(); let mut urls = Vec::new();
for line in contents.lines() { for line in contents.lines() {
@ -3595,7 +3677,11 @@ fn git_remote_url(repo_path: &Path, remote: &str) -> Result<String> {
} }
fn git_current_branch(repo_path: &Path) -> Result<String> { fn git_current_branch(repo_path: &Path) -> Result<String> {
let output = run_git(repo_path, &["rev-parse", "--abbrev-ref", "HEAD"], Vec::new())?; let output = run_git(
repo_path,
&["rev-parse", "--abbrev-ref", "HEAD"],
Vec::new(),
)?;
if !output.status.success() { if !output.status.success() {
return Err(anyhow!(format_git_error("git rev-parse", &output))); return Err(anyhow!(format_git_error("git rev-parse", &output)));
} }
@ -3755,8 +3841,14 @@ fn run_ytdlp_list_formats(link: &str) -> Result<Vec<DownloadQualityOption>> {
let Some(format_id) = format.get("format_id").and_then(|v| v.as_str()) else { let Some(format_id) = format.get("format_id").and_then(|v| v.as_str()) else {
continue; continue;
}; };
let vcodec = format.get("vcodec").and_then(|v| v.as_str()).unwrap_or("none"); let vcodec = format
let acodec = format.get("acodec").and_then(|v| v.as_str()).unwrap_or("none"); .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 let ext = format
.get("ext") .get("ext")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
@ -3930,10 +4022,7 @@ fn matches_query(entry: &EntryBlock, query: &str) -> bool {
} }
#[cfg(test)] #[cfg(test)]
fn displayed_indices_for_view( fn displayed_indices_for_view(session: &ListSession, peeked: &HashSet<String>) -> Vec<usize> {
session: &ListSession,
peeked: &HashSet<String>,
) -> Vec<usize> {
match session.view { match session.view {
ListView::Peek { mode, page } => peek_indices_for_session(session, peeked, mode, page), ListView::Peek { mode, page } => peek_indices_for_session(session, peeked, mode, page),
ListView::Selected { index, .. } => vec![index], ListView::Selected { index, .. } => vec![index],
@ -4083,9 +4172,7 @@ fn extract_links(text: &str) -> Vec<String> {
}; };
let start = scan + pos; let start = scan + pos;
let rest = &text[start..]; let rest = &text[start..];
let end_rel = rest let end_rel = rest.find(|c: char| c.is_whitespace()).unwrap_or(rest.len());
.find(|c: char| c.is_whitespace())
.unwrap_or(rest.len());
let end = start + end_rel; let end = start + end_rel;
let mut url = text[start..end].to_string(); let mut url = text[start..end].to_string();
url = trim_link(&url); url = trim_link(&url);
@ -4158,10 +4245,7 @@ fn build_picker_keyboard(picker_id: &str, selected: &[bool]) -> InlineKeyboardMa
rows.push(vec![InlineKeyboardButton::callback(label, data)]); rows.push(vec![InlineKeyboardButton::callback(label, data)]);
} }
rows.push(vec![ rows.push(vec![
InlineKeyboardButton::callback( InlineKeyboardButton::callback("Add selected", format!("pick:{}:add", picker_id)),
"Add selected",
format!("pick:{}:add", picker_id),
),
InlineKeyboardButton::callback("Cancel", format!("pick:{}:cancel", picker_id)), InlineKeyboardButton::callback("Cancel", format!("pick:{}:cancel", picker_id)),
]); ]);
InlineKeyboardMarkup::new(rows) InlineKeyboardMarkup::new(rows)
@ -4170,10 +4254,7 @@ fn build_picker_keyboard(picker_id: &str, selected: &[bool]) -> InlineKeyboardMa
fn build_add_prompt_keyboard(prompt_id: &str) -> InlineKeyboardMarkup { fn build_add_prompt_keyboard(prompt_id: &str) -> InlineKeyboardMarkup {
InlineKeyboardMarkup::new(vec![ InlineKeyboardMarkup::new(vec![
vec![ vec![
InlineKeyboardButton::callback( InlineKeyboardButton::callback("Reading list", format!("add:{}:normal", prompt_id)),
"Reading list",
format!("add:{}:normal", prompt_id),
),
InlineKeyboardButton::callback("Resource", format!("add:{}:resource", prompt_id)), InlineKeyboardButton::callback("Resource", format!("add:{}:resource", prompt_id)),
], ],
vec![InlineKeyboardButton::callback( vec![InlineKeyboardButton::callback(
@ -4183,10 +4264,7 @@ fn build_add_prompt_keyboard(prompt_id: &str) -> InlineKeyboardMarkup {
]) ])
} }
fn build_resource_picker_keyboard( fn build_resource_picker_keyboard(picker_id: &str, files: &[PathBuf]) -> InlineKeyboardMarkup {
picker_id: &str,
files: &[PathBuf],
) -> InlineKeyboardMarkup {
let mut rows: Vec<Vec<InlineKeyboardButton>> = Vec::new(); let mut rows: Vec<Vec<InlineKeyboardButton>> = Vec::new();
let mut current_row = Vec::new(); let mut current_row = Vec::new();
for (idx, path) in files.iter().enumerate() { for (idx, path) in files.iter().enumerate() {
@ -4244,10 +4322,7 @@ fn build_download_quality_text(
text.trim_end().to_string() text.trim_end().to_string()
} }
fn build_download_picker_keyboard( fn build_download_picker_keyboard(picker_id: &str, links: &[String]) -> InlineKeyboardMarkup {
picker_id: &str,
links: &[String],
) -> InlineKeyboardMarkup {
let mut rows = Vec::new(); let mut rows = Vec::new();
for (idx, _) in links.iter().enumerate() { for (idx, _) in links.iter().enumerate() {
rows.push(vec![ rows.push(vec![
@ -4305,7 +4380,9 @@ fn render_list_view(
ListView::Peek { mode, page } => { ListView::Peek { mode, page } => {
build_peek_view(session_id, session, *mode, *page, peeked, config) build_peek_view(session_id, session, *mode, *page, peeked, config)
} }
ListView::Selected { index, .. } => build_selected_view(session_id, session, *index, config), ListView::Selected { index, .. } => {
build_selected_view(session_id, session, *index, config)
}
ListView::FinishConfirm { index, .. } => { ListView::FinishConfirm { index, .. } => {
build_finish_confirm_view(session_id, session, *index, config) build_finish_confirm_view(session_id, session, *index, config)
} }
@ -4395,7 +4472,12 @@ fn build_peek_view(
} }
SessionKind::Search { query } => { SessionKind::Search { query } => {
if total_pages > 0 { if total_pages > 0 {
format!("Matches for \"{}\" (page {}/{})\n", query, page + 1, total_pages) format!(
"Matches for \"{}\" (page {}/{})\n",
query,
page + 1,
total_pages
)
} else { } else {
format!("Matches for \"{}\"\n", query) format!("Matches for \"{}\"\n", query)
} }
@ -4474,21 +4556,18 @@ fn build_selected_view(
let rows = match &session.kind { let rows = match &session.kind {
SessionKind::List => vec![ SessionKind::List => vec![
vec![ vec![
InlineKeyboardButton::callback("Mark Finished", format!("ls:{}:finish", session_id)), InlineKeyboardButton::callback(
"Mark Finished",
format!("ls:{}:finish", session_id),
),
InlineKeyboardButton::callback( InlineKeyboardButton::callback(
"Add Resource", "Add Resource",
format!("ls:{}:resource", session_id), format!("ls:{}:resource", session_id),
), ),
], ],
vec![ vec![
InlineKeyboardButton::callback( InlineKeyboardButton::callback("Delete", format!("ls:{}:delete", session_id)),
"Delete", InlineKeyboardButton::callback("Random", format!("ls:{}:random", session_id)),
format!("ls:{}:delete", session_id),
),
InlineKeyboardButton::callback(
"Random",
format!("ls:{}:random", session_id),
),
], ],
vec![InlineKeyboardButton::callback( vec![InlineKeyboardButton::callback(
"Back", "Back",
@ -4748,12 +4827,7 @@ fn undo_preview(entry: &str) -> Vec<String> {
entry.preview_lines() entry.preview_lines()
} }
async fn send_ephemeral( async fn send_ephemeral(bot: &Bot, chat_id: ChatId, text: &str, ttl_secs: u64) -> Result<()> {
bot: &Bot,
chat_id: ChatId,
text: &str,
ttl_secs: u64,
) -> Result<()> {
let sent = bot.send_message(chat_id, text).await?; let sent = bot.send_message(chat_id, text).await?;
let bot = bot.clone(); let bot = bot.clone();
tokio::spawn(async move { tokio::spawn(async move {
@ -4807,7 +4881,8 @@ async fn refresh_embedded_media_for_view(
peeked: &HashSet<String>, peeked: &HashSet<String>,
) -> Result<()> { ) -> Result<()> {
delete_embedded_media_messages(bot, chat_id, &session.sent_media_message_ids).await; 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?; session.sent_media_message_ids =
send_embedded_media_for_view(bot, chat_id, state, session, peeked).await?;
Ok(()) Ok(())
} }
@ -4883,8 +4958,8 @@ fn resolve_user_id_path(path: &Path, config_dir: &Path) -> PathBuf {
} }
fn read_user_id_file(path: &Path) -> Result<u64> { fn read_user_id_file(path: &Path) -> Result<u64> {
let contents = let contents = fs::read_to_string(path)
fs::read_to_string(path).with_context(|| format!("read user_id file {}", path.display()))?; .with_context(|| format!("read user_id file {}", path.display()))?;
parse_user_id_value(contents.trim()) parse_user_id_value(contents.trim())
.with_context(|| format!("parse user_id from {}", path.display())) .with_context(|| format!("parse user_id from {}", path.display()))
} }
@ -4898,7 +4973,8 @@ fn parse_user_id_value(raw: &str) -> Result<u64> {
} }
fn load_config(path: &Path) -> Result<Config> { fn load_config(path: &Path) -> Result<Config> {
let contents = fs::read_to_string(path).with_context(|| format!("read config {}", path.display()))?; 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_file: ConfigFile = toml::from_str(&contents).context("parse config")?;
let config_dir = path.parent().unwrap_or_else(|| Path::new(".")); let config_dir = path.parent().unwrap_or_else(|| Path::new("."));
let user_id = resolve_user_id(config_file.user_id, config_dir)?; let user_id = resolve_user_id(config_file.user_id, config_dir)?;
@ -4958,8 +5034,14 @@ fn list_resource_files(dir: &Path) -> Result<Vec<PathBuf>> {
} }
} }
files.sort_by(|a, b| { files.sort_by(|a, b| {
let a_name = a.file_name().map(|n| n.to_string_lossy()).unwrap_or_default(); let a_name = a
let b_name = b.file_name().map(|n| n.to_string_lossy()).unwrap_or_default(); .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) a_name.cmp(&b_name)
}); });
Ok(files) Ok(files)
@ -4969,8 +5051,8 @@ fn read_entries(path: &Path) -> Result<(Vec<String>, Vec<EntryBlock>)> {
if !path.exists() { if !path.exists() {
return Ok((Vec::new(), Vec::new())); return Ok((Vec::new(), Vec::new()));
} }
let contents = fs::read_to_string(path) let contents =
.with_context(|| format!("read file {}", path.display()))?; fs::read_to_string(path).with_context(|| format!("read file {}", path.display()))?;
let normalized = normalize_line_endings(&contents); let normalized = normalize_line_endings(&contents);
Ok(parse_entries(&normalized)) Ok(parse_entries(&normalized))
} }
@ -5072,9 +5154,7 @@ fn add_resource_entry_sync(path: &Path, entry_block: &str) -> Result<AddOutcome>
fn delete_entry_sync(path: &Path, entry_block: &str) -> Result<ModifyOutcome> { fn delete_entry_sync(path: &Path, entry_block: &str) -> Result<ModifyOutcome> {
let (preamble, mut entries) = read_entries(path)?; let (preamble, mut entries) = read_entries(path)?;
let pos = entries let pos = entries.iter().position(|e| e.block_string() == entry_block);
.iter()
.position(|e| e.block_string() == entry_block);
let Some(pos) = pos else { let Some(pos) = pos else {
return Ok(ModifyOutcome::NotFound); return Ok(ModifyOutcome::NotFound);
}; };
@ -5089,9 +5169,7 @@ fn update_entry_sync(
updated_entry: &EntryBlock, updated_entry: &EntryBlock,
) -> Result<ModifyOutcome> { ) -> Result<ModifyOutcome> {
let (preamble, mut entries) = read_entries(path)?; let (preamble, mut entries) = read_entries(path)?;
let pos = entries let pos = entries.iter().position(|e| e.block_string() == entry_block);
.iter()
.position(|e| e.block_string() == entry_block);
let Some(pos) = pos else { let Some(pos) = pos else {
return Ok(ModifyOutcome::NotFound); return Ok(ModifyOutcome::NotFound);
}; };
@ -5169,7 +5247,8 @@ fn load_queue(path: &Path) -> Result<Vec<QueuedOp>> {
if !path.exists() { if !path.exists() {
return Ok(Vec::new()); return Ok(Vec::new());
} }
let data = fs::read_to_string(path).with_context(|| format!("read queue {}", path.display()))?; let data =
fs::read_to_string(path).with_context(|| format!("read queue {}", path.display()))?;
let queue = serde_json::from_str(&data).context("parse queue")?; let queue = serde_json::from_str(&data).context("parse queue")?;
Ok(queue) Ok(queue)
} }
@ -5330,9 +5409,9 @@ fn format_embedded_references_for_lines(lines: &[String], config: &Config) -> Ve
} }
fn pick_best_photo(photos: &[teloxide::types::PhotoSize]) -> Option<&teloxide::types::PhotoSize> { fn pick_best_photo(photos: &[teloxide::types::PhotoSize]) -> Option<&teloxide::types::PhotoSize> {
photos.iter().max_by_key(|photo| { photos
photo.file.size.max((photo.width * photo.height) as u32) as u64 .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<()> { async fn download_telegram_file(bot: &Bot, file_id: &str, dest_path: &Path) -> Result<()> {
@ -5539,9 +5618,7 @@ mod tests {
#[test] #[test]
fn peek_indices_filters_and_pages() { fn peek_indices_filters_and_pages() {
let entries: Vec<EntryBlock> = (0..6) let entries: Vec<EntryBlock> = (0..6).map(|i| entry(&format!("item {}", i))).collect();
.map(|i| entry(&format!("item {}", i)))
.collect();
let mut peeked = HashSet::new(); let mut peeked = HashSet::new();
peeked.insert(entries[1].block_string()); peeked.insert(entries[1].block_string());
peeked.insert(entries[3].block_string()); peeked.insert(entries[3].block_string());
@ -5551,10 +5628,7 @@ mod tests {
peek_indices(&entries, &peeked, ListMode::Top, 0), peek_indices(&entries, &peeked, ListMode::Top, 0),
vec![0, 2, 4] vec![0, 2, 4]
); );
assert_eq!( assert_eq!(peek_indices(&entries, &peeked, ListMode::Top, 1), vec![5]);
peek_indices(&entries, &peeked, ListMode::Top, 1),
vec![5]
);
assert_eq!( assert_eq!(
peek_indices(&entries, &peeked, ListMode::Bottom, 0), peek_indices(&entries, &peeked, ListMode::Bottom, 0),
vec![5, 4, 2] vec![5, 4, 2]
@ -5567,9 +5641,7 @@ mod tests {
#[test] #[test]
fn search_peek_indices_ignore_peeked_entries() { fn search_peek_indices_ignore_peeked_entries() {
let entries: Vec<EntryBlock> = (0..4) let entries: Vec<EntryBlock> = (0..4).map(|i| entry(&format!("match {}", i))).collect();
.map(|i| entry(&format!("match {}", i)))
.collect();
let session = ListSession { let session = ListSession {
id: "session".to_string(), id: "session".to_string(),
chat_id: 0, chat_id: 0,
@ -5710,7 +5782,10 @@ mod tests {
}; };
let lines = embedded_lines_for_view(&session, &HashSet::new()); let lines = embedded_lines_for_view(&session, &HashSet::new());
assert_eq!(lines, vec!["first line".to_string(), "second line...".to_string()]); assert_eq!(
lines,
vec!["first line".to_string(), "second line...".to_string()]
);
} }
#[test] #[test]
@ -5775,10 +5850,7 @@ mod tests {
}; };
assert_eq!(norm_target_index(&session, &peeked), Some(1)); assert_eq!(norm_target_index(&session, &peeked), Some(1));
let session_multi = ListSession { let session_multi = ListSession { entries, ..session };
entries,
..session
};
let empty_peeked = HashSet::new(); let empty_peeked = HashSet::new();
assert_eq!(norm_target_index(&session_multi, &empty_peeked), None); assert_eq!(norm_target_index(&session_multi, &empty_peeked), None);
} }
@ -5816,10 +5888,7 @@ mod tests {
#[test] #[test]
fn parse_pull_mode_accepts_theirs() { fn parse_pull_mode_accepts_theirs() {
assert!(matches!(parse_pull_mode(""), Ok(PullMode::FastForward))); assert!(matches!(parse_pull_mode(""), Ok(PullMode::FastForward)));
assert!(matches!( assert!(matches!(parse_pull_mode("theirs"), Ok(PullMode::Theirs)));
parse_pull_mode("theirs"),
Ok(PullMode::Theirs)
));
assert!(parse_pull_mode("unknown").is_err()); assert!(parse_pull_mode("unknown").is_err());
} }