opencrabs 0.3.51

The autonomous, self-improving AI agent. Single Rust binary. Every channel. Install with: cargo install opencrabs
Documentation
//! Pure Telegram session title + label-drift helpers (testable without teloxide).
//!
//! Issue #121: naive full-title comparison reverted auto-titled DM sessions back
//! to the default `Telegram: DM …` template on every subsequent message.

/// Build the canonical session title for a Telegram chat.
///
/// `topic_id` scopes the title to a forum topic (#215): `Some` appends
/// `:topic:<id>` to the suffix so each topic in a supergroup resolves to its
/// own session row instead of all topics collapsing onto the bare
/// `[chat:<id>]` session. `None` (DMs, non-forum groups, the General topic)
/// produces the exact same title as before topics existed.
pub fn build_session_title(
    is_dm: bool,
    user_name: &str,
    user_id: i64,
    chat_title: &str,
    chat_id: i64,
    topic_id: Option<i32>,
    topic_name: Option<&str>,
) -> String {
    let suffix = chat_id_suffix(chat_id, topic_id);
    if is_dm {
        format!("Telegram: DM {user_name} ({user_id}) {suffix}")
    } else {
        // A forum topic gets its NAME in the display ("… / Devops"), so the
        // session reads as the topic instead of the numeric thread id. The
        // suffix stays the numeric `:topic:<id>` form for stable resolution.
        match topic_name.map(str::trim).filter(|n| !n.is_empty()) {
            Some(name) if topic_id.is_some() => {
                format!("Telegram: {chat_title} / {name} {suffix}")
            }
            _ => format!("Telegram: {chat_title} {suffix}"),
        }
    }
}

/// Legacy title format (pre suffix) for migration lookups.
pub fn build_legacy_session_title(
    is_dm: bool,
    user_name: &str,
    user_id: i64,
    chat_title: &str,
) -> String {
    if is_dm {
        format!("Telegram: DM {user_name} ({user_id})")
    } else {
        format!("Telegram: {chat_title}")
    }
}

/// Title/lookup suffix for a chat, optionally scoped to a forum topic (#215).
///
/// Base chats stay `[chat:<id>]`; a real forum topic becomes
/// `[chat:<id>:topic:<tid>]`. `find_session_by_title_suffix` matches with
/// `LIKE '%suffix'`, and a base title never ends with `:topic:<n>]` while a
/// topic title never ends with the bare `[chat:<id>]`, so the two never
/// cross-resolve.
pub fn chat_id_suffix(chat_id: i64, topic_id: Option<i32>) -> String {
    match topic_id {
        Some(tid) => format!("[chat:{chat_id}:topic:{tid}]"),
        None => format!("[chat:{chat_id}]"),
    }
}

/// Resolve the forum-topic id used to key a session (#215). Returns the thread
/// id ONLY for a genuine forum-topic message. DMs, non-forum groups, the
/// General topic, and plain reply-threads report `is_topic_message == false`
/// (or no thread id) and resolve to `None`, so they keep sharing the base
/// `[chat:<id>]` session exactly as before. Gating on `is_topic_message` is
/// deliberate: a bare `thread_id` can appear on ordinary reply-threads, which
/// must NOT spawn isolated sessions.
pub fn topic_session_id(is_topic_message: bool, thread_id: Option<i32>) -> Option<i32> {
    if is_topic_message { thread_id } else { None }
}

/// True when a session exceeded the configured idle window (same rule as handler suffix path).
pub fn session_idle_expired(
    updated_at: chrono::DateTime<chrono::Utc>,
    idle_hours: Option<f64>,
) -> bool {
    idle_hours.is_some_and(|h| {
        let elapsed = (chrono::Utc::now() - updated_at).num_seconds();
        elapsed > (h * 3600.0) as i64
    })
}

/// Handler resolve policy: explicit chat binding wins over suffix `updated_at` winner.
///
/// Cold-start behaviour: the `chat_sessions` map that feeds `chat_bound`
/// is in-memory and process-scoped — every opencrabs restart starts the
/// map empty. The first message in any chat after a restart therefore
/// returns `ResolveSource::Suffix` (no binding yet) and falls through to
/// `find_session_by_title_suffix`. That's correct behaviour, just worth
/// being aware of when reading the policy in isolation: an empty map
/// doesn't mean "create a new session", it means "look up the existing
/// row by suffix".
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResolveSource {
    ChatBound,
    Suffix,
    Create,
}

