opencrabs 0.5.1

The autonomous, self-improving AI agent. Single Rust binary. Every channel. Recommended: the 40MB prebuilt binary for macOS, Linux and Windows: https://github.com/adolfousier/opencrabs/releases
//! Scope cron jobs so they may send only to their configured targets.
//!
//! A cron job runs with no channel origin: nothing binds its session to a
//! chat. The proactive send path resolves a destination from the tool input
//! first, and a job's turn can pick that input up from anywhere it reads,
//! including a recalled memory. On 2026-08-21 a memory note from two weeks
//! earlier recorded a chat id and thread id under the heading "CONTINUE THIS
//! TASK", and a job posted its report into that group — one it was never
//! configured for, whose members had asked nothing.
//!
//! The rule this enforces: a cron turn may send only to the target its job was
//! created with. With no target it sends to no channel at all, and its output
//! lives in its own session. Recalled text can shape what a report says; it can
//! never decide where the report goes.
//!
//! #148 extends this across all channel authorities (telegram, discord,
//! slack, whatsapp), matching the central resolver's authority grammar.

/// A permitted delivery destination for a cron turn.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PermittedTarget {
    pub channel: &'static str,
    pub target_id: String,
}

tokio::task_local! {
    /// The permitted channel targets for this cron turn. `None` in the task
    /// local means not a cron turn (unscoped); an empty `Vec` means a cron
    /// turn whose job named no `deliver_to` (sends nowhere).
    static CRON_PERMITTED_TARGETS: Option<Vec<PermittedTarget>>;
}

/// Run `fut` with cron send scoping active, permitting only `targets`.
/// Task-local, so it covers every await inside the turn and never reaches
/// a sibling job on the scheduler.
pub async fn with_permitted_targets<F, T>(targets: Option<Vec<PermittedTarget>>, fut: F) -> T
where
    F: std::future::Future<Output = T>,
{
    CRON_PERMITTED_TARGETS.scope(targets, fut).await
}

/// Compatibility wrapper for the Telegram-only single-chat form.
pub async fn with_send_target<F, T>(target: Option<i64>, fut: F) -> T
where
    F: std::future::Future<Output = T>,
{
    let targets = match target {
        Some(chat) => Some(vec![PermittedTarget {
            channel: "telegram",
            target_id: chat.to_string(),
        }]),
        None => Some(Vec::new()),
    };
    with_permitted_targets(targets, fut).await
}

/// What this turn may do with a proactive send.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SendPermission {
    /// Not a cron turn. The ordinary channel rules apply.
    Unscoped,
    /// A cron turn with explicit permitted targets.
    Permitted(Vec<PermittedTarget>),
    /// A cron turn whose job named no target.
    Nowhere,
}

/// The permission in force for the current task.
pub fn permission() -> SendPermission {
    CRON_PERMITTED_TARGETS
        .try_with(|targets| match targets {
            Some(list) if list.is_empty() => SendPermission::Nowhere,
            Some(list) => SendPermission::Permitted(list.clone()),
            None => SendPermission::Unscoped,
        })
        .unwrap_or(SendPermission::Unscoped)
}

/// May the current task send to `(channel, target_id)`?
///
/// Outside a cron turn this is always true: the rule exists to stop a job
/// reaching destinations it was never given, not to police ordinary replies.
pub fn may_send(channel: &str, target_id: &str) -> bool {
    match permission() {
        SendPermission::Unscoped => true,
        SendPermission::Permitted(list) => list
            .iter()
            .any(|p| p.channel == channel && p.target_id == target_id),
        SendPermission::Nowhere => false,
    }
}

/// May the current task send to Telegram `chat_id`? (Compatibility helper).
pub fn may_send_to(chat_id: i64) -> bool {
    may_send("telegram", &chat_id.to_string())
}

/// Why a send was refused, for the tool result the model reads.
pub fn refusal_for(channel: &str, target_id: &str) -> String {
    match permission() {
        SendPermission::Permitted(list) => {
            let allowed_str = list
                .iter()
                .map(|p| format!("{}:{}", p.channel, p.target_id))
                .collect::<Vec<_>>()
                .join(", ");
            format!(
                "Refused: this scheduled job may only send to [{allowed_str}], and this send \
                 targeted {channel}:{target_id}. If the report belongs in another channel, \
                 change the job's deliver_to; an address found in memory or in earlier context \
                 is not permission to post there."
            )
        }
        _ => format!(
            "Refused: this scheduled job has no deliver_to, so it may not send to any channel \
             (attempted {channel}:{target_id}). Its output stays in its own session. Set \
             deliver_to on the job if it should report to a channel."
        ),
    }
}

/// Compatibility refusal for Telegram chat_id.
pub fn refusal(chat_id: i64) -> String {
    refusal_for("telegram", &chat_id.to_string())
}