Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 87 additions & 8 deletions crates/openab-core/src/cron.rs
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,18 @@ pub fn should_fire(schedule: &Schedule, tz: Tz) -> bool {
}

/// Known platforms that have adapter support.
const VALID_PLATFORMS: &[&str] = &["discord", "slack", "telegram"];
const VALID_PLATFORMS: &[&str] = &["discord", "slack", "telegram", "googlechat"];

fn should_create_cron_thread(job: &CronJobConfig) -> bool {
job.thread_id.is_none() && job.platform != "googlechat"
}

fn cron_sender_thread_id(channel: &ChannelRef) -> Option<String> {
channel
.thread_id
.clone()
.or_else(|| channel.parent_id.as_ref().map(|_| channel.channel_id.clone()))
}

/// Validate all cronjob configs (fail-fast on bad cron expressions or timezones).
pub fn validate_cronjobs(
Expand Down Expand Up @@ -660,9 +671,7 @@ async fn fire_cronjob(
}
};

let reply_channel = if job.thread_id.is_some() {
thread_channel.clone()
} else {
let reply_channel = if should_create_cron_thread(job) {
let thread_name = format::shorten_thread_name(&job.message);
match adapter
.create_thread(&thread_channel, &trigger_msg, &thread_name)
Expand Down Expand Up @@ -692,6 +701,8 @@ async fn fire_cronjob(
return;
}
}
} else {
thread_channel.clone()
};

let sender = SenderContext {
Expand All @@ -705,10 +716,7 @@ async fn fire_cronjob(
.as_deref()
.unwrap_or(&reply_channel.channel_id)
.to_string(),
thread_id: reply_channel
.thread_id
.clone()
.or(Some(reply_channel.channel_id.clone())),
thread_id: cron_sender_thread_id(&reply_channel),
is_bot: true,
timestamp: Some(Utc::now().to_rfc3339()),
message_id: None, // cron jobs don't originate from a message
Expand Down Expand Up @@ -1546,6 +1554,66 @@ message = "a"
}
}

#[test]
fn googlechat_cron_stays_top_level_without_explicit_thread() {
let mut job = test_cron_job();
job.platform = "googlechat".into();

assert!(!should_create_cron_thread(&job));

job.thread_id = Some("spaces/TEST/threads/THREAD".into());
assert!(!should_create_cron_thread(&job));
}

#[test]
fn thread_capable_cron_platform_creates_thread_when_unspecified() {
let job = test_cron_job();

assert!(should_create_cron_thread(&job));
}

#[test]
fn googlechat_top_level_sender_has_no_thread_id() {
let channel = ChannelRef {
platform: "googlechat".into(),
channel_id: "spaces/TEST".into(),
thread_id: None,
parent_id: None,
origin_event_id: None,
};

assert_eq!(cron_sender_thread_id(&channel), None);
}

#[test]
fn cron_sender_preserves_explicit_thread_id() {
let channel = ChannelRef {
platform: "googlechat".into(),
channel_id: "spaces/TEST".into(),
thread_id: Some("spaces/TEST/threads/THREAD".into()),
parent_id: None,
origin_event_id: None,
};

assert_eq!(
cron_sender_thread_id(&channel).as_deref(),
Some("spaces/TEST/threads/THREAD")
);
}

#[test]
fn cron_sender_uses_child_channel_id_for_thread_platforms() {
let channel = ChannelRef {
platform: "discord".into(),
channel_id: "thread-456".into(),
thread_id: None,
parent_id: Some("channel-123".into()),
origin_event_id: None,
};

assert_eq!(cron_sender_thread_id(&channel).as_deref(), Some("thread-456"));
}

// --- validate_cronjobs tests ---

#[test]
Expand All @@ -1568,6 +1636,17 @@ message = "a"
assert!(validate_cronjobs(&jobs, &["discord"]).is_ok());
}

#[test]
fn validate_cronjobs_googlechat_passes_when_configured() {
let mut job = test_cron_job();
job.platform = "googlechat".into();
job.channel = "spaces/TEST".into();
job.disable_on_success = None;
job.disable_on_success_match = None;

assert!(validate_cronjobs(&[job], &["googlechat"]).is_ok());
}

