use crate::channels::ChannelFactory;
use crate::config::Config;
use crate::db::CronJobRepository;
use crate::db::CronJobRunRepository;
use crate::db::models::{CronJob, CronJobRun};
use crate::db::repository::CronJobPatch;
use crate::services::{ServiceContext, SessionService};
use chrono::Utc;
use std::sync::Arc;
use tracing::Instrument;
use uuid::Uuid;
fn is_active_profile(job_profile: Option<&str>, active: Option<&str>) -> bool {
match job_profile {
None => true,
Some(stamped) => stamped == active.unwrap_or("default"),
}
}
pub const REBUILD_JOB_NAME: &str = "__opencrabs_rebuild__";
pub const DEDUP_SCAN_JOB_NAME: &str = "__opencrabs_dedup_scan__";
pub(crate) const DEDUP_SCAN_CRON: &str = "0 4 * * 1";
pub(crate) const LEGACY_DEDUP_SCAN_CRON: &str = "0 4 * * 0";
static INVALID_EXPR_WARNED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
pub async fn schedule_background_rebuild(
pool: crate::db::Pool,
session_id: Uuid,
deliver_to: Option<String>,
) -> anyhow::Result<()> {
let repo = CronJobRepository::new(pool);
if let Ok(existing) = repo.list_all().await {
for j in existing.iter().filter(|j| j.name == REBUILD_JOB_NAME) {
if let Err(e) = repo.delete(&j.id.to_string()).await {
tracing::warn!(error = %e, job_id = %j.id, "failed to delete cron job");
}
}
}
let now = Utc::now();
let job = CronJob {
id: Uuid::new_v4(),
name: REBUILD_JOB_NAME.to_string(),
cron_expr: "* * * * *".to_string(),
timezone: "UTC".to_string(),
prompt: session_id.to_string(),
provider: None,
model: None,
thinking: "off".to_string(),
auto_approve: true,
deliver_to,
deliver_api_key: None,
enabled: true,
last_run_at: None,
next_run_at: None,
created_at: now,
updated_at: now,
profile_name: Some(crate::config::profile::current_profile_name()),
};
repo.insert(&job).await?;
tracing::info!("Background rebuild queued for session {session_id}");
Ok(())
}
pub(crate) async fn ensure_weekly_dedup_scan_job(repo: &CronJobRepository) -> anyhow::Result<()> {
if let Ok(existing) = repo.list_all().await
&& let Some(job) = existing.iter().find(|j| j.name == DEDUP_SCAN_JOB_NAME)
{
if job.cron_expr == LEGACY_DEDUP_SCAN_CRON {
let patch = CronJobPatch {
cron_expr: Some(DEDUP_SCAN_CRON.to_string()),
reset_next_run: true,
..Default::default()
};
match repo.update_fields(&job.id.to_string(), patch).await {
Ok(true) => tracing::info!(
"Repaired legacy dedup-scan schedule '{}' -> '{}' (#1163)",
LEGACY_DEDUP_SCAN_CRON,
DEDUP_SCAN_CRON
),
Ok(false) => {}
Err(e) => {
tracing::warn!(error = %e, "failed to repair legacy dedup-scan schedule")
}
}
}
return Ok(());
}
let job = CronJob::new(
DEDUP_SCAN_JOB_NAME.to_string(),
DEDUP_SCAN_CRON.to_string(),
"UTC".to_string(),
"reserved: weekly cross-file brain dedup scan (report-only)".to_string(),
None,
None,
"off".to_string(),
true,
Some("telegram:-1002554690655".to_string()),
None,
);
repo.insert(&job).await?;
tracing::info!("Seeded weekly brain dedup scan job ({DEDUP_SCAN_JOB_NAME})");
Ok(())
}
async fn run_dedup_scan_job(job: &CronJob) -> anyhow::Result<()> {
let brain_dir = crate::config::opencrabs_home();
let store = crate::brain::rsi_proposals::ProposalsStore::new();
store.prune_handled();
let filed = crate::brain::dedup_scan::file_dedup_proposals(&brain_dir, &store);
let pending = store.list_brain_dedup_proposals().len();
tracing::info!(
"Weekly brain dedup scan complete: {filed} new proposal(s), {pending} pending total"
);
if pending > 0 {
let msg = format!(
"🧹 Weekly brain dedup scan: {pending} pending cross-file duplicate proposal(s) \
({filed} new this run). Review and approve in the Mission Control inbox."
);
deliver_rebuild_status(job, &msg).await;
send_dedup_approval_keyboard(job).await;
}
Ok(())
}
#[cfg(feature = "telegram")]
async fn send_dedup_approval_keyboard(job: &CronJob) {
let Some((chat_id, _thread)) = job.deliver_to.as_deref().and_then(|targets| {
targets
.split(',')
.map(str::trim)
.find_map(|t| t.strip_prefix("telegram:"))
.and_then(parse_telegram_target)
}) else {
return;
};
let Some(token) = read_channel_secret("telegram", "token") else {
tracing::warn!("No Telegram bot token in keys.toml, cannot send dedup approval keyboard");
return;
};
let bot = teloxide::Bot::new(token);
match crate::channels::telegram::dedup_approval::send_approval_request(
&bot,
teloxide::types::ChatId(chat_id),
)
.await
{
Ok(0) => {}
Ok(n) => tracing::info!(
"Sent dedup approval keyboard to Telegram chat {chat_id} ({n} proposal(s))"
),
Err(e) => tracing::warn!("Failed to send dedup approval keyboard to {chat_id}: {e}"),
}
}
#[cfg(not(feature = "telegram"))]
async fn send_dedup_approval_keyboard(_job: &CronJob) {}
async fn run_rebuild_job(
job: &CronJob,
ctx: &ServiceContext,
session_notifier: Option<&SessionNotifier>,
) -> anyhow::Result<()> {
use crate::brain::SelfUpdater;
let repo = CronJobRepository::new(ctx.pool());
if let Err(e) = repo.delete(&job.id.to_string()).await {
tracing::error!("rebuild job: failed to delete self: {e}");
}
let session_id = Uuid::parse_str(job.prompt.trim()).unwrap_or_else(|_| Uuid::nil());
tracing::info!("Background rebuild starting (will resume session {session_id})");
let updater =
SelfUpdater::auto_detect().map_err(|e| anyhow::anyhow!("rebuild: auto_detect: {e}"))?;
match updater
.build_streaming(|line| tracing::debug!("rebuild: {line}"))
.await
{
Ok(built_path) => {
tracing::info!(
"Background rebuild succeeded: {} — reloading",
built_path.display()
);
let handles = deliver_rebuild_status(
job,
"✅ Rebuilt from source — reloading into the new binary now.",
)
.await;
if !handles.is_empty() {
tracing::info!(
"Awaiting {} delivery handle(s) before exec()",
handles.len()
);
futures::future::join_all(handles).await;
}
if !session_id.is_nil() {
let msg_svc = crate::services::MessageService::new(ctx.clone());
let report = format!(
"✅ Background rebuild succeeded — binary at {}. Hot-reloading now.",
built_path.display()
);
match msg_svc
.create_message(session_id, "assistant".to_string(), report)
.await
{
Ok(_) => tracing::info!(
"Persisted rebuild completion report to session {session_id}"
),
Err(e) => tracing::error!("Failed to persist rebuild completion report: {e}"),
}
}
if let Err(e) = SelfUpdater::restart_into(&built_path, session_id) {
tracing::error!("Background rebuild restart failed: {e}");
return Err(anyhow::anyhow!("rebuild restart failed: {e}"));
}
Ok(()) }
Err(out) => {
tracing::error!("Background rebuild failed: {out}");
let msg = format!("⚠️ Background rebuild failed:\n{out}");
if let Some(notify) = session_notifier {
notify(session_id, msg.clone());
}
let _ = deliver_rebuild_status(job, &msg).await; Ok(())
}
}
}
async fn deliver_rebuild_status(job: &CronJob, msg: &str) -> Vec<tokio::task::JoinHandle<()>> {
let mut handles = Vec::new();
if let Some(ref deliver_to) = job.deliver_to {
for target in deliver_to
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
{
if let Some(h) =
deliver_result(target, &job.name, msg, job.deliver_api_key.as_deref(), None).await
{
handles.push(h);
}
}
}
handles
}
pub type SessionNotifier = Arc<dyn Fn(Uuid, String) + Send + Sync>;
pub struct CronScheduler {
repo: CronJobRepository,
run_repo: CronJobRunRepository,
factory: Arc<ChannelFactory>,
service_context: ServiceContext,
session_notifier: Option<SessionNotifier>,
}
impl CronScheduler {
pub fn new(
repo: CronJobRepository,
run_repo: CronJobRunRepository,
factory: Arc<ChannelFactory>,
service_context: ServiceContext,
) -> Self {
Self {
repo,
run_repo,
factory,
service_context,
session_notifier: None,
}
}
pub fn with_session_notifier(mut self, notifier: SessionNotifier) -> Self {
self.session_notifier = Some(notifier);
self
}
pub fn spawn(self) -> tokio::task::JoinHandle<()> {
tokio::spawn(self.run())
}
pub async fn run(self) {
tracing::info!(
"Cron scheduler started — polling every 60s (shared Cron session, compaction-isolated)"
);
if let Err(e) = ensure_weekly_dedup_scan_job(&self.repo).await {
tracing::warn!("Failed to seed weekly brain dedup scan job: {e}");
}
loop {
if let Err(e) = self.tick().await {
tracing::error!("Cron scheduler tick error: {e}");
}
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
}
}
async fn tick(&self) -> anyhow::Result<()> {
let jobs = self.repo.list_enabled().await?;
let now = Utc::now();
for job in &jobs {
if self.is_due(job, now) {
tracing::info!("Cron job '{}' ({}) is due — executing", job.name, job.id);
let next_run = match job.next_run_at {
Some(_) => self.next_run_after(job, now),
None => match super::next_run_utc(&job.cron_expr, job_tz(job), now) {
Some(boundary) => self.next_run_after(job, boundary),
None => None,
},
};
let next_run_str = next_run.map(|dt| dt.to_rfc3339());
self.repo
.update_last_run(&job.id.to_string(), next_run_str.as_deref())
.await?;
let job = job.clone();
let factory = self.factory.clone();
let ctx = self.service_context.clone();
let run_repo = self.run_repo.clone();
let notifier = self.session_notifier.clone();
let job_name = job.name.clone();
let job_id = job.id;
tokio::spawn(
async move {
let profile = job.profile_name.as_deref();
let active = crate::config::profile::active_profile().unwrap_or("default");
let needs_scope = profile.is_some() && profile != Some(active);
let result = if needs_scope {
crate::config::profile::with_profile_home_async(profile, async {
tracing::info!(
"Cron job '{}' — task-local profile home set to {:?}",
job.name,
crate::config::opencrabs_home()
);
match resolve_or_create_cron_session(&ctx, &job).await {
Ok(cron_sid) => {
execute_job(
&job,
&factory,
&ctx,
cron_sid,
&run_repo,
notifier.as_ref(),
)
.await
}
Err(e) => Err(e),
}
})
.await
} else {
match resolve_or_create_cron_session(&ctx, &job).await {
Ok(cron_sid) => {
execute_job(
&job,
&factory,
&ctx,
cron_sid,
&run_repo,
notifier.as_ref(),
)
.await
}
Err(e) => Err(e),
}
};
if let Err(e) = result {
tracing::error!("Cron job '{}' failed: {e}", job.name);
}
}
.instrument(tracing::info_span!("job", name = %job_name, id = %job_id)),
);
}
}
Ok(())
}
fn is_due(&self, job: &CronJob, now: chrono::DateTime<Utc>) -> bool {
match &job.next_run_at {
Some(next) => *next <= now,
None => {
match super::next_run_utc(&job.cron_expr, job_tz(job), now) {
Some(next) => (next - now).num_seconds() <= 60,
None => {
if !INVALID_EXPR_WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) {
tracing::warn!(
"Invalid cron expression for job '{}': {} (suppressing further warnings until restart)",
job.name,
job.cron_expr
);
}
false
}
}
}
}
}
fn next_run_after(
&self,
job: &CronJob,
after: chrono::DateTime<Utc>,
) -> Option<chrono::DateTime<Utc>> {
super::next_run_utc(&job.cron_expr, job_tz(job), after)
}
}
pub(crate) fn cron_session_title_suffix(job: &CronJob) -> String {
format!("[cron-job:{}]", job.id)
}
pub(crate) async fn resolve_or_create_cron_session(
ctx: &ServiceContext,
job: &CronJob,
) -> anyhow::Result<Uuid> {
let suffix = cron_session_title_suffix(job);
let title = format!("Cron: {} {}", job.name, suffix);
use crate::db::repository::SessionListOptions;
let session_svc = SessionService::new(ctx.clone());
let sessions = session_svc
.list_sessions(SessionListOptions {
include_archived: false,
limit: None,
offset: 0,
query: None,
include_subagents: false,
})
.await?;
if let Some(existing) = sessions
.iter()
.find(|s| s.title.as_deref().is_some_and(|n| n.ends_with(&suffix)))
{
return Ok(existing.id);
}
let config = Config::load()?;
let provider = config.cron.default_provider.clone();
let model = config.cron.default_model.clone();
let session = session_svc
.create_session_with_provider(Some(title), provider, model, None)
.await?;
Ok(session.id)
}
fn job_tz(job: &CronJob) -> chrono_tz::Tz {
super::parse_timezone(&job.timezone).unwrap_or(chrono_tz::UTC)
}
async fn resolve_job_agent(
job: &CronJob,
factory: &ChannelFactory,
ctx: &ServiceContext,
) -> anyhow::Result<(Config, Arc<crate::brain::agent::AgentService>)> {
let current = crate::config::profile::current_profile_name();
if is_active_profile(job.profile_name.as_deref(), Some(¤t)) {
return Ok((Config::load()?, factory.create_agent_service().await));
}
let profile = job.profile_name.as_deref();
tracing::info!(
"Cron job '{}' belongs to profile {:?} (current profile {:?}); \
running under its own profile context",
job.name,
profile,
current
);
let (config, brain, home) = crate::config::profile::with_profile_home(profile, || {
let config = Config::load()?;
let home = crate::config::opencrabs_home();
let brain =
crate::brain::prompt_builder::BrainLoader::new(home.clone()).build_core_brain(None);
anyhow::Ok((config, brain, home))
})?;
let provider = crate::brain::provider::create_provider(&config).await?;
let mut builder = crate::brain::agent::AgentService::new(provider, ctx.clone(), &config)
.await
.with_system_brain(brain)
.with_working_directory(home.clone())
.with_brain_path(home);
if let Some(registry) = factory.tool_registry() {
builder = builder.with_tool_registry(registry);
}
builder = builder.with_headless(true);
Ok((config, Arc::new(builder)))
}
async fn execute_job(
job: &CronJob,
factory: &ChannelFactory,
ctx: &ServiceContext,
cron_session_id: Uuid,
run_repo: &CronJobRunRepository,
session_notifier: Option<&SessionNotifier>,
) -> anyhow::Result<()> {
if job.name == REBUILD_JOB_NAME {
return run_rebuild_job(job, ctx, session_notifier).await;
}
if job.name == DEDUP_SCAN_JOB_NAME {
return run_dedup_scan_job(job).await;
}
let (config, agent) = resolve_job_agent(job, factory, ctx).await?;
let effective_provider = job
.provider
.clone()
.or_else(|| config.cron.default_provider.clone());
let effective_model = job
.model
.clone()
.or_else(|| config.cron.default_model.clone());
if let Some(ref provider_name) = effective_provider
&& let Some(ref model) = effective_model
{
match crate::brain::provider::create_provider_by_name(&config, provider_name).await {
Ok(provider) => {
let supported = provider.supported_models();
if !supported.is_empty() && !supported.iter().any(|m| m == model) {
tracing::error!(
"Cron job '{}' — model '{}' is NOT supported by provider '{}' \
(supported: {}). SKIPPING job — fix cron config. \
Either set a valid model or remove the model override to use \
the provider's default ('{}').",
job.name,
model,
provider_name,
supported.join(", "),
provider.default_model(),
);
let run = CronJobRun::new_running(
job.id,
job.name.clone(),
effective_provider.clone(),
effective_model.clone(),
);
let run_id = run.id.to_string();
if let Err(e) = run_repo.insert(&run).await {
tracing::error!("Failed to insert cron run record: {e}");
}
let err_msg = format!(
"model '{}' not supported by provider '{}' — cron config invalid",
model, provider_name
);
if let Err(db_err) = run_repo.complete_error(&run_id, &err_msg).await {
tracing::error!("Failed to save cron run error to DB: {db_err}");
}
return Ok(());
}
}
Err(e) => {
tracing::warn!(
"Cron job '{}' — cannot pre-validate model (provider '{}' creation \
failed: {e}) — proceeding with default validation",
job.name,
provider_name
);
}
}
}
let run = CronJobRun::new_running(
job.id,
job.name.clone(),
effective_provider.clone(),
effective_model.clone(),
);
let run_id = run.id.to_string();
if let Err(e) = run_repo.insert(&run).await {
tracing::error!("Failed to insert cron run record: {e}");
}
let session_id = cron_session_id;
tracing::info!(
"Cron job '{}' — using cron session {}",
job.name,
session_id
);
if let Some(ref provider_name) = effective_provider {
match crate::brain::provider::create_provider_by_name(&config, provider_name).await {
Ok(provider) => {
tracing::info!(
"Cron job '{}' — using provider '{}'",
job.name,
provider_name
);
agent.swap_provider_for_session(
cron_session_id,
provider.clone(),
provider.default_model().to_string(),
);
}
Err(e) => {
tracing::warn!(
"Cron job '{}' — failed to create provider '{}': {e}, using system default",
job.name,
provider_name
);
}
}
}
let permitted_targets: Option<Vec<crate::cron::send_scope::PermittedTarget>> =
job.deliver_to.as_deref().map(|targets| {
targets
.split(',')
.map(str::trim)
.filter(|t| !t.is_empty())
.filter_map(|t| {
if let Some(rest) = t.strip_prefix("telegram:") {
parse_telegram_target(rest).map(|(chat_id, _)| {
crate::cron::send_scope::PermittedTarget {
channel: "telegram",
target_id: chat_id.to_string(),
}
})
} else if let Some(rest) = t.strip_prefix("discord:") {
Some(crate::cron::send_scope::PermittedTarget {
channel: "discord",
target_id: rest.to_string(),
})
} else if let Some(rest) = t.strip_prefix("slack:") {
Some(crate::cron::send_scope::PermittedTarget {
channel: "slack",
target_id: rest.to_string(),
})
} else {
t.strip_prefix("whatsapp:").map(|rest| {
crate::cron::send_scope::PermittedTarget {
channel: "whatsapp",
target_id: rest.to_string(),
}
})
}
})
.collect()
});
let result = crate::cron::send_scope::with_permitted_targets(
permitted_targets,
agent.send_message_with_tools_and_callback(
session_id,
job.prompt.clone(),
effective_model,
None, Some(Arc::new(|_| {
Box::pin(async { Ok((true, false)) })
})),
None, "cron",
None,
None,
),
)
.await;
match result {
Ok(response) => {
let clean = crate::utils::sanitize::strip_llm_artifacts(&response.content);
tracing::info!(
"Cron job '{}' completed — {} tokens, ${:.6}",
job.name,
response.usage.input_tokens + response.usage.output_tokens,
response.cost
);
if let Err(e) = run_repo
.complete_success(
&run_id,
&clean,
response.usage.input_tokens as i64,
response.usage.output_tokens as i64,
response.cost,
)
.await
{
tracing::error!("Failed to save cron run result to DB: {e}");
}
if let Some(ref deliver_to) = job.deliver_to {
for target in deliver_to
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
{
let _ = deliver_result(
target,
&job.name,
&clean,
job.deliver_api_key.as_deref(),
Some(ctx.pool()),
)
.await;
}
}
}
Err(e) => {
tracing::error!("Cron job '{}' agent error: {e}", job.name);
let error_msg = format!("{e}");
if let Err(db_err) = run_repo.complete_error(&run_id, &error_msg).await {
tracing::error!("Failed to save cron run error to DB: {db_err}");
}
if let Some(ref deliver_to) = job.deliver_to {
let msg = format!("Cron job '{}' failed: {e}", job.name);
for target in deliver_to
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
{
let _ = deliver_result(
target,
&job.name,
&msg,
job.deliver_api_key.as_deref(),
Some(ctx.pool()),
)
.await;
}
}
}
}
let message_svc = crate::services::MessageService::new(ctx.clone());
if let Err(e) = message_svc
.create_message(
session_id,
"user".to_string(),
"[CONTEXT COMPACTION — Cron job execution boundary]".to_string(),
)
.await
{
tracing::warn!("Failed to insert cron compaction marker: {e}");
}
Ok(())
}
pub(crate) fn parse_telegram_target(target: &str) -> Option<(i64, Option<i64>)> {
let mut parts = target.split(':');
let chat_id = parts.next()?.trim().parse::<i64>().ok()?;
match parts.next() {
None => Some((chat_id, None)),
Some(thread) => {
let thread_id = thread.trim().parse::<i64>().ok()?;
if parts.next().is_some() {
return None;
}
Some((chat_id, Some(thread_id)))
}
}
}
pub(crate) async fn parse_session_target(target: &str) -> Option<Uuid> {
if let Ok(uuid) = Uuid::parse_str(target) {
return Some(uuid);
}
let config = crate::config::Config::load().ok()?;
let db = crate::db::Database::connect(&config.database.path)
.await
.ok()?;
let sessions = crate::db::repository::SessionRepository::new(db.pool().clone())
.list(crate::db::repository::SessionListOptions::default())
.await
.ok()?;
resolve_session_target(&sessions, target)
}
pub(crate) fn resolve_session_target(
sessions: &[crate::db::models::Session],
target: &str,
) -> Option<Uuid> {
crate::cli::session_resolve::resolve_session_id(sessions, target).ok()
}
async fn deliver_result(
deliver_to: &str,
job_name: &str,
content: &str,
api_key: Option<&str>,
pool: Option<crate::db::Pool>,
) -> Option<tokio::task::JoinHandle<()>> {
#[cfg(not(feature = "telegram"))]
let _ = &pool;
if deliver_to.starts_with("http://") || deliver_to.starts_with("https://") {
deliver_http(deliver_to, job_name, content, api_key).await;
return None;
}
if crate::channels::target_resolver::is_target_url(deliver_to) {
tracing::error!(
"Unbaked target URL '{deliver_to}' reached delivery for job '{job_name}' — oc:// targets must be baked at create/update time (#148); refusing fire-time resolution"
);
return None;
}
let parts: Vec<&str> = deliver_to.splitn(2, ':').collect();
if parts.len() != 2 {
tracing::warn!(
"Invalid deliver_to format '{}' for job '{}' — expected 'channel:id' or HTTP URL",
deliver_to,
job_name
);
return None;
}
let (channel, target_id) = (parts[0], parts[1]);
let max_len = 4000;
let msg = if content.len() > max_len {
format!(
"{}...\n\n(truncated — full output in session)",
&content[..max_len]
)
} else {
content.to_string()
};
let delivery_msg = format!("⏰ **Cron: {job_name}**\n\n{msg}");
match channel {
"session" => {
let Some(session_id) = parse_session_target(target_id).await else {
tracing::error!(
"Invalid session deliver_to target '{target_id}' for job '{job_name}' \
— no session matches; not delivering"
);
return None;
};
tracing::info!("Delivering cron result to session {session_id} (mode turn-end)");
let queued = crate::brain::agent::service::QueuedUserMessage {
context_text: delivery_msg.clone(),
display_text: delivery_msg,
origin: crate::brain::agent::PushOrigin::SessionNotify,
bg_meta: None,
};
let delivery = crate::brain::agent::service::session_routes::deliver_to_session(
session_id, queued, false,
);
tracing::info!("Cron '{job_name}' session delivery verdict: {delivery:?}");
return None;
}
"telegram" => {
#[cfg(feature = "telegram")]
{
match parse_telegram_target(target_id) {
Some((cid, thread_id)) => {
tracing::info!(
"Delivering cron result to Telegram chat {cid}{}",
thread_id
.map(|t| format!(" thread {t}"))
.unwrap_or_default()
);
return deliver_telegram(
cid,
thread_id,
job_name,
&delivery_msg,
pool.clone(),
)
.await;
}
None => {
tracing::error!(
"Invalid Telegram deliver_to target '{target_id}' for job \
'{job_name}' — expected 'telegram:<chat_id>' or \
'telegram:<chat_id>:<thread_id>'; not delivering"
);
}
}
}
#[cfg(not(feature = "telegram"))]
{
tracing::warn!("Telegram feature not enabled — cannot deliver cron result");
}
}
"discord" => {
#[cfg(feature = "discord")]
{
tracing::info!("Delivering cron result to Discord channel {target_id}");
deliver_discord(target_id, &delivery_msg).await;
}
#[cfg(not(feature = "discord"))]
{
tracing::warn!("Discord feature not enabled — cannot deliver cron result");
}
}
"slack" => {
#[cfg(feature = "slack")]
{
tracing::info!("Delivering cron result to Slack channel {target_id}");
deliver_slack(target_id, &delivery_msg).await;
}
#[cfg(not(feature = "slack"))]
{
tracing::warn!("Slack feature not enabled — cannot deliver cron result");
}
}
other => {
tracing::warn!("Unknown delivery channel '{other}' for job '{job_name}'");
}
}
None
}
async fn deliver_http(url: &str, job_name: &str, content: &str, api_key: Option<&str>) {
let client = reqwest::Client::new();
let body = serde_json::json!({
"job_name": job_name,
"content": content,
"timestamp": chrono::Utc::now().to_rfc3339(),
});
let mut request = client.post(url).json(&body);
if let Some(key) = api_key {
request = request.header("Authorization", format!("Bearer {key}"));
}
match request.send().await {
Ok(resp) if resp.status().is_success() => {
tracing::info!("Cron result for '{job_name}' delivered to {url}");
}
Ok(resp) => {
tracing::warn!(
"HTTP delivery to {url} failed ({}): {:?}",
resp.status(),
resp.text().await.unwrap_or_default()
);
}
Err(e) => {
tracing::error!("HTTP delivery to {url} error: {e}");
}
}
}
#[cfg(any(feature = "telegram", feature = "discord", feature = "slack"))]
fn read_channel_secret(channel: &str, field: &str) -> Option<String> {
let keys_path = crate::brain::BrainLoader::resolve_path().join("keys.toml");
let content = std::fs::read_to_string(&keys_path).ok()?;
content.parse::<toml::Table>().ok().and_then(|t| {
t.get("channels")?
.as_table()?
.get(channel)?
.as_table()?
.get(field)?
.as_str()
.map(String::from)
})
}
#[cfg(any(feature = "discord", feature = "slack"))]
fn split_for_delivery(text: &str, max_len: usize) -> Vec<&str> {
if text.len() <= max_len {
return vec![text];
}
let mut chunks = Vec::new();
let mut start = 0;
while start < text.len() {
let mut end = (start + max_len).min(text.len());
while end < text.len() && !text.is_char_boundary(end) {
end -= 1;
}
let break_at = if end < text.len() {
text[start..end]
.rfind('\n')
.filter(|&pos| pos > end - start - 200)
.map(|pos| start + pos + 1)
.unwrap_or(end)
} else {
end
};
chunks.push(&text[start..break_at]);
start = break_at;
}
chunks
}
#[cfg(feature = "telegram")]
async fn deliver_telegram(
chat_id: i64,
thread_id: Option<i64>,
job_name: &str,
message: &str,
pool: Option<crate::db::Pool>,
) -> Option<tokio::task::JoinHandle<()>> {
let Some(token) = read_channel_secret("telegram", "token") else {
tracing::warn!("No Telegram bot token found in keys.toml — cannot deliver cron result");
return None;
};
let bot = teloxide::Bot::new(token);
use teloxide::prelude::Requester;
let thread = thread_id.map(|t| teloxide::types::ThreadId(teloxide::types::MessageId(t as i32)));
if let Some(tid) = thread_id {
match bot.get_chat(teloxide::types::ChatId(chat_id)).await {
Ok(chat) if is_forum_chat(&chat) => {}
Ok(_) => {
tracing::error!(
"Cron job '{job_name}': deliver_to thread {tid} rejected — chat {chat_id} \
is not a forum (topics disabled); refusing delivery instead of falling \
back to the default topic"
);
return None;
}
Err(e) => {
tracing::error!(
"Cron job '{job_name}': cannot validate chat {chat_id} for thread \
{tid} delivery: {e} — refusing delivery"
);
return None;
}
}
}
let message = message.to_string();
let job_name = job_name.to_string();
Some(tokio::spawn(async move {
match crate::channels::telegram::send::send_markdown_outbox(
&bot,
teloxide::types::ChatId(chat_id),
thread,
&message,
"cron",
&job_name,
None,
)
.await
{
Ok(sent) => {
tracing::info!(
"Cron result for '{job_name}' delivered to Telegram chat {chat_id}{} ({} part(s))",
thread_id
.map(|t| format!(" thread {t}"))
.unwrap_or_default(),
sent.len()
);
crate::channels::telegram::send::record_outgoing(pool, chat_id, thread, &sent)
.await;
}
Err(e) => {
if let Some(t) = thread_id {
tracing::error!(
"Cron delivery for '{job_name}' to chat {chat_id} thread {t} failed: {e} — \
if the error is 'message thread not found', topic {t} does not exist \
in chat {chat_id}; fix the job's deliver_to (there is no fallback to the \
default topic)"
);
} else {
tracing::error!("Cron delivery for '{job_name}' to chat {chat_id} failed: {e}");
}
}
}
}))
}
#[cfg(feature = "telegram")]
fn is_forum_chat(chat: &teloxide::types::ChatFullInfo) -> bool {
matches!(
&chat.kind,
teloxide::types::ChatFullInfoKind::Public(public)
if matches!(
&public.kind,
teloxide::types::ChatFullInfoPublicKind::Supergroup(supergroup)
if supergroup.is_forum
)
)
}
#[cfg(feature = "discord")]
async fn deliver_discord(channel_id: &str, message: &str) {
let Some(token) = read_channel_secret("discord", "token") else {
tracing::warn!("No Discord bot token found in keys.toml — cannot deliver cron result");
return;
};
let url = format!("https://discord.com/api/v10/channels/{channel_id}/messages");
let client = reqwest::Client::new();
let mut delivered = 0usize;
for chunk in split_for_delivery(message, 2000) {
let body = serde_json::json!({ "content": chunk });
match client
.post(&url)
.header("Authorization", format!("Bot {token}"))
.json(&body)
.send()
.await
{
Ok(resp) if resp.status().is_success() => delivered += 1,
Ok(resp) => {
tracing::warn!(
"Discord delivery to {channel_id} failed ({}): {:?}",
resp.status(),
resp.text().await.unwrap_or_default()
);
}
Err(e) => {
tracing::error!("Discord delivery to {channel_id} HTTP error: {e}");
}
}
}
if delivered > 0 {
tracing::info!(
"Cron result delivered to Discord channel {channel_id} ({delivered} part(s))"
);
}
}
#[cfg(feature = "slack")]
async fn deliver_slack(channel_id: &str, message: &str) {
let Some(token) = read_channel_secret("slack", "token") else {
tracing::warn!("No Slack bot token found in keys.toml — cannot deliver cron result");
return;
};
let url = "https://slack.com/api/chat.postMessage";
let client = reqwest::Client::new();
let mut delivered = 0usize;
for chunk in split_for_delivery(message, 3500) {
let body = serde_json::json!({ "channel": channel_id, "text": chunk });
match client
.post(url)
.header("Authorization", format!("Bearer {token}"))
.json(&body)
.send()
.await
{
Ok(resp) => {
let parsed: serde_json::Value = resp.json().await.unwrap_or_default();
if parsed.get("ok").and_then(serde_json::Value::as_bool) == Some(true) {
delivered += 1;
} else {
tracing::warn!(
"Slack delivery to {channel_id} failed: {}",
parsed
.get("error")
.and_then(|e| e.as_str())
.unwrap_or("unknown error")
);
}
}
Err(e) => {
tracing::error!("Slack delivery to {channel_id} HTTP error: {e}");
}
}
}
if delivered > 0 {
tracing::info!("Cron result delivered to Slack channel {channel_id} ({delivered} part(s))");
}
}