diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0471ef..817e3f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: with: components: clippy - uses: Swatinem/rust-cache@v2 - - run: cargo clippy --all-targets --all-features -- -D warnings + - run: cargo clippy --all-targets -- -D warnings test: name: Test diff --git a/.gitignore b/.gitignore index 636e0e1..de37185 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,5 @@ Thumbs.db # 如果你的 test-webhook.sh 里包含了真实的 URL 或 Secret, # 请取消下面这行的注释(去掉 # 号) # ------------------------------------------------------ -# test-webhook.sh \ No newline at end of file +# test-webhook.sh +payload.json \ No newline at end of file diff --git a/src/config.rs b/src/config.rs index ffe7112..dbf0a57 100644 --- a/src/config.rs +++ b/src/config.rs @@ -51,11 +51,21 @@ impl LinearConfig { #[derive(Debug, Default, Deserialize, Serialize)] pub struct LarkConfig { + /// Incoming webhook URL for Linear group chat notifications. #[serde(default)] pub webhook_url: String, - pub target_chat_id: Option, + /// Incoming webhook URL for GitHub group chat notifications. + /// Falls back to `webhook_url` when empty. + #[serde(default)] + pub github_webhook_url: String, + /// Enterprise self-built app credentials — used only for Linear DMs. pub app_id: Option, pub app_secret: Option, + /// Enterprise self-built app credentials — used only for GitHub review-request DMs. + /// Falls back to `app_id`/`app_secret` when absent. + pub github_app_id: Option, + pub github_app_secret: Option, + /// Verification token for the Lark URL-unfurling event-subscription app. pub verification_token: Option, } @@ -75,12 +85,20 @@ impl LarkConfig { pub fn from_worker_env(env: &worker::Env) -> Result { Ok(Self { webhook_url: env - .var("LARK_WEBHOOK_URL") - .map(|v| v.to_string()) + .secret("LARK_WEBHOOK_URL") + .map(|s| s.to_string()) + .unwrap_or_default(), + github_webhook_url: env + .secret("LARK_GITHUB_WEBHOOK_URL") + .map(|s| s.to_string()) .unwrap_or_default(), - target_chat_id: env.var("LARK_TARGET_CHAT_ID").ok().map(|v| v.to_string()), app_id: env.var("LARK_APP_ID").ok().map(|v| v.to_string()), app_secret: env.secret("LARK_APP_SECRET").ok().map(|s| s.to_string()), + github_app_id: env.var("LARK_GITHUB_APP_ID").ok().map(|v| v.to_string()), + github_app_secret: env + .secret("LARK_GITHUB_APP_SECRET") + .ok() + .map(|s| s.to_string()), verification_token: env .secret("LARK_VERIFICATION_TOKEN") .ok() @@ -90,16 +108,26 @@ impl LarkConfig { } impl LarkConfig { - pub fn bot_client(&self, http: &Client) -> Option { + /// Creates the Linear DM bot client (enterprise self-built app, DMs only). + pub fn linear_dm_bot(&self, http: &Client) -> Option { match (&self.app_id, &self.app_secret) { (Some(id), Some(secret)) => { - info!("lark bot configured – Bot API notifications enabled"); + info!("LARK_APP_ID set – Linear DM bot enabled"); Some(LarkBotClient::new(id.clone(), secret.clone(), http.clone())) } - _ => { - info!("LARK_APP_ID/LARK_APP_SECRET not set – Bot API notifications disabled"); - None + _ => None, + } + } + + /// Creates the GitHub DM bot client (enterprise self-built app, DMs only). + /// Falls back to the Linear DM bot when `LARK_GITHUB_APP_ID` is absent. + pub fn github_dm_bot(&self, http: &Client) -> Option { + match (&self.github_app_id, &self.github_app_secret) { + (Some(id), Some(secret)) => { + info!("LARK_GITHUB_APP_ID set – GitHub DM bot enabled"); + Some(LarkBotClient::new(id.clone(), secret.clone(), http.clone())) } + _ => None, } } } @@ -261,7 +289,11 @@ pub struct AppState { pub server: ServerConfig, pub github: Option, pub http: Client, + /// Bot client for the Linear notification app. pub lark_bot: Option, + /// Bot client for the GitHub notification app. + /// Falls back to `lark_bot` when `None`. + pub github_lark_bot: Option, pub linear_client: Option, #[cfg(not(feature = "cf-worker"))] pub update_debounce: DebounceMap, @@ -278,15 +310,13 @@ impl AppState { let github = GitHubConfig::from_env(); let http = Client::new(); - let lark_bot = lark.bot_client(&http); + let lark_bot = lark.linear_dm_bot(&http); + let github_lark_bot = lark.github_dm_bot(&http); let linear_client = linear.graphql_client(&http); if lark.verification_token.is_some() { info!("LARK_VERIFICATION_TOKEN set – event verification enabled"); } - if lark.target_chat_id.is_some() { - info!("LARK_TARGET_CHAT_ID set – Bot API group chat enabled"); - } if let Some(gh) = &github { info!("GITHUB_WEBHOOK_SECRET set – GitHub webhook source enabled"); if !gh.repo_whitelist.is_empty() { @@ -305,6 +335,7 @@ impl AppState { github, http, lark_bot, + github_lark_bot, linear_client, update_debounce: DebounceMap::new(), } @@ -320,7 +351,8 @@ impl AppState { let github = GitHubConfig::from_worker_env(&env); let http = Client::new(); - let lark_bot = lark.bot_client(&http); + let lark_bot = lark.linear_dm_bot(&http); + let github_lark_bot = lark.github_dm_bot(&http); let linear_client = linear.graphql_client(&http); Self { @@ -330,6 +362,7 @@ impl AppState { github, http, lark_bot, + github_lark_bot, linear_client, env, } diff --git a/src/debounce_do.rs b/src/debounce_do.rs index b648b94..8776452 100644 --- a/src/debounce_do.rs +++ b/src/debounce_do.rs @@ -85,50 +85,31 @@ impl DurableObject for DebounceObject { let http = reqwest::Client::new(); - // Build the card once, then deliver via Bot API or webhook fallback. + // Deliver to the Linear group chat via incoming webhook. let card = crate::sinks::lark::cards::build_lark_card(&event); - - let app_id = self.env.var("LARK_APP_ID").ok().map(|v| v.to_string()); - let app_secret = self - .env - .secret("LARK_APP_SECRET") - .ok() - .map(|s| s.to_string()); - let target_chat_id = self + let webhook_url = self .env - .var("LARK_TARGET_CHAT_ID") - .ok() - .map(|v| v.to_string()); - - let bot = match (app_id, app_secret) { - (Some(id), Some(secret)) => Some(crate::sinks::lark::LarkBotClient::new( - id, - secret, - http.clone(), - )), - _ => None, - }; - - match (&bot, &target_chat_id) { - (Some(b), Some(chat_id)) => { - if let Err(e) = b.send_to_chat(chat_id, &card.card).await { - worker::console_error!("failed to send card to chat: {e}"); - } - } - _ => { - let webhook_url = self - .env - .var("LARK_WEBHOOK_URL") - .map(|v| v.to_string()) - .unwrap_or_default(); - if !webhook_url.is_empty() { - crate::sinks::lark::webhook::send_lark_card(&http, &webhook_url, &card).await; - } - } + .var("LARK_WEBHOOK_URL") + .map(|v| v.to_string()) + .unwrap_or_default(); + if !webhook_url.is_empty() { + crate::sinks::lark::webhook::send_lark_card(&http, &webhook_url, &card).await; + } else { + worker::console_error!("LARK_WEBHOOK_URL not configured — group notification skipped"); } - if let (Some(email), Some(b)) = (&dm_email, &bot) { - crate::sinks::lark::try_dm(&event, b, email).await; + // Send DM via the Linear DM bot (enterprise self-built app). + if let Some(email) = &dm_email { + let app_id = self.env.var("LARK_APP_ID").ok().map(|v| v.to_string()); + let app_secret = self + .env + .secret("LARK_APP_SECRET") + .ok() + .map(|s| s.to_string()); + if let (Some(id), Some(secret)) = (app_id, app_secret) { + let bot = crate::sinks::lark::LarkBotClient::new(id, secret, http.clone()); + crate::sinks::lark::try_dm(&event, &bot, email).await; + } } Response::ok("dispatched") diff --git a/src/dispatch.rs b/src/dispatch.rs index 65af8c4..48dcbb1 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -2,8 +2,8 @@ use crate::{config::AppState, event::Event, sinks}; -/// Sends `event` to all sinks. If `dm_email` is provided, a direct message -/// is also sent to that address. +/// Sends `event` to the Linear Lark group. If `dm_email` is provided, a +/// direct message is also sent using the Linear bot credentials. pub async fn dispatch(event: &Event, state: &AppState, dm_email: Option<&str>) { sinks::lark::notify(event, state).await; @@ -11,3 +11,15 @@ pub async fn dispatch(event: &Event, state: &AppState, dm_email: Option<&str>) { sinks::lark::try_dm(event, bot, email).await; } } + +/// Sends `event` to the GitHub Lark group. If `dm_email` is provided, a +/// direct message is sent using the GitHub bot credentials (falling back to +/// the Linear bot when no GitHub-specific bot is configured). +pub async fn dispatch_github(event: &Event, state: &AppState, dm_email: Option<&str>) { + sinks::lark::notify_github(event, state).await; + + let bot = state.github_lark_bot.as_ref().or(state.lark_bot.as_ref()); + if let (Some(email), Some(bot)) = (dm_email, bot) { + sinks::lark::try_dm(event, bot, email).await; + } +} diff --git a/src/sinks/lark/mod.rs b/src/sinks/lark/mod.rs index b8450ac..3b7994f 100644 --- a/src/sinks/lark/mod.rs +++ b/src/sinks/lark/mod.rs @@ -13,32 +13,35 @@ use tracing::error; use crate::{config::AppState, event::Event}; -/// Sends a card notification for `event` to the Lark group. -/// -/// Prefers Bot API (`target_chat_id`) when available, falls back to the -/// simple webhook (`webhook_url`). +/// Sends a card notification for `event` to the Linear group chat via webhook. pub async fn notify(event: &Event, state: &AppState) { let card = cards::build_lark_card(event); + if !state.lark.webhook_url.is_empty() { + webhook::send_lark_card(&state.http, &state.lark.webhook_url, &card).await; + } else { + error!("LARK_WEBHOOK_URL not configured — Linear group chat notification skipped"); + } +} - match (&state.lark_bot, &state.lark.target_chat_id) { - (Some(bot), Some(chat_id)) => { - if let Err(e) = bot.send_to_chat(chat_id, &card.card).await { - error!("failed to send card to chat {chat_id}: {e}"); - } - } - _ if !state.lark.webhook_url.is_empty() => { - webhook::send_lark_card(&state.http, &state.lark.webhook_url, &card).await; - } - _ => { - error!( - "no Lark delivery method configured (need LARK_TARGET_CHAT_ID + bot, or LARK_WEBHOOK_URL)" - ); - } +/// Sends a card notification for `event` to the GitHub group chat via webhook. +/// +/// Uses `LARK_GITHUB_WEBHOOK_URL` when set, falls back to `LARK_WEBHOOK_URL`. +pub async fn notify_github(event: &Event, state: &AppState) { + let card = cards::build_lark_card(event); + let webhook = if !state.lark.github_webhook_url.is_empty() { + &state.lark.github_webhook_url + } else { + &state.lark.webhook_url + }; + if !webhook.is_empty() { + webhook::send_lark_card(&state.http, webhook, &card).await; + } else { + error!("no webhook URL configured — GitHub group chat notification skipped"); } } -/// DMs the assignee about `event` (no-op when `bot` is `None` or event -/// does not support DM notifications). +/// Sends a DM about `event` via the enterprise self-built app bot. +/// No-op when the event does not support DM notifications. pub async fn try_dm(event: &Event, bot: &LarkBotClient, email: &str) { if let Some(card) = cards::build_assign_dm_card(event) && let Err(e) = bot.send_dm(email, &card).await diff --git a/src/sources/github/handler.rs b/src/sources/github/handler.rs index c57cdee..45b088c 100644 --- a/src/sources/github/handler.rs +++ b/src/sources/github/handler.rs @@ -345,7 +345,7 @@ async fn handle_pull_request( deletions: pr.deletions.unwrap_or(0), url: html_url, }; - dispatch::dispatch(&event, state, None).await; + dispatch::dispatch_github(&event, state, None).await; StatusCode::OK } PullRequestWebhookEventAction::ReviewRequested => { @@ -368,7 +368,7 @@ async fn handle_pull_request( reviewer_lark_id, url: html_url, }; - dispatch::dispatch(&event, state, dm_email.as_deref()).await; + dispatch::dispatch_github(&event, state, dm_email.as_deref()).await; StatusCode::OK } PullRequestWebhookEventAction::Closed if pr.merged_at.is_some() => { @@ -386,7 +386,7 @@ async fn handle_pull_request( merged_by, url: html_url, }; - dispatch::dispatch(&event, state, None).await; + dispatch::dispatch_github(&event, state, None).await; StatusCode::OK } _ => { @@ -429,7 +429,7 @@ async fn handle_issues( author, url: html_url, }; - dispatch::dispatch(&event, state, None).await; + dispatch::dispatch_github(&event, state, None).await; StatusCode::OK } @@ -465,7 +465,7 @@ async fn handle_push( commits, compare_url: payload.compare.to_string(), }; - dispatch::dispatch(&event, state, None).await; + dispatch::dispatch_github(&event, state, None).await; StatusCode::OK } @@ -526,7 +526,7 @@ async fn dispatch_cf( Ok(p) => p, Err(e) => { warn!("failed to parse pull_request payload: {e}"); - return StatusCode::BAD_REQUEST; + return StatusCode::OK; } }; handle_pr_cf(state, github, repo, payload).await @@ -536,7 +536,7 @@ async fn dispatch_cf( Ok(p) => p, Err(e) => { warn!("failed to parse issues payload: {e}"); - return StatusCode::BAD_REQUEST; + return StatusCode::OK; } }; handle_issues_cf(state, github, repo, payload).await @@ -546,7 +546,7 @@ async fn dispatch_cf( Ok(p) => p, Err(e) => { warn!("failed to parse push payload: {e}"); - return StatusCode::BAD_REQUEST; + return StatusCode::OK; } }; handle_push_cf(state, repo, payload).await @@ -556,7 +556,7 @@ async fn dispatch_cf( Ok(p) => p, Err(e) => { warn!("failed to parse workflow_run payload: {e}"); - return StatusCode::BAD_REQUEST; + return StatusCode::OK; } }; if payload.action != "completed" { @@ -570,7 +570,7 @@ async fn dispatch_cf( Ok(p) => p, Err(e) => { warn!("failed to parse secret_scanning_alert payload: {e}"); - return StatusCode::BAD_REQUEST; + return StatusCode::OK; } }; if payload.action != "created" { @@ -584,7 +584,7 @@ async fn dispatch_cf( Ok(p) => p, Err(e) => { warn!("failed to parse dependabot_alert payload: {e}"); - return StatusCode::BAD_REQUEST; + return StatusCode::OK; } }; if payload.action != "created" { @@ -631,7 +631,7 @@ async fn handle_pr_cf( deletions: pr.deletions.unwrap_or(0), url: html_url, }; - dispatch::dispatch(&event, state, None).await; + dispatch::dispatch_github(&event, state, None).await; StatusCode::OK } "review_requested" => { @@ -654,7 +654,7 @@ async fn handle_pr_cf( reviewer_lark_id, url: html_url, }; - dispatch::dispatch(&event, state, dm_email.as_deref()).await; + dispatch::dispatch_github(&event, state, dm_email.as_deref()).await; StatusCode::OK } "closed" if pr.merged_at.is_some() => { @@ -672,7 +672,7 @@ async fn handle_pr_cf( merged_by, url: html_url, }; - dispatch::dispatch(&event, state, None).await; + dispatch::dispatch_github(&event, state, None).await; StatusCode::OK } _ => { @@ -714,7 +714,7 @@ async fn handle_issues_cf( author: issue.user.login.clone(), url: issue.html_url.clone(), }; - dispatch::dispatch(&event, state, None).await; + dispatch::dispatch_github(&event, state, None).await; StatusCode::OK } @@ -750,7 +750,7 @@ async fn handle_push_cf( commits, compare_url: payload.compare.clone(), }; - dispatch::dispatch(&event, state, None).await; + dispatch::dispatch_github(&event, state, None).await; StatusCode::OK } @@ -769,7 +769,7 @@ async fn dispatch_workflow_run( Ok(r) => r, Err(e) => { warn!("failed to parse workflow_run data: {e}"); - return StatusCode::BAD_REQUEST; + return StatusCode::OK; } }; let conclusion = run.conclusion.unwrap_or_else(|| "unknown".to_string()); @@ -789,7 +789,7 @@ async fn dispatch_workflow_run( conclusion, url: run.html_url, }; - dispatch::dispatch(&event, state, None).await; + dispatch::dispatch_github(&event, state, None).await; StatusCode::OK } @@ -802,7 +802,7 @@ async fn dispatch_secret_scanning( Ok(a) => a, Err(e) => { warn!("failed to parse secret_scanning_alert data: {e}"); - return StatusCode::BAD_REQUEST; + return StatusCode::OK; } }; let secret_type = alert @@ -815,7 +815,7 @@ async fn dispatch_secret_scanning( secret_type: secret_type.to_string(), url: alert.html_url, }; - dispatch::dispatch(&event, state, None).await; + dispatch::dispatch_github(&event, state, None).await; StatusCode::OK } @@ -828,7 +828,7 @@ async fn dispatch_dependabot( Ok(a) => a, Err(e) => { warn!("failed to parse dependabot_alert data: {e}"); - return StatusCode::BAD_REQUEST; + return StatusCode::OK; } }; let severity = alert.severity.to_lowercase(); @@ -855,7 +855,7 @@ async fn dispatch_dependabot( summary: summary.to_string(), url: alert.html_url, }; - dispatch::dispatch(&event, state, None).await; + dispatch::dispatch_github(&event, state, None).await; StatusCode::OK } diff --git a/wrangler.toml b/wrangler.toml index e9eb6a1..a2fb84f 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -13,6 +13,5 @@ tag = "v1" new_sqlite_classes = ["DebounceObject"] [vars] -LARK_WEBHOOK_URL = "https://open.larksuite.com/open-apis/bot/v2/hook/c10edb2b-d186-457e-926d-7f4562846983" PORT = "3000" DEBOUNCE_DELAY_MS = "5000"