Refactor bot into modules and DRY message delivery

This commit is contained in:
TheGeneralist 2026-02-25 11:00:58 +01:00
parent 43f2adbefb
commit 35b234c897
Signed by: thegeneralist01
SSH key fingerprint: SHA256:pp9qddbCNmVNoSjevdvQvM5z0DHN7LTa8qBMbcMq/R4
6 changed files with 5533 additions and 5508 deletions

1232
src/callback_handlers.rs Normal file

File diff suppressed because it is too large Load diff

1592
src/helpers.rs Normal file

File diff suppressed because it is too large Load diff

922
src/integrations.rs Normal file
View file

@ -0,0 +1,922 @@
use super::*;
pub(super) fn run_push(sync: &SyncConfig) -> Result<PushOutcome> {
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<PullOutcome> {
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<String> = 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<SyncOutcome> {
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<SyncXOutcome> {
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<Vec<String>> {
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<GitOutput> {
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<Vec<String>> {
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::<Vec<_>>();
Ok(names)
}
pub(super) fn git_remote_url(repo_path: &Path, remote: &str) -> Result<String> {
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<String> {
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<String> {
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<String> {
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<PullMode, String> {
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<TempPath> {
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<String> {
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<AppState>,
link: &str,
format_selector: &str,
) -> Result<PathBuf> {
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<Vec<DownloadQualityOption>> {
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<i64, (String, String, Option<u64>, bool)> = HashMap::new();
let mut best_audio: Option<(String, String, Option<u64>, Option<f64>)> = 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<i64> = 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<PathBuf> {
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
}

File diff suppressed because it is too large Load diff

1367
src/message_handlers.rs Normal file

File diff suppressed because it is too large Load diff

408
src/tests.rs Normal file
View file

@ -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<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());
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<EntryBlock> = (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::<Vec<_>>();
assert_eq!(
blocks,
vec![
"- https://one.example".to_string(),
"- https://two.example".to_string(),
"- https://already.example".to_string(),
]
);
}