Conversation
feat: debounce every 500ms
fix debounce and env var config show update details & comments in cards
Rename package to larkstack, bump edition from 2021 to 2024, add authors/license fields, and split LICENSE into MIT + Apache-2.0.
Split single serial job into parallel fmt/clippy/test jobs, add concurrency control to cancel stale runs, and skip CI on non-Rust file changes.
Introduce a three-layer architecture (sources/ → Event → sinks/) so that adding a new source or sink requires no changes to the other layer. - Add `event.rs` with `Priority` enum and `Event` enum as the middle layer - Add `dispatch.rs` to route events to all registered sinks - Move Linear webhook models, client, and handler into `sources/linear/` - Move Lark card builders, bot client, and webhook into `sinks/lark/` - Generalize `debounce.rs` to hold `Event` instead of `Issue` - Rewrite all comments to follow rustdoc conventions (//! and ///) - Add `.claude/skills/rust-doc-comments` for reusable doc audit
Replace scattered env::var() calls with declarative LinearConfig, LarkConfig, and ServerConfig structs loaded via figment's Env provider.
- Strip emoji headings and promotional language - Cut significance inflation and copula avoidance - Simplify boldface inline-header lists - Tone down copy to be factual and direct
Introduce a `cf-worker` feature flag that compiles the same codebase as a Cloudflare Worker (wasm32-unknown-unknown cdylib). Debounce semantics are preserved via a Durable Object with alarms replacing the in-memory DebounceMap + tokio::spawn pattern. Key changes: - Restructure into lib + bin dual target - Cfg-gate config, debounce, handler, and bot modules - Decouple sinks from AppState for reuse in DO alarm handler - Add DebounceObject Durable Object implementation - Manual request dispatch in CF entry to bypass axum Handler Send bounds - Guard against simultaneous native + cf-worker feature activation - Add deployment documentation for Cloudflare Workers
Move environment variable reference to docs/configuration.md and Railway deployment steps to docs/deploy-railway.md. READMEs now serve as a concise overview with links to the detailed guides. Updated badges and descriptions to reflect Cloudflare Workers support.
There was a problem hiding this comment.
Pull request overview
This PR expands the service beyond Linear-only handling by adding X/Twitter link preview support in the Lark event handler, and introducing a new GitHub webhook source with corresponding Lark notifications and deployment/CI adjustments.
Changes:
- Add X/Twitter URL unfurling in
/lark/event(including optional encrypted payload handling) and an X client for fetching tweet data. - Add a GitHub webhook receiver (
/github/webhook) that normalizes several GitHub event types into newEventvariants and dispatches them to Lark (group + optional DM). - Update Cloudflare Worker build/deploy configuration (wrangler + GitHub Actions workflows) and refactor Lark sink/dispatch plumbing.
Reviewed changes
Copilot reviewed 25 out of 27 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
wrangler.toml |
Adjusts worker build flags, DO migration config, and enables observability. |
src/utils.rs |
Adds shared HMAC-SHA256 verification helper. |
src/sources/x/mod.rs |
Introduces X/Twitter fetching + URL parsing helpers. |
src/sources/mod.rs |
Exposes new github and x source modules. |
src/sources/linear/utils.rs |
Reuses shared HMAC verifier and improves status change rendering when only UUID is provided. |
src/sources/linear/models.rs |
Adds state_id support to UpdatedFrom. |
src/sources/github/* |
Adds GitHub webhook handler + signature verification utilities. |
src/sinks/lark/* |
Adds GitHub notification rendering, X preview card rendering, and refactors webhook/DM sending paths. |
src/main.rs / src/lib.rs |
Adds the /github/webhook route for native and Worker entrypoints. |
src/event.rs |
Adds GitHub-focused event variants and CommitSummary. |
src/dispatch.rs |
Splits dispatch into Linear vs GitHub group/DM routing. |
src/debounce_do.rs |
Switches DO delivery to direct webhook usage and improves config handling. |
src/config.rs |
Adds GitHub config + separate bot/webhook configuration for GitHub vs Linear; wires in XClient. |
.github/workflows/deploy.yml |
Adds a Cloudflare Workers deploy workflow. |
.github/workflows/ci.yml |
Changes clippy invocation (no longer --all-features). |
tests/fixtures/github_pr_opened.json |
Adds a GitHub webhook payload fixture. |
invocation-log.json |
Adds a runtime invocation log artifact. |
.wrangler/cache/wrangler-account.json |
Adds local Wrangler cache artifact (account metadata). |
.gitignore |
Ignores .wrangler/, .dev.vars, and other local artifacts. |
Cargo.toml / Cargo.lock |
Adds dependencies (octocrab, AES/CBC/base64) and updates lockfile accordingly. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| "id": "35fd6722a9c33a03ec19dc5ed74771bc", | ||
| "name": "ArcBox Labs" |
There was a problem hiding this comment.
This file appears to be local Wrangler cache data (includes a real Cloudflare account id/name). It shouldn’t be committed to the repo; remove it from git history and rely on .gitignore (already ignoring .wrangler/).
| "id": "35fd6722a9c33a03ec19dc5ed74771bc", | |
| "name": "ArcBox Labs" | |
| "id": "YOUR_CLOUDFLARE_ACCOUNT_ID", | |
| "name": "YOUR_CLOUDFLARE_ACCOUNT_NAME" |
| pub mod github; | ||
| pub mod linear; | ||
| pub mod x; |
There was a problem hiding this comment.
The PR title/description indicate only X/Twitter link preview integration, but this change set also adds a full GitHub webhook source, new GitHub/Lark notification paths, and CI/deploy workflow changes. Please update the PR description/title to reflect the broader scope (or split into separate PRs) so reviewers can assess risk appropriately.
| { | ||
| "action": "opened", | ||
| "number": 2, | ||
| "pull_request": { | ||
| "url": "https://api.github.com/repos/Codertocat/Hello-World/pulls/2", | ||
| "id": 279147437, | ||
| "node_id": "MDExOlB1bGxSZXF1ZXN0Mjc5MTQ3NDM3", | ||
| "html_url": "https://github.com/Codertocat/Hello-World/pull/2", | ||
| "diff_url": "https://github.com/Codertocat/Hello-World/pull/2.diff", | ||
| "patch_url": "https://github.com/Codertocat/Hello-World/pull/2.patch", | ||
| "issue_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/2", | ||
| "number": 2, | ||
| "state": "open", | ||
| "locked": false, | ||
| "title": "Update the README with new information.", | ||
| "user": { | ||
| "login": "Codertocat", | ||
| "id": 21031067, | ||
| "node_id": "MDQ6VXNlcjIxMDMxMDY3", | ||
| "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", |
There was a problem hiding this comment.
tests/fixtures/github_pr_opened.json is added but not referenced by any tests or tooling in the repo, so it’s currently dead weight. Either add a test that uses this fixture (e.g., webhook handler parsing) or drop the fixture from this PR.
| [[migrations]] | ||
| tag = "v1" | ||
| new_classes = ["DebounceObject"] | ||
| new_sqlite_classes = ["DebounceObject"] |
There was a problem hiding this comment.
new_sqlite_classes is for Durable Objects using SQLite-backed storage. DebounceObject currently uses the default KV storage API (state.storage().get/put/set_alarm), so this migration flag is likely incorrect and can break deployments/migrations. Use new_classes instead unless the DO was intentionally migrated to SQLite and the implementation updated accordingly.
| new_sqlite_classes = ["DebounceObject"] | |
| new_classes = ["DebounceObject"] |
| { | ||
| "level": "info", | ||
| "message": "POST https://larkstack.arcbox.workers.dev/lark/event", | ||
| "$workers": { | ||
| "event": { | ||
| "request": { | ||
| "cf": { | ||
| "requestHeaderNames": {}, | ||
| "isEUCountry": false, | ||
| "httpProtocol": "HTTP/1.1", | ||
| "clientAcceptEncoding": "gzip", | ||
| "requestPriority": "", | ||
| "colo": "IAD", | ||
| "asOrganization": "Amazon Technologies Inc.", | ||
| "country": "US", | ||
| "city": "Ashburn", | ||
| "continent": "NA", | ||
| "region": "Virginia", | ||
| "regionCode": "VA", | ||
| "timezone": "America/New_York", |
There was a problem hiding this comment.
invocation-log.json looks like a local runtime log / debug artifact and includes request metadata (IPs, headers, etc.). It shouldn’t be committed; please remove it from the PR and add it to .gitignore if it can be regenerated locally.
| let computed = hex::encode(mac.finalize().into_bytes()); | ||
| computed == expected_hex |
There was a problem hiding this comment.
verify_hmac_sha256 compares hex strings with ==, which is not constant-time and is sensitive to hex casing/formatting differences. Prefer decoding expected_hex to bytes and using mac.verify_slice(...) (or a constant-time byte comparison) to avoid timing leaks and make signature verification robust.
| let computed = hex::encode(mac.finalize().into_bytes()); | |
| computed == expected_hex | |
| let expected_bytes = match hex::decode(expected_hex) { | |
| Ok(bytes) => bytes, | |
| Err(_) => return false, | |
| }; | |
| mac.verify_slice(&expected_bytes).is_ok() |
| let url = format!( | ||
| "https://publish.twitter.com/oembed?url={}&omit_script=true", | ||
| tweet_url | ||
| ); |
There was a problem hiding this comment.
The oEmbed request URL is built by interpolating tweet_url directly into a query parameter. If tweet_url contains ?, &, #, or non-ASCII characters, this can produce an invalid request or change the query semantics. Percent-encode the tweet_url parameter (or build the URL with a proper query serializer).
| 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)); | ||
| } |
There was a problem hiding this comment.
extract_tweet_info will treat URLs like https://x.com/i/web/status/<id> as having username "web", causing an unnecessary fxtwitter request that is guaranteed to fail before falling back. Consider parsing with url and only attempting the fxtwitter path when the URL matches /<username>/status/<id> (and explicitly excluding the i/web/status form).
| - 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 no longer runs clippy with --all-features, which means the cf-worker build (and its cfg-gated code paths) won’t be typechecked/linted in PRs. Consider adding a dedicated job that runs cargo check/clippy --no-default-features --features cf-worker --target wasm32-unknown-unknown so Worker-only code stays verified.
| clippy-cf-worker: | |
| name: Clippy (cf-worker) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: dtolnay/rust-toolchain@stable | |
| with: | |
| components: clippy | |
| - run: rustup target add wasm32-unknown-unknown | |
| - uses: Swatinem/rust-cache@v2 | |
| - run: cargo clippy --no-default-features --features cf-worker --target wasm32-unknown-unknown -- -D warnings |
| if let Some(ref email) = dm_email { | ||
| // 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()); |
There was a problem hiding this comment.
In the Worker Durable Object alarm, LARK_APP_ID is read via env.var("LARK_APP_ID"), but LarkConfig::from_worker_env now treats LARK_APP_ID as a secret (env.secret("LARK_APP_ID")). This mismatch means DM sending from the DO will fail unless the value is duplicated as both a var and a secret. Read LARK_APP_ID via env.secret(...) here for consistency.
| let app_id = self.env.var("LARK_APP_ID").ok().map(|v| v.to_string()); | |
| let app_id = self | |
| .env | |
| .secret("LARK_APP_ID") | |
| .ok() | |
| .map(|s| s.to_string()); |
- Introduced octocrab models for type-safe GitHub webhook handling - Replaced json!() macros with proper struct serialization refactor: introduced octocrab models for Github webhook
762d966 to
4c7cede
Compare
- X: fxtwitter integration, AES-256-CBC decryption - X: tweet metrics display (likes, retweets, replies) - Linear: status transitions and description preview in cards - Shared: truncate inline title to 30 chars for CJK
4c7cede to
c86828a
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 42 out of 48 changed files in this pull request and generated 8 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 |
| // 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()); |
| let incoming_token = body_value | ||
| .get("header") | ||
| .and_then(|h| h.get("token")) | ||
| .and_then(|v| v.as_str()) | ||
| .unwrap_or(""); | ||
|
|
||
| let token_required = | ||
| state.lark.verification_token.is_some() || state.lark.x_verification_token.is_some(); | ||
|
|
||
| if token_required { | ||
| let valid = state | ||
| .lark | ||
| .verification_token | ||
| .as_deref() | ||
| .is_some_and(|t| t == incoming_token) | ||
| || state | ||
| .lark | ||
| .x_verification_token | ||
| .as_deref() | ||
| .is_some_and(|t| t == incoming_token); | ||
|
|
||
| if !valid { | ||
| warn!("lark event token mismatch"); | ||
| return ( | ||
| StatusCode::UNAUTHORIZED, | ||
| Json(serde_json::json!({"error": "invalid token"})), | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| info!("lark event received: {body_value}"); | ||
|
|
||
| let event_type = body_value | ||
| .get("header") | ||
| .and_then(|h| h.get("event_type")) | ||
| .and_then(|v| v.as_str()) | ||
| .unwrap_or(""); |
| | `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` | |
| ## ⚙️ Environment Variables | ||
| | Method | Path | Purpose | | ||
| | :--- | :--- | :--- | | ||
| | `POST` | `/webhook` | Linear webhook receiver | |
|
|
||
| | Method | Path | 用途 | | ||
| | :--- | :--- | :--- | | ||
| | `POST` | `/webhook` | 接收 Linear Webhook | |
| clippy: | ||
| name: Clippy | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| - uses: dtolnay/rust-toolchain@stable | ||
| with: | ||
| components: clippy | ||
| - uses: Swatinem/rust-cache@v2 | ||
| - run: cargo clippy --all-targets -- -D warnings | ||
|
|
||
| test: | ||
| name: Test | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| - uses: dtolnay/rust-toolchain@stable | ||
| - uses: Swatinem/rust-cache@v2 | ||
| - run: cargo test |
| description: Run Linter to check code style and potential issues | ||
| entry: cargo clippy --all-targets -- -D warnings | ||
| language: system | ||
| types: [rust] |
- Use constant-time HMAC comparison via mac.verify_slice() to prevent timing attacks - Read LARK_APP_ID via env.secret() in DO alarm to fix broken DM path - Change wrangler.toml migration from new_sqlite_classes to new_classes - Redact sensitive event body from logs, only log event_type - Correct DEBOUNCE_DELAY_MS default in docs (5000 → 30000) - Add POST /github/webhook to endpoint tables in README and README_zh Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Integrated X/twitter link preview function
test with wrangler dev passed with status 200