pub fn choose_resolve_source(
    chat_bound: Option<uuid::Uuid>,
    bound_archived: bool,
    suffix_match: Option<uuid::Uuid>,
) -> ResolveSource {
    if chat_bound.is_some() && !bound_archived {
        ResolveSource::ChatBound
    } else if suffix_match.is_some() {
        ResolveSource::Suffix
    } else {
        ResolveSource::Create
    }
}

/// Whether to overwrite a stored session title with the freshly built template.
///
/// - Default DM titles: refresh when the template default changed (display name).
/// - Auto-titled / custom DM titles: never clobber (issue #121).
/// - Telegram groups: refresh when the visible group label changed (suffix stable).
pub fn should_refresh_label(stored: &str, template: &str) -> bool {
    if stored == template {
        return false;
    }

    if crate::brain::agent::service::AgentService::is_default_channel_title(stored) {
        return crate::brain::agent::service::AgentService::is_default_channel_title(template)
            && stored != template;
    }

    if is_telegram_group_session_title(stored) && is_telegram_group_session_title(template) {
        return telegram_middle_label(stored) != telegram_middle_label(template);
    }

    false
}

fn is_telegram_group_session_title(title: &str) -> bool {
    let Some(rest) = title.strip_prefix("Telegram: ") else {
        return false;
    };
    !rest.starts_with("DM ") && title.contains("[chat:")
}

fn telegram_middle_label(title: &str) -> String {
    let body = title.strip_prefix("Telegram: ").unwrap_or(title).trim();
    let suffix = crate::brain::agent::service::AgentService::extract_chat_id_suffix(title);
    if suffix.is_empty() {
        return body.to_string();
    }
    body.strip_suffix(suffix).unwrap_or(body).trim().to_string()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn dm_template_format() {
        let t = build_session_title(true, "Alice", 123, "", 456, None, None);
        assert_eq!(t, "Telegram: DM Alice (123) [chat:456]");
    }

    #[test]
    fn should_not_clobber_auto_titled_dm() {
        let auto = "Telegram: Fix deploy [chat:133526395]";
        let template = build_session_title(true, "Alexey", 133526395, "", 133526395, None, None);
        assert!(!should_refresh_label(auto, &template));
    }

    #[test]
    fn should_refresh_group_rename() {
        let old = "Telegram: Old Group [chat:-1]";
        let new = "Telegram: New Group [chat:-1]";
        assert!(should_refresh_label(old, new));
    }

    #[test]
    fn default_dm_still_refreshes_on_name_change() {
        let old = build_session_title(true, "Alice", 1, "", 99, None, None);
        let new = build_session_title(true, "Bob", 1, "", 99, None, None);
        assert!(should_refresh_label(&old, &new));
    }

    #[test]
    fn chat_bound_wins_over_suffix_candidate() {
        let a = uuid::Uuid::new_v4();
        let b = uuid::Uuid::new_v4();
        assert_eq!(
            choose_resolve_source(Some(a), false, Some(b)),
            ResolveSource::ChatBound
        );
    }

    #[test]
    fn archived_bound_falls_through_to_suffix() {
        let a = uuid::Uuid::new_v4();
        let b = uuid::Uuid::new_v4();
        assert_eq!(
            choose_resolve_source(Some(a), true, Some(b)),
            ResolveSource::Suffix
        );
    }

    #[test]
    fn session_idle_expired_within_and_past_window() {
        let recent = chrono::Utc::now() - chrono::Duration::minutes(30);
        assert!(!session_idle_expired(recent, Some(1.0)));

        let stale = chrono::Utc::now() - chrono::Duration::hours(2);
        assert!(session_idle_expired(stale, Some(1.0)));
        assert!(!session_idle_expired(stale, None));
    }

    #[test]
    fn session_idle_expired_boundary_not_yet_expired() {
        let at_limit = chrono::Utc::now() - chrono::Duration::seconds(3600);
        assert!(!session_idle_expired(at_limit, Some(1.0)));
        let past_limit = chrono::Utc::now() - chrono::Duration::seconds(3601);
        assert!(session_idle_expired(past_limit, Some(1.0)));
    }
}