Fix /list callback session loss on no-op edits
This commit is contained in:
parent
b385ac5f21
commit
6e3b950083
1 changed files with 524 additions and 455 deletions
443
src/main.rs
443
src/main.rs
|
|
@ -15,8 +15,8 @@ use serde::{Deserialize, Serialize};
|
|||
use teloxide::net::Download;
|
||||
use teloxide::prelude::*;
|
||||
use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup, InputFile, Message, MessageId};
|
||||
use tokio::sync::Mutex;
|
||||
use tempfile::{NamedTempFile, TempDir, TempPath};
|
||||
use tokio::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
const ACK_TTL_SECS: u64 = 5;
|
||||
|
|
@ -303,8 +303,14 @@ struct ListSession {
|
|||
#[derive(Clone, Debug)]
|
||||
enum ListView {
|
||||
Menu,
|
||||
Peek { mode: ListMode, page: usize },
|
||||
Selected { return_to: Box<ListView>, index: usize },
|
||||
Peek {
|
||||
mode: ListMode,
|
||||
page: usize,
|
||||
},
|
||||
Selected {
|
||||
return_to: Box<ListView>,
|
||||
index: usize,
|
||||
},
|
||||
FinishConfirm {
|
||||
selected: Box<ListView>,
|
||||
index: usize,
|
||||
|
|
@ -413,11 +419,7 @@ async fn main() -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_message(
|
||||
bot: Bot,
|
||||
msg: Message,
|
||||
state: std::sync::Arc<AppState>,
|
||||
) -> Result<()> {
|
||||
async fn handle_message(bot: Bot, msg: Message, state: std::sync::Arc<AppState>) -> Result<()> {
|
||||
let user_id = match msg.from() {
|
||||
Some(user) => user.id.0,
|
||||
None => return Ok(()),
|
||||
|
|
@ -484,8 +486,7 @@ async fn handle_message(
|
|||
}
|
||||
|
||||
if let Some(prompt) = pending_resource_prompt {
|
||||
handle_resource_filename_response(&bot, msg.chat.id, msg.id, &state, &text, prompt)
|
||||
.await?;
|
||||
handle_resource_filename_response(&bot, msg.chat.id, msg.id, &state, &text, prompt).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
|
|
@ -654,7 +655,14 @@ async fn handle_media_message(
|
|||
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?;
|
||||
handle_single_item(
|
||||
bot.clone(),
|
||||
chat_id,
|
||||
state.clone(),
|
||||
&entry_text,
|
||||
Some(msg.id),
|
||||
)
|
||||
.await?;
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
|
@ -672,7 +680,14 @@ async fn handle_media_message(
|
|||
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?;
|
||||
handle_single_item(
|
||||
bot.clone(),
|
||||
chat_id,
|
||||
state.clone(),
|
||||
&entry_text,
|
||||
Some(msg.id),
|
||||
)
|
||||
.await?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
|
|
@ -692,7 +707,14 @@ async fn handle_media_message(
|
|||
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?;
|
||||
handle_single_item(
|
||||
bot.clone(),
|
||||
chat_id,
|
||||
state.clone(),
|
||||
&entry_text,
|
||||
Some(msg.id),
|
||||
)
|
||||
.await?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
|
|
@ -728,7 +750,8 @@ async fn handle_norm_message(
|
|||
let target_index = match norm_target_index(&session, &peeked_snapshot) {
|
||||
Some(index) => index,
|
||||
None => {
|
||||
state.sessions
|
||||
state
|
||||
.sessions
|
||||
.lock()
|
||||
.await
|
||||
.insert(session.id.clone(), session);
|
||||
|
|
@ -741,7 +764,8 @@ async fn handle_norm_message(
|
|||
let entry = match session.entries.get(target_index).cloned() {
|
||||
Some(entry) => entry,
|
||||
None => {
|
||||
state.sessions
|
||||
state
|
||||
.sessions
|
||||
.lock()
|
||||
.await
|
||||
.insert(session.id.clone(), session);
|
||||
|
|
@ -752,7 +776,8 @@ async fn handle_norm_message(
|
|||
};
|
||||
|
||||
let Some(normalized_entry) = normalize_entry_markdown_links(&entry) else {
|
||||
state.sessions
|
||||
state
|
||||
.sessions
|
||||
.lock()
|
||||
.await
|
||||
.insert(session.id.clone(), session);
|
||||
|
|
@ -797,7 +822,8 @@ async fn handle_norm_message(
|
|||
}
|
||||
}
|
||||
|
||||
state.sessions
|
||||
state
|
||||
.sessions
|
||||
.lock()
|
||||
.await
|
||||
.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) {
|
||||
Some(index) => index,
|
||||
None => {
|
||||
state.sessions
|
||||
state
|
||||
.sessions
|
||||
.lock()
|
||||
.await
|
||||
.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()) {
|
||||
Some(entry) => entry,
|
||||
None => {
|
||||
state.sessions
|
||||
state
|
||||
.sessions
|
||||
.lock()
|
||||
.await
|
||||
.insert(session.id.clone(), session);
|
||||
|
|
@ -898,7 +926,8 @@ async fn handle_instant_delete_message(
|
|||
}
|
||||
}
|
||||
|
||||
state.sessions
|
||||
state
|
||||
.sessions
|
||||
.lock()
|
||||
.await
|
||||
.insert(session.id.clone(), session);
|
||||
|
|
@ -966,10 +995,7 @@ async fn handle_list_command(
|
|||
};
|
||||
|
||||
let (text, kb) = build_menu_view(&session_id, &session);
|
||||
let sent = bot
|
||||
.send_message(msg.chat.id, text)
|
||||
.reply_markup(kb)
|
||||
.await?;
|
||||
let sent = bot.send_message(msg.chat.id, text).reply_markup(kb).await?;
|
||||
session.message_id = Some(sent.id);
|
||||
state
|
||||
.sessions
|
||||
|
|
@ -1017,10 +1043,7 @@ async fn handle_search_command(
|
|||
|
||||
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?;
|
||||
let sent = bot.send_message(msg.chat.id, text).reply_markup(kb).await?;
|
||||
session.message_id = Some(sent.id);
|
||||
state
|
||||
.sessions
|
||||
|
|
@ -1216,7 +1239,8 @@ async fn handle_sync_x_command(
|
|||
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?;
|
||||
state.sync_x_cookie_prompts.lock().await.insert(
|
||||
msg.chat.id.0,
|
||||
|
|
@ -1238,7 +1262,12 @@ async fn handle_sync_x_cookie_response(
|
|||
) -> 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?;
|
||||
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 {
|
||||
|
|
@ -1268,7 +1297,9 @@ async fn handle_sync_x_cookie_response(
|
|||
} else {
|
||||
let text = format!(
|
||||
"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?;
|
||||
}
|
||||
|
|
@ -1307,11 +1338,7 @@ async fn handle_undos_command(
|
|||
message_id: sent.id,
|
||||
records,
|
||||
};
|
||||
state
|
||||
.undo_sessions
|
||||
.lock()
|
||||
.await
|
||||
.insert(session_id, session);
|
||||
state.undo_sessions.lock().await.insert(session_id, session);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -1371,7 +1398,10 @@ async fn handle_multi_item(
|
|||
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 sent = bot
|
||||
.send_message(chat_id, view_text)
|
||||
.reply_markup(kb)
|
||||
.await?;
|
||||
|
||||
let picker = PickerState {
|
||||
id: picker_id.clone(),
|
||||
|
|
@ -1394,7 +1424,10 @@ async fn handle_add_command(
|
|||
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 sent = bot
|
||||
.send_message(msg.chat.id, prompt_text)
|
||||
.reply_markup(kb)
|
||||
.await?;
|
||||
|
||||
let prompt = AddPrompt {
|
||||
chat_id: msg.chat.id.0,
|
||||
|
|
@ -1495,7 +1528,10 @@ async fn start_resource_picker(
|
|||
} else {
|
||||
"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 {
|
||||
chat_id: chat_id.0,
|
||||
|
|
@ -1689,9 +1725,7 @@ async fn handle_resource_filename_response(
|
|||
)
|
||||
.await?;
|
||||
|
||||
let _ = bot
|
||||
.delete_message(chat_id, prompt.prompt_message_id)
|
||||
.await;
|
||||
let _ = bot.delete_message(chat_id, prompt.prompt_message_id).await;
|
||||
let _ = bot.delete_message(chat_id, message_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1783,8 +1817,7 @@ async fn handle_download_callback(
|
|||
DownloadAction::Send,
|
||||
&options,
|
||||
);
|
||||
let kb =
|
||||
build_download_quality_keyboard(&picker_id, &options);
|
||||
let kb = build_download_quality_keyboard(&picker_id, &options);
|
||||
bot.edit_message_text(message.chat.id, message.id, text)
|
||||
.reply_markup(kb)
|
||||
.await?;
|
||||
|
|
@ -1828,8 +1861,7 @@ async fn handle_download_callback(
|
|||
DownloadAction::Save,
|
||||
&options,
|
||||
);
|
||||
let kb =
|
||||
build_download_quality_keyboard(&picker_id, &options);
|
||||
let kb = build_download_quality_keyboard(&picker_id, &options);
|
||||
bot.edit_message_text(message.chat.id, message.id, text)
|
||||
.reply_markup(kb)
|
||||
.await?;
|
||||
|
|
@ -1864,9 +1896,10 @@ async fn handle_download_callback(
|
|||
},
|
||||
) = (selected, &picker.mode)
|
||||
{
|
||||
if let (Some(link), Some(option)) =
|
||||
(picker.links.get(*link_index).cloned(), options.get(selected).cloned())
|
||||
{
|
||||
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(
|
||||
|
|
@ -1887,20 +1920,13 @@ async fn handle_download_callback(
|
|||
}
|
||||
}
|
||||
DownloadAction::Save => {
|
||||
match download_and_save_link(
|
||||
&state,
|
||||
&link,
|
||||
&option.format_selector,
|
||||
)
|
||||
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",
|
||||
),
|
||||
InlineKeyboardButton::callback("Delete message", "msgdel"),
|
||||
]]);
|
||||
bot.send_message(message.chat.id, note)
|
||||
.reply_markup(kb)
|
||||
|
|
@ -2007,9 +2033,7 @@ async fn handle_download_link_response(
|
|||
}
|
||||
}
|
||||
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, prompt.prompt_message_id).await;
|
||||
let _ = bot.delete_message(chat_id, message_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -2051,18 +2075,14 @@ async fn handle_finish_title_response(
|
|||
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, 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, prompt.prompt_message_id).await;
|
||||
let _ = bot.delete_message(chat_id, message_id).await;
|
||||
return Ok(());
|
||||
}
|
||||
|
|
@ -2080,9 +2100,7 @@ async fn handle_finish_title_response(
|
|||
.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, prompt.prompt_message_id).await;
|
||||
let _ = bot.delete_message(chat_id, message_id).await;
|
||||
return Ok(());
|
||||
};
|
||||
|
|
@ -2138,9 +2156,7 @@ async fn handle_finish_title_response(
|
|||
.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, prompt.prompt_message_id).await;
|
||||
let _ = bot.delete_message(chat_id, message_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -2186,7 +2202,10 @@ async fn handle_list_callback(
|
|||
};
|
||||
|
||||
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) {
|
||||
|
|
@ -2232,15 +2251,19 @@ async fn handle_list_callback(
|
|||
}
|
||||
"close" => {
|
||||
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;
|
||||
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);
|
||||
}
|
||||
bot.answer_callback_query(q.id).await?;
|
||||
return Ok(());
|
||||
close_session = true;
|
||||
refresh_list_view = false;
|
||||
}
|
||||
}
|
||||
"random" => {
|
||||
|
|
@ -2338,7 +2361,8 @@ async fn handle_list_callback(
|
|||
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?;
|
||||
let _ =
|
||||
add_undo(&state, UndoKind::MoveToFinished, entry_block).await?;
|
||||
}
|
||||
UserOpOutcome::Applied(ApplyOutcome::NotFound) => {
|
||||
send_error(&bot, message.chat.id, "Item not found.").await?;
|
||||
|
|
@ -2348,7 +2372,11 @@ async fn handle_list_callback(
|
|||
session.view = *selected;
|
||||
}
|
||||
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?;
|
||||
session.view = *selected;
|
||||
}
|
||||
|
|
@ -2409,6 +2437,7 @@ async fn handle_list_callback(
|
|||
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?;
|
||||
}
|
||||
|
|
@ -2435,8 +2464,7 @@ async fn handle_list_callback(
|
|||
{
|
||||
if now_ts() > expires_at {
|
||||
session.view = *selected;
|
||||
send_error(&bot, message.chat.id, "Delete confirmation expired.")
|
||||
.await?;
|
||||
send_error(&bot, message.chat.id, "Delete confirmation expired.").await?;
|
||||
} else {
|
||||
session.view = ListView::DeleteConfirm {
|
||||
selected,
|
||||
|
|
@ -2457,8 +2485,7 @@ async fn handle_list_callback(
|
|||
{
|
||||
if now_ts() > expires_at {
|
||||
session.view = *selected;
|
||||
send_error(&bot, message.chat.id, "Delete confirmation expired.")
|
||||
.await?;
|
||||
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 {
|
||||
|
|
@ -2506,17 +2533,53 @@ async fn handle_list_callback(
|
|||
_ => {}
|
||||
}
|
||||
|
||||
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);
|
||||
bot.edit_message_text(message.chat.id, message.id, text)
|
||||
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?;
|
||||
if let Err(err) =
|
||||
refresh_embedded_media_for_view(&bot, message.chat.id, &state, &mut session, &peeked_snapshot)
|
||||
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()
|
||||
|
|
@ -2527,8 +2590,30 @@ async fn handle_list_callback(
|
|||
.lock()
|
||||
.await
|
||||
.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(())
|
||||
}
|
||||
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(
|
||||
|
|
@ -2620,12 +2705,14 @@ async fn handle_picker_callback(
|
|||
}
|
||||
|
||||
if queued {
|
||||
send_error(&bot, message.chat.id, "Write failed; queued for retry.")
|
||||
.await?;
|
||||
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)
|
||||
format!(
|
||||
"Saved {} item(s); {} duplicate(s) skipped.",
|
||||
added, duplicates
|
||||
)
|
||||
} else {
|
||||
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?;
|
||||
}
|
||||
UserOpOutcome::Queued => {
|
||||
send_error(&bot, message.chat.id, "Write failed; queued for retry.")
|
||||
.await?;
|
||||
send_error(&bot, message.chat.id, "Write failed; queued for retry.").await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2858,8 +2944,8 @@ async fn apply_op(state: &std::sync::Arc<AppState>, op: &QueuedOp) -> Result<App
|
|||
match op.kind {
|
||||
QueuedOpKind::Add => {
|
||||
let entry = EntryBlock::from_block(&op.entry);
|
||||
let outcome = with_retries(|| add_entry_sync(&state.config.read_later_path, &entry))
|
||||
.await?;
|
||||
let outcome =
|
||||
with_retries(|| add_entry_sync(&state.config.read_later_path, &entry)).await?;
|
||||
Ok(match outcome {
|
||||
AddOutcome::Added => ApplyOutcome::Applied,
|
||||
AddOutcome::Duplicate => ApplyOutcome::Duplicate,
|
||||
|
|
@ -2877,9 +2963,8 @@ async fn apply_op(state: &std::sync::Arc<AppState>, op: &QueuedOp) -> Result<App
|
|||
})
|
||||
}
|
||||
QueuedOpKind::Delete => {
|
||||
let outcome = with_retries(|| {
|
||||
delete_entry_sync(&state.config.read_later_path, &op.entry)
|
||||
})
|
||||
let outcome =
|
||||
with_retries(|| delete_entry_sync(&state.config.read_later_path, &op.entry))
|
||||
.await?;
|
||||
Ok(match outcome {
|
||||
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())?;
|
||||
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 {
|
||||
PullMode::FastForward => vec![
|
||||
"pull".to_string(),
|
||||
"--ff-only".to_string(),
|
||||
remote,
|
||||
branch,
|
||||
],
|
||||
PullMode::FastForward => vec!["pull".to_string(), "--ff-only".to_string(), remote, branch],
|
||||
PullMode::Theirs => vec![
|
||||
"pull".to_string(),
|
||||
"--no-edit".to_string(),
|
||||
|
|
@ -3339,7 +3420,8 @@ fn run_sync_x(config: &Config, cookie_header: &str) -> Result<SyncXOutcome> {
|
|||
} else {
|
||||
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(&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>> {
|
||||
let contents =
|
||||
fs::read_to_string(path).with_context(|| format!("read bookmarks file {}", path.display()))?;
|
||||
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() {
|
||||
|
|
@ -3595,7 +3677,11 @@ fn git_remote_url(repo_path: &Path, remote: &str) -> 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() {
|
||||
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 {
|
||||
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 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())
|
||||
|
|
@ -3930,10 +4022,7 @@ fn matches_query(entry: &EntryBlock, query: &str) -> bool {
|
|||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn displayed_indices_for_view(
|
||||
session: &ListSession,
|
||||
peeked: &HashSet<String>,
|
||||
) -> Vec<usize> {
|
||||
fn displayed_indices_for_view(session: &ListSession, peeked: &HashSet<String>) -> Vec<usize> {
|
||||
match session.view {
|
||||
ListView::Peek { mode, page } => peek_indices_for_session(session, peeked, mode, page),
|
||||
ListView::Selected { index, .. } => vec![index],
|
||||
|
|
@ -4083,9 +4172,7 @@ fn extract_links(text: &str) -> Vec<String> {
|
|||
};
|
||||
let start = scan + pos;
|
||||
let rest = &text[start..];
|
||||
let end_rel = rest
|
||||
.find(|c: char| c.is_whitespace())
|
||||
.unwrap_or(rest.len());
|
||||
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);
|
||||
|
|
@ -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(
|
||||
"Add selected",
|
||||
format!("pick:{}:add", picker_id),
|
||||
),
|
||||
InlineKeyboardButton::callback("Add selected", format!("pick:{}:add", picker_id)),
|
||||
InlineKeyboardButton::callback("Cancel", format!("pick:{}:cancel", picker_id)),
|
||||
]);
|
||||
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 {
|
||||
InlineKeyboardMarkup::new(vec![
|
||||
vec![
|
||||
InlineKeyboardButton::callback(
|
||||
"Reading list",
|
||||
format!("add:{}:normal", prompt_id),
|
||||
),
|
||||
InlineKeyboardButton::callback("Reading list", format!("add:{}:normal", prompt_id)),
|
||||
InlineKeyboardButton::callback("Resource", format!("add:{}:resource", prompt_id)),
|
||||
],
|
||||
vec![InlineKeyboardButton::callback(
|
||||
|
|
@ -4183,10 +4264,7 @@ fn build_add_prompt_keyboard(prompt_id: &str) -> InlineKeyboardMarkup {
|
|||
])
|
||||
}
|
||||
|
||||
fn build_resource_picker_keyboard(
|
||||
picker_id: &str,
|
||||
files: &[PathBuf],
|
||||
) -> InlineKeyboardMarkup {
|
||||
fn build_resource_picker_keyboard(picker_id: &str, files: &[PathBuf]) -> InlineKeyboardMarkup {
|
||||
let mut rows: Vec<Vec<InlineKeyboardButton>> = Vec::new();
|
||||
let mut current_row = Vec::new();
|
||||
for (idx, path) in files.iter().enumerate() {
|
||||
|
|
@ -4244,10 +4322,7 @@ fn build_download_quality_text(
|
|||
text.trim_end().to_string()
|
||||
}
|
||||
|
||||
fn build_download_picker_keyboard(
|
||||
picker_id: &str,
|
||||
links: &[String],
|
||||
) -> InlineKeyboardMarkup {
|
||||
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![
|
||||
|
|
@ -4305,7 +4380,9 @@ fn render_list_view(
|
|||
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::Selected { index, .. } => {
|
||||
build_selected_view(session_id, session, *index, config)
|
||||
}
|
||||
ListView::FinishConfirm { index, .. } => {
|
||||
build_finish_confirm_view(session_id, session, *index, config)
|
||||
}
|
||||
|
|
@ -4395,7 +4472,12 @@ fn build_peek_view(
|
|||
}
|
||||
SessionKind::Search { query } => {
|
||||
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 {
|
||||
format!("Matches for \"{}\"\n", query)
|
||||
}
|
||||
|
|
@ -4474,21 +4556,18 @@ fn build_selected_view(
|
|||
let rows = match &session.kind {
|
||||
SessionKind::List => vec![
|
||||
vec![
|
||||
InlineKeyboardButton::callback("Mark Finished", format!("ls:{}:finish", session_id)),
|
||||
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),
|
||||
),
|
||||
InlineKeyboardButton::callback("Delete", format!("ls:{}:delete", session_id)),
|
||||
InlineKeyboardButton::callback("Random", format!("ls:{}:random", session_id)),
|
||||
],
|
||||
vec![InlineKeyboardButton::callback(
|
||||
"Back",
|
||||
|
|
@ -4748,12 +4827,7 @@ fn undo_preview(entry: &str) -> Vec<String> {
|
|||
entry.preview_lines()
|
||||
}
|
||||
|
||||
async fn send_ephemeral(
|
||||
bot: &Bot,
|
||||
chat_id: ChatId,
|
||||
text: &str,
|
||||
ttl_secs: u64,
|
||||
) -> Result<()> {
|
||||
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 {
|
||||
|
|
@ -4807,7 +4881,8 @@ async fn refresh_embedded_media_for_view(
|
|||
peeked: &HashSet<String>,
|
||||
) -> 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?;
|
||||
session.sent_media_message_ids =
|
||||
send_embedded_media_for_view(bot, chat_id, state, session, peeked).await?;
|
||||
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> {
|
||||
let contents =
|
||||
fs::read_to_string(path).with_context(|| format!("read user_id file {}", path.display()))?;
|
||||
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()))
|
||||
}
|
||||
|
|
@ -4898,7 +4973,8 @@ fn parse_user_id_value(raw: &str) -> Result<u64> {
|
|||
}
|
||||
|
||||
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_dir = path.parent().unwrap_or_else(|| Path::new("."));
|
||||
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| {
|
||||
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();
|
||||
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)
|
||||
|
|
@ -4969,8 +5051,8 @@ fn read_entries(path: &Path) -> Result<(Vec<String>, Vec<EntryBlock>)> {
|
|||
if !path.exists() {
|
||||
return Ok((Vec::new(), Vec::new()));
|
||||
}
|
||||
let contents = fs::read_to_string(path)
|
||||
.with_context(|| format!("read file {}", path.display()))?;
|
||||
let contents =
|
||||
fs::read_to_string(path).with_context(|| format!("read file {}", path.display()))?;
|
||||
let normalized = normalize_line_endings(&contents);
|
||||
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> {
|
||||
let (preamble, mut entries) = read_entries(path)?;
|
||||
let pos = entries
|
||||
.iter()
|
||||
.position(|e| e.block_string() == entry_block);
|
||||
let pos = entries.iter().position(|e| e.block_string() == entry_block);
|
||||
let Some(pos) = pos else {
|
||||
return Ok(ModifyOutcome::NotFound);
|
||||
};
|
||||
|
|
@ -5089,9 +5169,7 @@ fn update_entry_sync(
|
|||
updated_entry: &EntryBlock,
|
||||
) -> Result<ModifyOutcome> {
|
||||
let (preamble, mut entries) = read_entries(path)?;
|
||||
let pos = entries
|
||||
.iter()
|
||||
.position(|e| e.block_string() == entry_block);
|
||||
let pos = entries.iter().position(|e| e.block_string() == entry_block);
|
||||
let Some(pos) = pos else {
|
||||
return Ok(ModifyOutcome::NotFound);
|
||||
};
|
||||
|
|
@ -5169,7 +5247,8 @@ fn load_queue(path: &Path) -> Result<Vec<QueuedOp>> {
|
|||
if !path.exists() {
|
||||
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")?;
|
||||
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> {
|
||||
photos.iter().max_by_key(|photo| {
|
||||
photo.file.size.max((photo.width * photo.height) as u32) as u64
|
||||
})
|
||||
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<()> {
|
||||
|
|
@ -5539,9 +5618,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn peek_indices_filters_and_pages() {
|
||||
let entries: Vec<EntryBlock> = (0..6)
|
||||
.map(|i| entry(&format!("item {}", i)))
|
||||
.collect();
|
||||
let entries: Vec<EntryBlock> = (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());
|
||||
|
|
@ -5551,10 +5628,7 @@ mod tests {
|
|||
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::Top, 1), vec![5]);
|
||||
assert_eq!(
|
||||
peek_indices(&entries, &peeked, ListMode::Bottom, 0),
|
||||
vec![5, 4, 2]
|
||||
|
|
@ -5567,9 +5641,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn search_peek_indices_ignore_peeked_entries() {
|
||||
let entries: Vec<EntryBlock> = (0..4)
|
||||
.map(|i| entry(&format!("match {}", i)))
|
||||
.collect();
|
||||
let entries: Vec<EntryBlock> = (0..4).map(|i| entry(&format!("match {}", i))).collect();
|
||||
let session = ListSession {
|
||||
id: "session".to_string(),
|
||||
chat_id: 0,
|
||||
|
|
@ -5710,7 +5782,10 @@ mod tests {
|
|||
};
|
||||
|
||||
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]
|
||||
|
|
@ -5775,10 +5850,7 @@ mod tests {
|
|||
};
|
||||
assert_eq!(norm_target_index(&session, &peeked), Some(1));
|
||||
|
||||
let session_multi = ListSession {
|
||||
entries,
|
||||
..session
|
||||
};
|
||||
let session_multi = ListSession { entries, ..session };
|
||||
let empty_peeked = HashSet::new();
|
||||
assert_eq!(norm_target_index(&session_multi, &empty_peeked), None);
|
||||
}
|
||||
|
|
@ -5816,10 +5888,7 @@ mod tests {
|
|||
#[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!(matches!(parse_pull_mode("theirs"), Ok(PullMode::Theirs)));
|
||||
assert!(parse_pull_mode("unknown").is_err());
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue