Conversation
There was a problem hiding this comment.
Pull request overview
This PR expands the webhook/notification pipeline beyond Linear by adding GitHub webhook ingestion and X/Twitter link previews, along with related Lark sink updates, debounce improvements, and deployment/CI configuration updates.
Changes:
- Add a GitHub webhook source that verifies signatures, normalizes events into the unified
Eventmodel, and dispatches to Lark (including optional DMs). - Add X/Twitter link preview support in the Lark event handler, including optional decryption for encrypted Lark callback payloads.
- Add a max-wait cap to debounce behavior (native + Durable Object) and update Cloudflare Workers build/deploy settings.
Reviewed changes
Copilot reviewed 27 out of 29 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
wrangler.toml |
Updates Worker build command, debounce defaults, migrations, and observability settings. |
tests/fixtures/github_pr_opened.json |
Adds a GitHub webhook fixture payload for testing/dev. |
src/utils.rs |
Adds shared HMAC-SHA256 verification helper. |
src/sources/x/mod.rs |
Introduces X/Twitter client + URL parsing + multi-source fetch fallback. |
src/sources/mod.rs |
Exposes new github and x sources. |
src/sources/linear/utils.rs |
Reuses shared HMAC verifier; improves status change messaging. |
src/sources/linear/models.rs |
Extends UpdatedFrom to support state_id-only payloads. |
src/sources/linear/handler.rs |
Threads debounce delay/max-wait into debounce scheduling + payload. |
src/sources/github/utils.rs |
Adds GitHub signature header parsing and HMAC delegation. |
src/sources/github/mod.rs |
Wires GitHub source module exports. |
src/sources/github/handler.rs |
Implements GitHub webhook handler (native via octocrab, CF via thin structs). |
src/sinks/lark/mod.rs |
Splits Linear vs GitHub webhook notifications; refines DM logic. |
src/sinks/lark/event_handler.rs |
Adds encrypted payload support + routes link previews to X or Linear. |
src/sinks/lark/cards.rs |
Adds GitHub card builders + X preview card builder; refactors shared helpers. |
src/sinks/lark/bot.rs |
Refactors bot API calls into typed request/response structs; adds chat send helper. |
src/main.rs |
Adds /github/webhook route for native server. |
src/lib.rs |
Adds /github/webhook routing for CF Worker entrypoint. |
src/event.rs |
Extends Event enum with GitHub event variants and commit summaries. |
src/dispatch.rs |
Adds dedicated GitHub dispatch path (separate webhook + bot fallback). |
src/debounce_do.rs |
Adds max-wait enforcement in DO debounce + improved webhook/DM sending. |
src/debounce.rs |
Adds max-wait enforcement for in-memory debounce and returns actual delay. |
src/config.rs |
Adds GitHub config, X client, multiple Lark webhook/bot configs, and debounce max-wait config. |
invocation-log.json |
Adds a runtime invocation log artifact. |
Cargo.toml |
Adds deps for octocrab (native), AES-CBC decrypt, base64. |
Cargo.lock |
Locks new dependencies introduced by the PR. |
.wrangler/cache/wrangler-account.json |
Adds Wrangler local cache artifact containing account metadata. |
.gitignore |
Ignores additional local/dev artifacts including .wrangler/. |
.github/workflows/deploy.yml |
Adds GitHub Actions workflow to deploy to Cloudflare Workers on main. |
.github/workflows/ci.yml |
Adjusts clippy invocation in CI. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| let computed = hex::encode(mac.finalize().into_bytes()); | ||
| computed == expected_hex |
There was a problem hiding this comment.
verify_hmac_sha256 compares the computed and expected MAC using normal string equality, which is not constant-time and also ties verification to a specific hex formatting (case, leading zeros). Prefer decoding expected_hex to raw bytes and using the HMAC crate’s constant-time verification (e.g., verify_slice) so signature checks are timing-safe and format-robust.
| let computed = hex::encode(mac.finalize().into_bytes()); | |
| computed == expected_hex | |
| let Ok(expected_bytes) = hex::decode(expected_hex) else { | |
| return false; | |
| }; | |
| let result = mac.finalize(); | |
| result.verify_slice(&expected_bytes).is_ok() |
| })); | ||
| if !tweet.text.is_empty() { | ||
| elements.push(json!({ | ||
| "tag": "markdown", |
There was a problem hiding this comment.
build_x_preview_card adds an element with { "tag": "markdown" }, while the rest of the code builds interactive cards using div + lark_md. If Lark interactive cards don’t support a top-level markdown element, this preview card will render incorrectly or be rejected. Consider using the existing md_div(...) helper (or whichever element type Lark expects) for tweet text to keep the card schema consistent.
| "tag": "markdown", | |
| "tag": "div", | |
| "lark_md": true, |
| async fn fetch_oembed(&self, tweet_url: &str) -> Result<TweetData, String> { | ||
| let url = format!( | ||
| "https://publish.twitter.com/oembed?url={}&omit_script=true", | ||
| tweet_url | ||
| ); |
There was a problem hiding this comment.
fetch_oembed interpolates tweet_url directly into the url= query parameter without percent-encoding. Real tweet URLs often contain ?/& query strings, which will break the oEmbed request (or change its semantics). Encode tweet_url before building the request URL (e.g., via url::form_urlencoded / percent-encoding) so the fallback works reliably.
| .route( | ||
| "/github/webhook", | ||
| post(larkstack::sources::github::webhook_handler), | ||
| ) |
There was a problem hiding this comment.
PR title/description mention only “Linear integration” updates, but this change set also adds a full GitHub webhook source, X/Twitter link previews, new CI/deploy workflows, and Lark encryption handling. Please update the PR title/description to reflect the actual scope so reviewers/operators can assess impact correctly.
| 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() |
There was a problem hiding this comment.
In the Durable Object alarm path, LARK_APP_ID is read via env.var("LARK_APP_ID"), but LarkConfig::from_worker_env reads LARK_APP_ID via env.secret("LARK_APP_ID"). With the current setup, one side will fail depending on how the Worker env var is configured, and Linear DMs may silently stop working. Align these to use the same mechanism (prefer secret(...) for credentials).
| components: clippy | ||
| - uses: Swatinem/rust-cache@v2 | ||
| - run: cargo clippy --all-targets --all-features -- -D warnings | ||
| - run: cargo clippy --all-targets -- -D warnings |
There was a problem hiding this comment.
CI clippy no longer runs with --all-features. Since this repo has feature-gated targets (native vs cf-worker), dropping --all-features can let feature-specific compilation/lint issues slip through (notably for the Cloudflare Worker build). Consider adding a second clippy step for --no-default-features --features cf-worker or re-enabling --all-features.
| - run: cargo clippy --all-targets -- -D warnings | |
| - name: Clippy (default features) | |
| run: cargo clippy --all-targets -- -D warnings | |
| - name: Clippy (cf-worker features) | |
| run: cargo clippy --all-targets --no-default-features --features cf-worker -- -D warnings |
| { | ||
| "level": "info", | ||
| "message": "POST https://larkstack.arcbox.workers.dev/lark/event", | ||
| "$workers": { | ||
| "event": { | ||
| "request": { |
There was a problem hiding this comment.
This invocation log appears to be an environment-generated diagnostic artifact and includes detailed request metadata. It shouldn’t be committed to the repo; please remove it and add it to .gitignore to avoid leaking operational details and noisy diffs in future PRs.
| "account": { | ||
| "id": "35fd6722a9c33a03ec19dc5ed74771bc", | ||
| "name": "ArcBox Labs" | ||
| } |
There was a problem hiding this comment.
This file contains a Cloudflare account id/name and is under .wrangler/cache/, which is typically local, auto-generated state. It should not be committed; please remove it from the repo (and ensure .wrangler/ stays ignored, as in the updated .gitignore).
| "account": { | |
| "id": "35fd6722a9c33a03ec19dc5ed74771bc", | |
| "name": "ArcBox Labs" | |
| } |
- Introduced octocrab models for type-safe GitHub webhook handling - Replaced json!() macros with proper struct serialization refactor: introduced octocrab models for Github webhook
…te card feat: X link preview with fxtwitter, AES decryption, and card improvements - Add fxtwitter.com as primary tweet source (no auth, rich metrics) - Add AES-256-CBC decryption for encrypted Lark payloads (LARK_X_ENCRYPT_KEY) - Support separate verification tokens for Linear and X Lark apps - Show tweet text, likes, and retweets in X preview card - Fix metrics condition to show counts even when only one metric is present - Truncate inline title to 30 chars (better for CJK text) - Add like_count, retweet_count, reply_count to TweetData - Enable CF Workers observability in wrangler.toml
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 42 out of 48 changed files in this pull request and generated 6 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
| pub fn verify_hmac_sha256(secret: &str, body: &[u8], expected_hex: &str) -> bool { | ||
| let Ok(mut mac) = Hmac::<Sha256>::new_from_slice(secret.as_bytes()) else { | ||
| return false; | ||
| }; | ||
| mac.update(body); | ||
| let expected = hex::encode(mac.finalize().into_bytes()); | ||
| expected == signature | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Priority helpers | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| pub fn priority_color(priority: u8) -> &'static str { | ||
| match priority { | ||
| 1 => "red", | ||
| 2 => "orange", | ||
| 3 => "yellow", | ||
| _ => "blue", // 0 (No priority) and 4 (Low) | ||
| } | ||
| let computed = hex::encode(mac.finalize().into_bytes()); | ||
| computed == expected_hex |
| // Extract repo name and full_name in one pass (used for whitelist + display). | ||
| let (repo, repo_name) = match serde_json::from_slice::<RepoProbe>(&body) { | ||
| Ok(probe) => { | ||
| let full = probe | ||
| .repository | ||
| .full_name | ||
| .clone() | ||
| .unwrap_or_else(|| probe.repository.name.clone()); | ||
| (full, probe.repository.name) | ||
| } | ||
| Err(_) => { | ||
| warn!("could not extract repository name from payload"); | ||
| (String::new(), String::new()) | ||
| } | ||
| }; | ||
|
|
||
| if !github.repo_whitelist.is_empty() | ||
| && !repo_name.is_empty() | ||
| && !github.repo_whitelist.contains(&repo_name) | ||
| { | ||
| info!("ignoring event from non-whitelisted repo: {repo_name}"); | ||
| return StatusCode::OK; | ||
| } |
| // 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()); |
| /// Extracts `(username, tweet_id)` from an X or Twitter URL. | ||
| /// | ||
| /// Handles `https://x.com/user/status/1234567890` and | ||
| /// `https://twitter.com/user/status/1234567890`. | ||
| pub fn extract_tweet_info(url: &str) -> Option<(&str, &str)> { | ||
| let parts: Vec<&str> = url.split('/').collect(); | ||
| for (i, &part) in parts.iter().enumerate() { | ||
| if part == "status" { | ||
| let id = parts.get(i + 1).copied()?; | ||
| let id = id.split('?').next().unwrap_or(id); | ||
| let id = id.split('#').next().unwrap_or(id); | ||
| if !id.is_empty() && id.chars().all(|c| c.is_ascii_digit()) { | ||
| let username = parts.get(i.wrapping_sub(1)).copied().unwrap_or(""); | ||
| return Some((username, id)); | ||
| } |
| ); | ||
| } | ||
| } | ||
|
|
||
| info!("lark event received: {body_value}"); | ||
|
|
||
| let event_type = body_value |
| | `LINEAR_API_KEY` | Optional | GraphQL API access — enables link previews | | ||
| | `LARK_VERIFICATION_TOKEN` | Optional | Lark event callback verification | | ||
| | `PORT` | No | Listen port, defaults to `3000` (ignored on CF Workers) | | ||
| | `DEBOUNCE_DELAY_MS` | No | Debounce window in ms, defaults to `5000` | |
- Replace note/plain_text with md_div for X preview metrics so emoji (❤️ 🔁) render correctly in Lark - Fix repo whitelist check to also reject events when repo name cannot be extracted from payload (was previously bypassed on parse failure)
There was a problem hiding this comment.
Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.
There was a problem hiding this comment.
Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.
Later updates for linear integration will be updated here.