opencrabs 0.3.67

The autonomous, self-improving AI agent. Single Rust binary. Every channel. Install with: cargo install opencrabs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
//! Incoming Telegram media: caption merging, file download/saving to tmp,
//! recent-tmp-file lookup for voice/attachment turns, and the size-capped
//! getFile wrapper.
//!
//! Moved VERBATIM out of handler.rs (#471 phase 1, pure decomposition —
//! only visibility widened to pub(crate) so the handler glob re-export
//! keeps every existing call site and test import stable).

use super::send::message_in_thread;
use teloxide::prelude::*;

pub(crate) fn prepend_caption(caption: &str, body: String) -> String {
    if caption.trim().is_empty() {
        body
    } else {
        format!("{caption}\n\n{body}")
    }
}

/// Fire-and-forget: save any incoming voice/document/audio file to
/// `~/.opencrabs/tmp/` so the agent can pick them up later when tagged.
/// This runs for ALL incoming messages regardless of mention-only status.
/// Rewrite `<<IMG:local_path>>` markers in `text` to their archived location
/// under the session's project files dir. Each local image is tracked via
/// `FileService`, which copies it into `projects/<name>/files/` when the
/// session belongs to a project; for non-project sessions (or URLs) the path
/// is returned unchanged, so the marker is left as-is.
pub(crate) async fn archive_image_markers(
    text: &str,
    session_id: uuid::Uuid,
    fs: &crate::services::FileService,
) -> String {
    use std::path::PathBuf;
    let mut replacements: Vec<(String, String)> = Vec::new();
    let mut rest = text;
    while let Some(start) = rest.find("<<IMG:") {
        let after = &rest[start + 6..];
        let Some(end) = after.find(">>") else { break };
        let path = &after[..end];
        if !path.starts_with("http")
            && let Ok(file) = fs
                .get_or_create_file(session_id, PathBuf::from(path), None)
                .await
        {
            let new = file.path.to_string_lossy().to_string();
            if new != path {
                replacements.push((path.to_string(), new));
            }
        }
        rest = &after[end + 2..];
    }
    let mut out = text.to_string();
    for (old, new) in replacements {
        out = out.replace(&format!("<<IMG:{old}>>"), &format!("<<IMG:{new}>>"));
    }
    out
}

/// Sanitize one filename component: keep alphanumerics plus `.`/`-`/`_`,
/// collapse everything else to `_`, and never yield an empty string. Mirrors
/// the guard the `channel_attachments` writer uses so both stores agree (#513).
pub(crate) fn sanitize_filename_component(s: &str) -> String {
    let cleaned: String = s
        .chars()
        .map(|c| {
            if c.is_alphanumeric() || c == '.' || c == '-' || c == '_' {
                c
            } else {
                '_'
            }
        })
        .collect();
    let trimmed = cleaned.trim_matches('_');
    if trimmed.is_empty() {
        "file".to_string()
    } else {
        trimmed.to_string()
    }
}

/// Build the tmp filename for a named attachment (document / audio) so it LEADS
/// with the sender's original filename stem, then appends `-{chat_id}-{ts}.{ext}`
/// (#513). Leading with the real name makes the file human-findable; the
/// chat_id/ts suffix keeps the origin chat detectable and makes re-posts
/// collision-safe, the same reason voice/photo keep it. Falls back to
/// `{fallback_stem}-{chat_id}-{ts}.{fallback_ext}` when no filename is present.
/// Documents/audio are never scanned by `find_recent_tmp_file` (photo/voice
/// only), so leading with the name breaks no pickup path.
pub(crate) fn attachment_tmp_name(
    original: Option<&str>,
    fallback_stem: &str,
    chat_id: i64,
    ts: i64,
    fallback_ext: &str,
) -> String {
    let (stem, ext) = match original {
        Some(name) => {
            // rsplit yields the whole string when there is no `.`; the filter
            // rejects that so a dotless name falls back to the default ext.
            let ext = name
                .rsplit('.')
                .next()
                .filter(|e| *e != name)
                .unwrap_or(fallback_ext);
            let stem = name.strip_suffix(&format!(".{ext}")).unwrap_or(name);
            (sanitize_filename_component(stem), ext.to_string())
        }
        None => (fallback_stem.to_string(), fallback_ext.to_string()),
    };
    let ext = sanitize_filename_component(&ext);
    format!("{stem}-{chat_id}-{ts}.{ext}")
}

/// One-time cleanup: sweep files sitting flat in `channel_attachments/` into
/// the per-platform `channel_attachments/telegram/` subdir. Everything stored
/// before per-platform subdirs existed came from Telegram, so a loose file is
/// a pre-subdir Telegram attachment (#513). Uses the same non-profile-aware
/// base as the store (`~/.opencrabs/channel_attachments`), so it targets the
/// exact dir new files are written to.
pub(crate) fn migrate_flat_channel_attachments() {
    let base = dirs::home_dir()
        .unwrap_or_else(|| std::path::PathBuf::from("."))
        .join(".opencrabs")
        .join("channel_attachments");
    let moved = migrate_flat_attachments_in(&base, "telegram");
    if moved > 0 {
        tracing::info!("channel_attachments: migrated {moved} flat file(s) → telegram/");
    }
}

/// Core of [`migrate_flat_channel_attachments`], parameterised on the base dir
/// and target platform subdir so it is unit-testable against a tempdir. Moves
/// only regular files (never the platform subdirs), skips a move when the dest
/// name already exists, and is idempotent. Returns the number of files moved.
pub(crate) fn migrate_flat_attachments_in(base: &std::path::Path, platform: &str) -> u32 {
    let Ok(entries) = std::fs::read_dir(base) else {
        return 0;
    };
    let target = base.join(platform);
    let mut moved = 0u32;
    for entry in entries.flatten() {
        let path = entry.path();
        // Skip the platform subdirs themselves; only loose files migrate.
        if !path.is_file() {
            continue;
        }
        let Some(name) = path.file_name() else {
            continue;
        };
        if let Err(e) = std::fs::create_dir_all(&target) {
            tracing::warn!("channel_attachments migration: mkdir failed: {e}");
            return moved;
        }
        let dest = target.join(name);
        if dest.exists() {
            continue; // name clash — leave the original in place
        }
        match std::fs::rename(&path, &dest) {
            Ok(()) => moved += 1,
            Err(e) => tracing::warn!("channel_attachments migration: rename failed: {e}"),
        }
    }
    moved
}

pub(crate) async fn save_incoming_files_to_tmp(bot: &Bot, msg: &Message, bot_token: &str) {
    use std::path::PathBuf;

    // Skip private chats — the bot will process those directly
    if matches!(msg.chat.kind, teloxide::types::ChatKind::Private { .. }) {
        return;
    }

    // Profile-aware: same tmp dir save_to_temp uses, so saves and pickups
    // agree and a profile's files stay under that profile.
    let tmp_dir: PathBuf = crate::config::opencrabs_home().join("tmp");
    let _ = std::fs::create_dir_all(&tmp_dir);

    let chat_id = msg.chat.id.0;
    let ts = chrono::Utc::now().timestamp();

    // Voice messages (.ogg)
    if let Some(voice) = msg.voice() {
        save_telegram_file(
            bot,
            bot_token,
            voice.file.id.clone(),
            &tmp_dir,
            &format!("voice-{chat_id}-{ts}.ogg"),
        )
        .await;
    }
    // Video notes (.mp4)
    if let Some(vn) = msg.video_note() {
        save_telegram_file(
            bot,
            bot_token,
            vn.file.id.clone(),
            &tmp_dir,
            &format!("video_note-{chat_id}-{ts}.mp4"),
        )
        .await;
    }
    // Documents: lead with the original filename, keep the chat_id/ts suffix
    // for origin detection and collision-safety (#513).
    if let Some(doc) = msg.document() {
        let name = attachment_tmp_name(doc.file_name.as_deref(), "doc", chat_id, ts, "bin");
        save_telegram_file(bot, bot_token, doc.file.id.clone(), &tmp_dir, &name).await;
    }
    // Audio files (.mp3/.ogg/.wav etc): same original-name-leading scheme (#513).
    if let Some(audio) = msg.audio() {
        let name = attachment_tmp_name(audio.file_name.as_deref(), "audio", chat_id, ts, "ogg");
        save_telegram_file(bot, bot_token, audio.file.id.clone(), &tmp_dir, &name).await;
    }
    // Photos (largest size) — so an image shared without @mentioning the bot
    // can still be picked up when the user tags it in a follow-up message.
    // Telegram orders sizes smallest→largest, so the last entry is the best.
    if let Some(largest) = msg.photo().and_then(|sizes| sizes.last()) {
        save_telegram_file(
            bot,
            bot_token,
            largest.file.id.clone(),
            &tmp_dir,
            &format!("photo-{chat_id}-{ts}.jpg"),
        )
        .await;
    }
}

/// Download a file from Telegram by file_id and save to the given path.
pub(crate) async fn save_telegram_file(
    bot: &Bot,
    bot_token: &str,
    file_id: teloxide::types::FileId,
    dir: &std::path::Path,
    filename: &str,
) {
    let file = match bot.get_file(file_id).await {
        Ok(f) => f,
        Err(e) => {
            tracing::warn!("Telegram: tmp save: get_file failed: {e}");
            return;
        }
    };
    let url = format!("https://api.telegram.org/file/bot{bot_token}/{}", file.path);
    let bytes = match reqwest::get(&url).await {
        Ok(r) => match r.bytes().await {
            Ok(b) => b,
            Err(e) => {
                tracing::warn!("Telegram: tmp save: read bytes failed: {e}");
                return;
            }
        },
        Err(e) => {
            tracing::warn!("Telegram: tmp save: download failed: {e}");
            return;
        }
    };
    let path = dir.join(filename);
    match std::fs::write(&path, &bytes) {
        Ok(()) => tracing::info!("Telegram: saved incoming file → {}", path.display()),
        Err(e) => tracing::warn!("Telegram: tmp save: write failed: {e}"),
    }
}

/// Check `~/.opencrabs/tmp/` for the most recent voice/audio file from a
/// specific chat (within `max_age_secs`). Returns the path if found.
pub(crate) fn find_recent_voice_in_tmp(
    chat_id: i64,
    max_age_secs: i64,
) -> Option<std::path::PathBuf> {
    find_recent_tmp_file(chat_id, "voice", max_age_secs)
}

/// Find the newest `~/.opencrabs/tmp/{kind}-{chat_id}-{ts}.*` file within
/// `max_age_secs`. Used to pick up a voice/photo a user sent to a mention-only
/// group *before* tagging the bot (the file was stored fire-and-forget on
/// arrival; this retrieves it when the follow-up @mention finally triggers us).
pub(crate) fn find_recent_tmp_file(
    chat_id: i64,
    kind: &str,
    max_age_secs: i64,
) -> Option<std::path::PathBuf> {
    use std::path::PathBuf;

    // Profile-aware: same tmp dir save_to_temp uses, so saves and pickups
    // agree and a profile's files stay under that profile.
    let tmp_dir: PathBuf = crate::config::opencrabs_home().join("tmp");

    let now = chrono::Utc::now().timestamp();
    let prefix = format!("{kind}-{chat_id}-");

    let mut best: Option<(i64, PathBuf)> = None;

    let entries = std::fs::read_dir(&tmp_dir).ok()?;
    for entry in entries.flatten() {
        let name = entry.file_name();
        let name_str = name.to_string_lossy();
        if !name_str.starts_with(&prefix) {
            continue;
        }
        // Extract timestamp from filename: voice-{chat_id}-{ts}.ogg
        let ts_str = name_str.strip_prefix(&prefix)?.split('.').next()?;
        let ts: i64 = ts_str.parse().ok()?;
        if now - ts > max_age_secs {
            continue;
        }
        match &best {
            Some((best_ts, _)) if ts <= *best_ts => {}
            _ => best = Some((ts, entry.path())),
        }
    }
    best.map(|(_, p)| p)
}

/// Like [`find_recent_tmp_file`] but returns ALL matching files, sorted
/// oldest-first. Used for multi-photo pickup so every image the user
/// dropped is included, not just the last one.
pub(crate) fn find_all_recent_tmp_files(
    chat_id: i64,
    kind: &str,
    max_age_secs: i64,
) -> Vec<std::path::PathBuf> {
    use std::path::PathBuf;

    let tmp_dir: PathBuf = crate::config::opencrabs_home().join("tmp");
    let now = chrono::Utc::now().timestamp();
    let prefix = format!("{kind}-{chat_id}-");

    let mut results: Vec<(i64, PathBuf)> = Vec::new();
    let entries = match std::fs::read_dir(&tmp_dir) {
        Ok(e) => e,
        Err(_) => return Vec::new(),
    };
    for entry in entries.flatten() {
        let name = entry.file_name();
        let name_str = name.to_string_lossy();
        if !name_str.starts_with(&prefix) {
            continue;
        }
        let ts_str = match name_str
            .strip_prefix(&prefix)
            .and_then(|s| s.split('.').next())
        {
            Some(s) => s,
            None => continue,
        };
        let ts: i64 = match ts_str.parse() {
            Ok(t) => t,
            Err(_) => continue,
        };
        if now - ts <= max_age_secs {
            results.push((ts, entry.path()));
        }
    }
    results.sort_by_key(|(ts, _)| *ts);
    results.into_iter().map(|(_, p)| p).collect()
}

/// Wrap `bot.get_file(...)` so size-limit failures (and other errors) become
/// a user-visible reply instead of a silent error in the bot logs.
///
/// Telegram's Bot API enforces a hard 20 MB cap on `getFile` even though chat
/// uploads may be much larger. When a user sends a video, animation, document,
/// or video_note that exceeds this cap the API returns
/// `Bad Request: file is too big`. Without this helper the `?` operator
/// bubbled the error up and the user heard nothing back.
///
/// Returns `Some(File)` on success, or `None` after notifying the user (caller
/// should `return Ok(())`).
pub(crate) async fn fetch_file_or_notify(
    bot: &Bot,
    file_id: teloxide::types::FileId,
    chat_id: ChatId,
    thread_id: Option<teloxide::types::ThreadId>,
    kind: &str,
) -> Option<teloxide::types::File> {
    use teloxide::payloads::SendMessageSetters;
    match bot.get_file(file_id.clone()).await {
        Ok(f) => Some(f),
        Err(e) => {
            let s = e.to_string();
            let reply = if s.contains("file is too big") {
                format!(
                    "📎 That {kind} exceeds Telegram's 20 MB Bot API download limit. \
                     The chat itself accepts larger files but bots cannot fetch them. \
                     Please trim or compress the {kind} to under 20 MB and resend."
                )
            } else {
                format!("Failed to fetch {kind}: {s}")
            };
            tracing::warn!("Telegram: get_file failed for {}: {}", kind, s);
            if let Err(send_err) = message_in_thread(bot, chat_id, thread_id, reply)
                .disable_notification(true)
                .await
            {
                tracing::warn!(
                    "Telegram: failed to send size-limit reply to user: {}",
                    send_err
                );
            }
            None
        }
    }
}