#[test]
fn validate_cronjobs_invalid_cron_fails() {
let jobs = vec![CronJobConfig {
Expand Down
15 changes: 13 additions & 2 deletions docs/cronjob.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,9 @@ thread_id = "" # optional: post to existing thread
|-------|----------|---------|-------------|
| `enabled` | | `true` | Set `false` to disable without removing the entry |
| `schedule` | ✅ | — | 5-field POSIX cron expression |
| `channel` | ✅ | — | Discord channel/thread ID, Slack channel ID, or Telegram chat ID |
| `channel` | ✅ | — | Discord channel/thread ID, Slack channel ID, Telegram chat ID, or Google Chat space name |
| `message` | ✅ | — | Message sent to the agent as a prompt |
| `platform` | | `"discord"` | `"discord"`, `"slack"`, or `"telegram"` (requires `telegram` feature) |
| `platform` | | `"discord"` | `"discord"`, `"slack"`, `"telegram"`, or `"googlechat"` (non-default platforms require their feature) |
| `sender_name` | | `"openab-cron"` | Attribution shown in prompt context |
| `timezone` | | `"UTC"` | IANA timezone (e.g. `"America/New_York"`, `"Europe/Berlin"`) |
| `thread_id` | | — | Post into an existing thread instead of the channel |
Expand Down Expand Up @@ -120,6 +120,14 @@ channel = "176096071"
message = "講一個冷笑話"
platform = "telegram"
sender_name = "JokeBot"

[[cron.jobs]]
schedule = "0 9 * * 1-5"
channel = "spaces/AAAA1234567"
message = "summarize the new support escalations"
platform = "googlechat"
sender_name = "SupportDigest"
timezone = "Asia/Taipei"
```

## Helm Deployment
Expand Down Expand Up @@ -338,9 +346,12 @@ Use `sender_name` to distinguish different scheduled tasks in logs and thread ti
| `discord` | (always enabled) | `[discord]` section in config.toml |
| `slack` | `--features slack` | `[slack]` section in config.toml |
| `telegram` | `--features telegram` | `[telegram]` section in config.toml **or** `TELEGRAM_BOT_TOKEN` env var |
| `googlechat` | `--features googlechat` | `[googlechat] enabled = true` in config.toml **or** `GOOGLE_CHAT_ENABLED=true` env var, plus credentials (`sa_key_json`/`sa_key_file`/`access_token` fields or their `GOOGLE_CHAT_*` env equivalents) |

> **Note:** The `channel` field for Telegram should be the numeric chat ID (e.g. `"176096071"`). Use [@userinfobot](https://t.me/userinfobot) or the Telegram Bot API `getUpdates` to find your chat ID.

For Google Chat, use the space resource name (for example, `"spaces/AAAA1234567"`). Jobs without `thread_id` stay at the top level of the space because Google Chat does not implement OpenAB's `create_topic` command. To post into an existing thread, set `thread_id` to its full Google Chat thread resource name.

## When to Use External Schedulers Instead

Config-driven cron covers the 80% use case: "send this message at this time." For advanced needs, use external schedulers:
Expand Down
6 changes: 3 additions & 3 deletions docs/platforms/schema/googlechat.toml
Original file line number Diff line number Diff line change
Expand Up @@ -229,9 +229,9 @@ pr = ""

[[openab_features]]
feature = "cron_dispatch"
status = "not_implemented"
note = "Not wired for scheduled cron dispatch: `VALID_PLATFORMS` (cron.rs) covers only discord/slack/telegram and no adapter is registered for this platform in `cron_adapters`, so `cronjob.toml` jobs targeting it are rejected at startup by `validate_cronjobs`."
source = []
status = "implemented"
note = "`cronjob.toml` jobs with `platform = \"googlechat\"` fire through the unified adapter when the `googlechat` feature is compiled and the platform is enabled (`[googlechat] enabled = true` or `GOOGLE_CHAT_ENABLED` env — config-first, per the first-class-platform-config ADR). Jobs without an explicit `thread_id` remain at the top level of the configured space because Google Chat does not implement the gateway `create_topic` command."
source = ["crates/openab-core/src/cron.rs#VALID_PLATFORMS", "crates/openab-core/src/cron.rs#fire_cronjob", "src/main.rs#cron_adapters"]
pr = ""


Expand Down
14 changes: 14 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,16 @@ async fn main() -> anyhow::Result<()> {
if cfg.telegram.is_some() || std::env::var("TELEGRAM_BOT_TOKEN").is_ok() {
configured_platforms.push("telegram");
}
#[cfg(feature = "googlechat")]
if cfg
.googlechat
.clone()
.unwrap_or_default()
.resolve()
.enabled
{
configured_platforms.push("googlechat");
}
cron::validate_cronjobs(&cfg.cron.jobs, &configured_platforms)?;

// Spawn Slack adapter (background task)
Expand Down Expand Up @@ -1180,6 +1190,10 @@ async fn main() -> anyhow::Result<()> {
if let Some(ref a) = shared_unified_adapter {
cron_adapters.insert("telegram".into(), a.clone());
}
#[cfg(feature = "googlechat")]
if let Some(ref a) = shared_unified_adapter {
cron_adapters.insert("googlechat".into(), a.clone());
}
let cron_platforms: Vec<String> =
configured_platforms.iter().map(|s| s.to_string()).collect();
info!(baseline = cronjobs.len(), usercron = ?usercron_path, "starting cron scheduler");
Expand Down
Loading