diff --git a/crates/astra-gateway/docs/wecom-webhook-cron.md b/crates/astra-gateway/docs/wecom-webhook-cron.md new file mode 100644 index 0000000..222cdb6 --- /dev/null +++ b/crates/astra-gateway/docs/wecom-webhook-cron.md @@ -0,0 +1,93 @@ +# WeCom Webhook for Cron Scheduled Messages + +## Background + +WeCom AI Bot (`aibot_respond_msg`) can only **respond** to messages in group chats — it requires a `reply_token` from an inbound @mention. Cron-triggered tasks have no `reply_token`, so their results cannot be delivered to groups via the AI Bot respond path. + +By configuring `webhook_url`, cron task results are delivered to the group via a WeCom Webhook Bot instead. + +## Setup + +### 1. Create a Webhook Bot in Your WeCom Group + +1. Open the target group chat → Group Settings (top-right corner) +2. Group Bots → Add Bot +3. Enter a name (e.g. "Nightly Report") +4. Copy the Webhook URL after creation + +### 2. Add webhook_url to gateway.yaml + +```yaml +platforms: + wecom: + enabled: true + bot_id: "your-bot-id" + secret: "your-secret" + webhook_url: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY" +``` + +`webhook_url` is optional. When not configured, cron results fall back to `aibot_send_msg`. + +### 3. Create a Cron Job + +@mention the AI Bot in the group to create a scheduled task: + +> @BisectBot add a cron job: every day at 9am, analyze nightly regression results and generate a report + +The gateway will parse this and create a cron job. When triggered, the result is posted to the group via the webhook bot. + +## How It Works + +``` +Cron timer fires + → CronScheduler invokes Claude CLI with the task message + → Gets analysis result + → Detects platform == "wecom" && webhook_url is configured + → HTTP POST to webhook URL (markdown format) + → Message appears in group (as the webhook bot identity) +``` + +## Message Format + +Messages are sent as WeCom Webhook markdown: + +```json +{ + "msgtype": "markdown", + "markdown": { + "content": "⏰ **Scheduled task `abc12345`**\n\n" + } +} +``` + +Messages exceeding 4000 characters are automatically truncated. + +## Mentioning Users + +Use `<@userid>` syntax in the message content to @mention specific users: + +```json +{ + "msgtype": "markdown", + "markdown": { + "content": "<@WeiLu><@SunYuZe> Nightly regression report:\n\n..." + } +} +``` + +## Comparison: AI Bot vs Webhook Bot + +| Capability | AI Bot (respond) | AI Bot (send) | Webhook Bot | +|---|---|---|---| +| Group reply (reactive) | Yes (needs reply_token) | - | - | +| Group message (proactive) | No | Yes, but cannot @mention | Yes, supports @mention | +| DM (proactive) | - | Yes | No | +| Cron result to group | No | Possible | Recommended | + +Both can coexist in the same group — AI Bot handles interactive conversations, Webhook Bot handles scheduled push notifications. + +## Fallback Behavior + +- **webhook_url configured**: cron results go through webhook (reliable, supports @mention) +- **webhook_url not configured**: cron results attempt `aibot_send_msg` (works but cannot @mention users) +- **Non-wecom platforms**: always use the normal outbound delivery path diff --git a/crates/astra-gateway/src/commands.rs b/crates/astra-gateway/src/commands.rs index 0516a9f..922e57c 100644 --- a/crates/astra-gateway/src/commands.rs +++ b/crates/astra-gateway/src/commands.rs @@ -404,10 +404,7 @@ pub async fn handle_command(ctx: &CommandContext<'_>, text: &str) -> Option HarnessSnapshot { @@ -2473,10 +2469,7 @@ mod tests { ); // Numeric index: 1=默认, 2=Sonnet, 3=Haiku, 4=Opus 4.7, 5=Opus 4.6 assert!(matches!(resolve_model_input("1"), ResolvedModel::Default)); - assert_eq!( - id(resolve_model_input("4")), - "us.anthropic.claude-opus-4-7" - ); + assert_eq!(id(resolve_model_input("4")), "us.anthropic.claude-opus-4-7"); // Default assert!(matches!( resolve_model_input("默认"), @@ -2516,7 +2509,9 @@ mod tests { ); // Extended whitelist: older Bedrock ids not in the menu still accepted assert_eq!( - id(resolve_model_input("us.anthropic.claude-opus-4-5-20251101-v1:0")), + id(resolve_model_input( + "us.anthropic.claude-opus-4-5-20251101-v1:0" + )), "us.anthropic.claude-opus-4-5-20251101-v1:0" ); // Anything else rejected (no passthrough) diff --git a/crates/astra-gateway/src/config.rs b/crates/astra-gateway/src/config.rs index 0476fe6..35ea822 100644 --- a/crates/astra-gateway/src/config.rs +++ b/crates/astra-gateway/src/config.rs @@ -143,6 +143,11 @@ pub struct WeComConfig { pub secret: String, #[serde(default = "default_wecom_ws_url")] pub websocket_url: String, + /// Webhook URL for proactive group messages (cron results, alerts). + /// WeCom AI Bot cannot send proactive messages to groups — only respond. + /// A separate webhook bot in the same group enables scheduled reports. + #[serde(default)] + pub webhook_url: Option, } impl std::fmt::Debug for WeComConfig { diff --git a/crates/astra-gateway/src/scheduler.rs b/crates/astra-gateway/src/scheduler.rs index 27676c3..07151a8 100644 --- a/crates/astra-gateway/src/scheduler.rs +++ b/crates/astra-gateway/src/scheduler.rs @@ -12,6 +12,7 @@ use std::time::Duration; use tokio::sync::mpsc; const POLL_INTERVAL: Duration = Duration::from_secs(60); +const WEBHOOK_MAX_LEN: usize = 4000; #[cfg(test)] const CLI_TIMEOUT: Duration = Duration::from_secs(300); @@ -168,7 +169,20 @@ impl CronScheduler { let prefix = format!("⏰ **定时任务 `{}`**\n\n", &job_id[..8.min(job_id.len())]); let body = format!("{prefix}{response}"); - if let Some(writer) = trace.as_ref() { + + // WeCom groups cannot receive proactive messages via AI Bot — use webhook if configured. + if platform == "wecom" + && let Some(ref webhook_url) = self + .config + .platforms + .wecom + .as_ref() + .and_then(|w| w.webhook_url.clone()) + { + if let Err(e) = send_webhook(webhook_url, &body).await { + tracing::warn!(error = %e, "scheduler: webhook send failed"); + } + } else if let Some(writer) = trace.as_ref() { match writer.enqueue_outbox(platform, chat_id, None, &body).await { Ok(outbox_id) => { if let Err(e) = self @@ -306,6 +320,33 @@ impl CronScheduler { } } +/// Send a message to a WeCom group via webhook bot. +async fn send_webhook(url: &str, text: &str) -> Result<(), String> { + let truncated = if text.len() > WEBHOOK_MAX_LEN { + format!("{}…\n\n_(truncated)_", &text[..WEBHOOK_MAX_LEN]) + } else { + text.to_string() + }; + let payload = serde_json::json!({ + "msgtype": "markdown", + "markdown": { "content": truncated } + }); + let client = reqwest::Client::new(); + let resp = client + .post(url) + .json(&payload) + .send() + .await + .map_err(|e| e.to_string())?; + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(format!("webhook returned {status}: {body}")); + } + tracing::info!("cron result sent via webhook"); + Ok(()) +} + #[cfg(test)] mod tests { use super::*;