diff --git a/crates/openab-core/src/cron.rs b/crates/openab-core/src/cron.rs index f507f9bfc..2d3841460 100644 --- a/crates/openab-core/src/cron.rs +++ b/crates/openab-core/src/cron.rs @@ -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 { + 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( @@ -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) @@ -692,6 +701,8 @@ async fn fire_cronjob( return; } } + } else { + thread_channel.clone() }; let sender = SenderContext { @@ -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 @@ -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] @@ -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 { diff --git a/docs/cronjob.md b/docs/cronjob.md index 8ff7260bd..7623cefea 100644 --- a/docs/cronjob.md +++ b/docs/cronjob.md @@ -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 | @@ -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 @@ -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: diff --git a/docs/platforms/schema/googlechat.toml b/docs/platforms/schema/googlechat.toml index 4bd02224e..375e62663 100644 --- a/docs/platforms/schema/googlechat.toml +++ b/docs/platforms/schema/googlechat.toml @@ -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 = "" diff --git a/src/main.rs b/src/main.rs index 2aabd9f0b..4d9377e20 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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) @@ -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 = configured_platforms.iter().map(|s| s.to_string()).collect(); info!(baseline = cronjobs.len(), usercron = ?usercron_path, "starting cron scheduler");