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
93 changes: 93 additions & 0 deletions crates/astra-gateway/docs/wecom-webhook-cron.md
Original file line number Diff line number Diff line change
@@ -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<analysis result>"
}
}
```

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
15 changes: 5 additions & 10 deletions crates/astra-gateway/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -404,10 +404,7 @@ pub async fn handle_command(ctx: &CommandContext<'_>, text: &str) -> Option<Stri
let current_display = current_model
.map(|m| display_model_name(m, &entries))
.unwrap_or_else(|| "默认".to_string());
let mut lines = vec![
format!("🤖 当前: **{current_display}**"),
String::new(),
];
let mut lines = vec![format!("🤖 当前: **{current_display}**"), String::new()];
for (i, entry) in entries.iter().enumerate() {
let mark = if entry.matches_current(current_model) {
" ✓"
Expand Down Expand Up @@ -2184,7 +2181,6 @@ mod tests {
assert_eq!(format_duration(125_000), "2m 5s");
}


// ── Harness snapshot tests ──────────────────────────────────

fn test_snapshot() -> HarnessSnapshot {
Expand Down Expand Up @@ -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("默认"),
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions crates/astra-gateway/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
}

impl std::fmt::Debug for WeComConfig {
Expand Down
43 changes: 42 additions & 1 deletion crates/astra-gateway/src/scheduler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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::*;
Expand Down
Loading