diff --git a/README.md b/README.md
index c705dc48..530eb871 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@

-A lightweight, secure, cloud-native ACP harness that bridges **Discord, Slack**, and any [Agent Client Protocol](https://github.com/anthropics/agent-protocol)-compatible coding CLI (Kiro CLI, Claude Code, Codex, Gemini, OpenCode, Copilot CLI, etc.) over stdio JSON-RPC — delivering the next-generation development experience. **Telegram, LINE**, and other webhook-based platforms are supported via the standalone [Custom Gateway](gateway/).
+A lightweight, secure, cloud-native ACP harness that bridges **Discord, Slack**, and any [Agent Client Protocol](https://github.com/anthropics/agent-protocol)-compatible coding CLI (Kiro CLI, Claude Code, Codex, Gemini, OpenCode, Copilot CLI, etc.) over stdio JSON-RPC — delivering the next-generation development experience. **Telegram, LINE, Feishu/Lark**, and other webhook-based platforms are supported via the standalone [Custom Gateway](gateway/).
🪼 **Join our community!** Come say hi on Discord — we'd love to have you: **[🪼 OpenAB — Official](https://discord.gg/DmbhfDZjQS)** 🎉
@@ -21,8 +21,10 @@ A lightweight, secure, cloud-native ACP harness that bridges **Discord, Slack**,
├──────────────┤ ▼ ▼
│ LINE │◄──webhook──┌──────────────────┐
│ User │ │ Custom Gateway │
-└──────────────┘ │ (standalone) │
- └──────────────────┘
+├──────────────┤ │ (standalone) │
+│ Feishu/Lark │◄───WS──────│ │
+│ User │ └──────────────────┘
+└──────────────┘
```
## Demo
@@ -87,6 +89,13 @@ See [docs/line.md](docs/line.md) for the full setup guide. Requires the standalo
+
+Feishu/Lark (via Custom Gateway)
+
+See [docs/feishu.md](docs/feishu.md) for the full setup guide. Requires the standalone [Custom Gateway](gateway/) service. Supports WebSocket long-connection (default, no public URL needed) and HTTP webhook fallback.
+
+
+
### 2. Install with Helm (Kiro CLI — default)
```bash
diff --git a/charts/openab/templates/gateway-secret.yaml b/charts/openab/templates/gateway-secret.yaml
index f70de93b..eb55a198 100644
--- a/charts/openab/templates/gateway-secret.yaml
+++ b/charts/openab/templates/gateway-secret.yaml
@@ -3,7 +3,8 @@
{{- $gwCfg := omit $cfg "nameOverride" }}
{{- $d := dict "ctx" $ "agent" (printf "%s-gateway" $name) "cfg" $gwCfg }}
{{- $hasTeams := and (($cfg.gateway).teams).appId (($cfg.gateway).teams).appSecret }}
-{{- if $hasTeams }}
+{{- $hasFeishu := and (($cfg.gateway).feishu).appId (($cfg.gateway).feishu).appSecret }}
+{{- if or $hasTeams $hasFeishu }}
---
apiVersion: v1
kind: Secret
@@ -15,7 +16,18 @@ metadata:
"helm.sh/resource-policy": keep
type: Opaque
data:
+ {{- if $hasTeams }}
teams-app-secret: {{ ($cfg.gateway).teams.appSecret | b64enc | quote }}
+ {{- end }}
+ {{- if $hasFeishu }}
+ feishu-app-secret: {{ ($cfg.gateway).feishu.appSecret | b64enc | quote }}
+ {{- if ($cfg.gateway).feishu.verificationToken }}
+ feishu-verification-token: {{ ($cfg.gateway).feishu.verificationToken | b64enc | quote }}
+ {{- end }}
+ {{- if ($cfg.gateway).feishu.encryptKey }}
+ feishu-encrypt-key: {{ ($cfg.gateway).feishu.encryptKey | b64enc | quote }}
+ {{- end }}
+ {{- end }}
{{- end }}
{{- end }}
{{- end }}
diff --git a/charts/openab/templates/gateway.yaml b/charts/openab/templates/gateway.yaml
index 39672c05..643d7e1b 100644
--- a/charts/openab/templates/gateway.yaml
+++ b/charts/openab/templates/gateway.yaml
@@ -75,6 +75,54 @@ spec:
value: {{ ($cfg.gateway).teams.webhookPath | quote }}
{{- end }}
{{- end }}
+ {{- $hasFeishu := and (($cfg.gateway).feishu).appId (($cfg.gateway).feishu).appSecret }}
+ {{- if $hasFeishu }}
+ - name: FEISHU_APP_ID
+ value: {{ ($cfg.gateway).feishu.appId | quote }}
+ - name: FEISHU_APP_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "openab.agentFullname" $d }}
+ key: feishu-app-secret
+ {{- if ($cfg.gateway).feishu.domain }}
+ - name: FEISHU_DOMAIN
+ value: {{ ($cfg.gateway).feishu.domain | quote }}
+ {{- end }}
+ {{- if ($cfg.gateway).feishu.connectionMode }}
+ - name: FEISHU_CONNECTION_MODE
+ value: {{ ($cfg.gateway).feishu.connectionMode | quote }}
+ {{- end }}
+ {{- if ($cfg.gateway).feishu.webhookPath }}
+ - name: FEISHU_WEBHOOK_PATH
+ value: {{ ($cfg.gateway).feishu.webhookPath | quote }}
+ {{- end }}
+ {{- if ($cfg.gateway).feishu.verificationToken }}
+ - name: FEISHU_VERIFICATION_TOKEN
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "openab.agentFullname" $d }}
+ key: feishu-verification-token
+ {{- end }}
+ {{- if ($cfg.gateway).feishu.encryptKey }}
+ - name: FEISHU_ENCRYPT_KEY
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "openab.agentFullname" $d }}
+ key: feishu-encrypt-key
+ {{- end }}
+ {{- if ($cfg.gateway).feishu.allowedGroups }}
+ - name: FEISHU_ALLOWED_GROUPS
+ value: {{ ($cfg.gateway).feishu.allowedGroups | join "," | quote }}
+ {{- end }}
+ {{- if ($cfg.gateway).feishu.allowedUsers }}
+ - name: FEISHU_ALLOWED_USERS
+ value: {{ ($cfg.gateway).feishu.allowedUsers | join "," | quote }}
+ {{- end }}
+ {{- if not (($cfg.gateway).feishu).requireMention }}
+ - name: FEISHU_REQUIRE_MENTION
+ value: "false"
+ {{- end }}
+ {{- end }}
- name: RUST_LOG
value: {{ ($cfg.gateway).rustLog | default "info" | quote }}
livenessProbe:
diff --git a/charts/openab/values.yaml b/charts/openab/values.yaml
index 0eddf16e..4e507cb8 100644
--- a/charts/openab/values.yaml
+++ b/charts/openab/values.yaml
@@ -214,6 +214,19 @@ agents:
openidMetadata: "" # Override for sovereign clouds → TEAMS_OPENID_METADATA
allowedTenants: [] # List of tenant IDs → TEAMS_ALLOWED_TENANTS
webhookPath: "" # Gateway default: /webhook/teams → TEAMS_WEBHOOK_PATH
+ # Feishu/Lark adapter config (gateway-side env vars)
+ # See docs/feishu.md for full setup guide
+ feishu:
+ appId: "" # Feishu App ID → FEISHU_APP_ID
+ appSecret: "" # Feishu App Secret → FEISHU_APP_SECRET (use --set-literal or external secret mgmt)
+ domain: "feishu" # "feishu" (China) or "lark" (international) → FEISHU_DOMAIN
+ connectionMode: "websocket" # "websocket" (default, recommended) or "webhook" → FEISHU_CONNECTION_MODE
+ webhookPath: "" # Gateway default: /webhook/feishu → FEISHU_WEBHOOK_PATH
+ verificationToken: "" # Webhook verification token → FEISHU_VERIFICATION_TOKEN
+ encryptKey: "" # Webhook encrypt key → FEISHU_ENCRYPT_KEY
+ allowedGroups: [] # List of chat_id → FEISHU_ALLOWED_GROUPS
+ allowedUsers: [] # List of open_id → FEISHU_ALLOWED_USERS
+ requireMention: true # Require @mention in groups → FEISHU_REQUIRE_MENTION
# Scheduled messages — config-driven cron (ADR: basic-cronjob)
# Each entry sends a message to the agent at the specified schedule.
# Example:
diff --git a/docs/config-reference.md b/docs/config-reference.md
index 2dd5bd1f..1bfc762f 100644
--- a/docs/config-reference.md
+++ b/docs/config-reference.md
@@ -65,12 +65,12 @@ Slack adapter using Socket Mode. Requires both a Bot User OAuth Token and an App
## `[gateway]`
-Custom Gateway adapter for platforms like Telegram and LINE. Connects to the gateway via WebSocket.
+Custom Gateway adapter for platforms like Telegram, LINE, and Feishu/Lark. Connects to the gateway via WebSocket.
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `url` | string | *required* | WebSocket URL of the gateway (e.g. `ws://openab-gateway:8080/ws`). |
-| `platform` | string | `"telegram"` | Platform name for session key namespacing (e.g. `"telegram"`, `"line"`). |
+| `platform` | string | `"telegram"` | Platform name for session key namespacing (e.g. `"telegram"`, `"line"`, `"feishu"`). |
| `token` | string | — | Shared token for WebSocket authentication (optional but recommended). |
| `bot_username` | string | — | Bot username for @mention gating in groups. |
| `allow_all_channels` | bool \| omit | auto-detect | `true` = all channels; `false` = only `allowed_channels`. Omitted = inferred from list (non-empty → false, empty → true). |
diff --git a/docs/feishu.md b/docs/feishu.md
new file mode 100644
index 00000000..59511925
--- /dev/null
+++ b/docs/feishu.md
@@ -0,0 +1,108 @@
+# Feishu / Lark
+
+Connect OpenAB to Feishu (China) or Lark (international) so users can chat with an AI agent in DMs or group chats.
+
+## Prerequisites
+
+1. Create a Feishu/Lark app at [open.feishu.cn](https://open.feishu.cn/) or [open.larksuite.com](https://open.larksuite.com/).
+2. Enable the **Bot** capability.
+3. In **Event Subscriptions**, select **Long Connection** (WebSocket) mode.
+4. Add the `im.message.receive_v1` event.
+5. Grant the following permission scopes:
+ - `im:message` — receive messages
+ - `im:message:send_as_bot` — send messages as bot
+ - `contact:user.base:readonly` — resolve sender display names (recommended; without it, senders show as `ou_xxx`)
+6. Copy the **App ID** and **App Secret** from **Credentials & Basic Info**.
+
+## Quick Start (Helm)
+
+```yaml
+agents:
+ kiro:
+ gateway:
+ enabled: true
+ url: "ws://openab-kiro-gateway:8080/ws"
+ platform: "feishu"
+ botUsername: "ou_YOUR_BOT_OPEN_ID" # bot's open_id for @mention gating
+ feishu:
+ appId: "cli_xxx"
+ appSecret: "secret_xxx"
+ domain: "feishu" # "feishu" or "lark"
+ connectionMode: "websocket" # recommended
+```
+
+```bash
+helm upgrade --install openab charts/openab \
+ --set-literal agents.kiro.gateway.feishu.appSecret="your-secret"
+```
+
+## Connection Modes
+
+### WebSocket (default, recommended)
+
+The gateway connects outbound to Feishu — no public URL, TLS, or Ingress required.
+
+Set `connectionMode: "websocket"` (default).
+
+### Webhook (fallback)
+
+Use when outbound WebSocket is blocked by your network.
+
+```yaml
+feishu:
+ connectionMode: "webhook"
+ webhookPath: "/webhook/feishu"
+ verificationToken: "your-token"
+ encryptKey: "your-key"
+```
+
+Then configure the webhook URL in Feishu Open Platform → Event Subscriptions → Request URL:
+```
+https://your-gateway-host/webhook/feishu
+```
+
+## Configuration Reference
+
+| Helm Value | Env Var | Default | Description |
+|---|---|---|---|
+| `feishu.appId` | `FEISHU_APP_ID` | — | App ID (required) |
+| `feishu.appSecret` | `FEISHU_APP_SECRET` | — | App Secret (required, stored in K8s Secret) |
+| `feishu.domain` | `FEISHU_DOMAIN` | `feishu` | `feishu` (China) or `lark` (international) |
+| `feishu.connectionMode` | `FEISHU_CONNECTION_MODE` | `websocket` | `websocket` or `webhook` |
+| `feishu.webhookPath` | `FEISHU_WEBHOOK_PATH` | `/webhook/feishu` | Webhook endpoint path |
+| `feishu.verificationToken` | `FEISHU_VERIFICATION_TOKEN` | — | Webhook verification token (stored in K8s Secret) |
+| `feishu.encryptKey` | `FEISHU_ENCRYPT_KEY` | — | Webhook encrypt key (stored in K8s Secret) |
+| `feishu.allowedGroups` | `FEISHU_ALLOWED_GROUPS` | — | Comma-separated chat_id allowlist |
+| `feishu.allowedUsers` | `FEISHU_ALLOWED_USERS` | — | Comma-separated open_id allowlist |
+| `feishu.requireMention` | `FEISHU_REQUIRE_MENTION` | `true` | Require @mention in groups |
+| — | `FEISHU_DEDUPE_TTL_SECS` | `300` | Event deduplication cache TTL (seconds) |
+| — | `FEISHU_MESSAGE_LIMIT` | `4000` | Max message length before auto-splitting (bytes) |
+| `gateway.botUsername` | — | — | Set to bot's `open_id` for @mention gating |
+
+## @mention Gating
+
+In group chats, the bot only responds when @mentioned (default). To find your bot's `open_id`:
+
+1. Start the gateway — it logs the bot identity on startup:
+ ```
+ feishu bot identity resolved bot_open_id=ou_xxx
+ ```
+2. Set `gateway.botUsername` to this value.
+
+To disable mention gating: `feishu.requireMention: false`.
+
+## Security Notes
+
+- `appSecret`, `verificationToken`, and `encryptKey` are stored in a Kubernetes Secret, not in ConfigMap.
+- In webhook mode, always set both `verificationToken` and `encryptKey` for production.
+- The gateway enforces a 1 MB body size limit and per-IP rate limiting (120 req/60s) on the webhook endpoint.
+
+## Troubleshooting
+
+| Problem | Fix |
+|---|---|
+| Bot doesn't respond | Check `FEISHU_APP_ID`/`FEISHU_APP_SECRET` are correct. Check gateway logs for token errors. |
+| Bot doesn't respond in groups | Ensure bot is @mentioned, or set `requireMention: false`. Check `botUsername` matches bot's `open_id`. |
+| WebSocket keeps reconnecting | Check event subscription is set to **Long Connection** mode. Check app is published and approved. |
+| Webhook verification fails | Ensure `verificationToken` and `encryptKey` match Feishu app config. |
+| Messages from Lark (international) | Set `domain: "lark"` to use `open.larksuite.com` API endpoints. |
diff --git a/gateway/Cargo.lock b/gateway/Cargo.lock
index e0d445d5..1ff375e3 100644
--- a/gateway/Cargo.lock
+++ b/gateway/Cargo.lock
@@ -2,6 +2,17 @@
# It is not intended for manual editing.
version = 4
+[[package]]
+name = "aes"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
+dependencies = [
+ "cfg-if",
+ "cipher",
+ "cpufeatures",
+]
+
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -124,6 +135,15 @@ dependencies = [
"generic-array",
]
+[[package]]
+name = "block-padding"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
+dependencies = [
+ "generic-array",
+]
+
[[package]]
name = "bumpalo"
version = "3.20.2"
@@ -142,6 +162,15 @@ version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+[[package]]
+name = "cbc"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
+dependencies = [
+ "cipher",
+]
+
[[package]]
name = "cc"
version = "1.2.60"
@@ -178,6 +207,16 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "cipher"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
+dependencies = [
+ "crypto-common",
+ "inout",
+]
+
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
@@ -227,6 +266,15 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+dependencies = [
+ "powerfmt",
+]
+
[[package]]
name = "digest"
version = "0.10.7"
@@ -249,6 +297,12 @@ dependencies = [
"syn",
]
+[[package]]
+name = "either"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
+
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -736,6 +790,16 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "inout"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
+dependencies = [
+ "block-padding",
+ "generic-array",
+]
+
[[package]]
name = "ipnet"
version = "2.12.0"
@@ -752,6 +816,15 @@ dependencies = [
"serde",
]
+[[package]]
+name = "itertools"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
+dependencies = [
+ "either",
+]
+
[[package]]
name = "itoa"
version = "1.0.18"
@@ -770,6 +843,21 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "jsonwebtoken"
+version = "9.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
+dependencies = [
+ "base64",
+ "js-sys",
+ "pem",
+ "ring",
+ "serde",
+ "serde_json",
+ "simple_asn1",
+]
+
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -862,6 +950,31 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "num-bigint"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-conv"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
+
+[[package]]
+name = "num-integer"
+version = "0.1.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
+dependencies = [
+ "num-traits",
+]
+
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -891,16 +1004,21 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
name = "openab-gateway"
version = "0.1.0"
dependencies = [
+ "aes",
"anyhow",
"axum",
"base64",
+ "cbc",
"chrono",
"futures-util",
"hmac",
+ "jsonwebtoken",
+ "prost",
"reqwest",
"serde",
"serde_json",
"sha2",
+ "subtle",
"tokio",
"tokio-tungstenite 0.21.0",
"tracing",
@@ -932,6 +1050,16 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "pem"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
+dependencies = [
+ "base64",
+ "serde_core",
+]
+
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -953,6 +1081,12 @@ dependencies = [
"zerovec",
]
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@@ -981,6 +1115,29 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "prost"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5"
+dependencies = [
+ "bytes",
+ "prost-derive",
+]
+
+[[package]]
+name = "prost-derive"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
+dependencies = [
+ "anyhow",
+ "itertools",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "quinn"
version = "0.11.9"
@@ -1409,6 +1566,18 @@ dependencies = [
"libc",
]
+[[package]]
+name = "simple_asn1"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
+dependencies = [
+ "num-bigint",
+ "num-traits",
+ "thiserror 2.0.18",
+ "time",
+]
+
[[package]]
name = "slab"
version = "0.4.12"
@@ -1523,6 +1692,37 @@ dependencies = [
"cfg-if",
]
+[[package]]
+name = "time"
+version = "0.3.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
+dependencies = [
+ "deranged",
+ "itoa",
+ "num-conv",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
+
+[[package]]
+name = "time-macros"
+version = "0.2.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
[[package]]
name = "tinystr"
version = "0.8.3"
diff --git a/gateway/Cargo.toml b/gateway/Cargo.toml
index 98291ed9..0d10b846 100644
--- a/gateway/Cargo.toml
+++ b/gateway/Cargo.toml
@@ -20,6 +20,10 @@ hmac = "0.12"
sha2 = "0.10"
base64 = "0.22"
jsonwebtoken = "9"
+aes = "0.8"
+cbc = "0.1"
+prost = "0.13"
+subtle = "2"
[dev-dependencies]
wiremock = "0.6"
diff --git a/gateway/README.md b/gateway/README.md
index 2a06e49d..bfce41b2 100644
--- a/gateway/README.md
+++ b/gateway/README.md
@@ -49,6 +49,18 @@ url = "ws://gateway:8080/ws"
| `TELEGRAM_WEBHOOK_PATH` | `/webhook/telegram` | Webhook endpoint path |
| `LINE_CHANNEL_SECRET` | (optional) | LINE channel secret for webhook HMAC signature verification |
| `LINE_CHANNEL_ACCESS_TOKEN` | (optional) | LINE channel access token for Reply/Push API |
+| `FEISHU_APP_ID` | (optional) | Feishu/Lark App ID — enables feishu adapter |
+| `FEISHU_APP_SECRET` | (optional) | Feishu/Lark App Secret |
+| `FEISHU_DOMAIN` | `feishu` | `feishu` (China) or `lark` (international) |
+| `FEISHU_CONNECTION_MODE` | `websocket` | `websocket` (recommended) or `webhook` |
+| `FEISHU_WEBHOOK_PATH` | `/webhook/feishu` | Webhook endpoint path |
+| `FEISHU_VERIFICATION_TOKEN` | (optional) | Webhook verification token |
+| `FEISHU_ENCRYPT_KEY` | (optional) | Webhook encrypt key for AES-256-CBC |
+| `FEISHU_ALLOWED_GROUPS` | (optional) | Comma-separated chat_id allowlist |
+| `FEISHU_ALLOWED_USERS` | (optional) | Comma-separated open_id allowlist |
+| `FEISHU_REQUIRE_MENTION` | `true` | Require @mention in groups |
+| `FEISHU_DEDUPE_TTL_SECS` | `300` | Event deduplication cache TTL (seconds) |
+| `FEISHU_MESSAGE_LIMIT` | `4000` | Max message length before auto-splitting (bytes) |
### Endpoints
@@ -56,6 +68,7 @@ url = "ws://gateway:8080/ws"
|---|---|
| `POST /webhook/telegram` | Telegram webhook receiver |
| `POST /webhook/line` | LINE webhook receiver |
+| `POST /webhook/feishu` | Feishu webhook receiver (when `FEISHU_CONNECTION_MODE=webhook`) |
| `GET /ws` | WebSocket server (OAB connects here) |
| `GET /health` | Health check |
diff --git a/gateway/src/adapters/feishu.rs b/gateway/src/adapters/feishu.rs
new file mode 100644
index 00000000..a2c54f89
--- /dev/null
+++ b/gateway/src/adapters/feishu.rs
@@ -0,0 +1,1847 @@
+use crate::schema::*;
+use axum::extract::State;
+use prost::Message as ProstMessage;
+use serde::Deserialize;
+use std::collections::HashMap;
+use std::sync::Arc;
+use std::time::Instant;
+use tokio::sync::RwLock;
+use tracing::{info, warn};
+
+/// Timing-safe string comparison to prevent side-channel attacks on tokens.
+fn constant_time_eq(a: &str, b: &str) -> bool {
+ use subtle::ConstantTimeEq;
+ if a.len() != b.len() {
+ return false;
+ }
+ a.as_bytes().ct_eq(b.as_bytes()).into()
+}
+
+// ---------------------------------------------------------------------------
+// Feishu WebSocket protobuf frame (pbbp2.Frame)
+// ---------------------------------------------------------------------------
+
+#[derive(Clone, PartialEq, ProstMessage)]
+pub struct WsFrame {
+ #[prost(uint64, tag = "1")]
+ pub seq_id: u64,
+ #[prost(uint64, tag = "2")]
+ pub log_id: u64,
+ #[prost(int32, tag = "3")]
+ pub service: i32,
+ #[prost(int32, tag = "4")]
+ pub method: i32,
+ #[prost(message, repeated, tag = "5")]
+ pub headers: Vec,
+ #[prost(string, optional, tag = "6")]
+ pub payload_encoding: Option,
+ #[prost(string, optional, tag = "7")]
+ pub payload_type: Option,
+ #[prost(bytes = "vec", optional, tag = "8")]
+ pub payload: Option>,
+ #[prost(string, optional, tag = "9")]
+ pub log_id_new: Option,
+}
+
+#[derive(Clone, PartialEq, ProstMessage)]
+pub struct WsHeader {
+ #[prost(string, tag = "1")]
+ pub key: String,
+ #[prost(string, tag = "2")]
+ pub value: String,
+}
+
+// ---------------------------------------------------------------------------
+// Configuration
+// ---------------------------------------------------------------------------
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum ConnectionMode {
+ Websocket,
+ Webhook,
+}
+
+#[derive(Debug, Clone)]
+pub struct FeishuConfig {
+ pub app_id: String,
+ pub app_secret: String,
+ pub domain: String,
+ pub connection_mode: ConnectionMode,
+ pub webhook_path: String,
+ pub verification_token: Option,
+ pub encrypt_key: Option,
+ pub allowed_groups: Vec,
+ pub allowed_users: Vec,
+ pub require_mention: bool,
+ pub dedupe_ttl_secs: u64,
+ pub message_limit: usize,
+}
+
+impl FeishuConfig {
+ /// Build config from environment variables. Returns None if FEISHU_APP_ID
+ /// is not set (adapter disabled).
+ pub fn from_env() -> Option {
+ let app_id = std::env::var("FEISHU_APP_ID").ok()?;
+ let app_secret = std::env::var("FEISHU_APP_SECRET").ok().unwrap_or_default();
+ if app_secret.is_empty() {
+ warn!("FEISHU_APP_ID set but FEISHU_APP_SECRET is empty");
+ return None;
+ }
+ let domain = std::env::var("FEISHU_DOMAIN").unwrap_or_else(|_| "feishu".into());
+ let connection_mode = match std::env::var("FEISHU_CONNECTION_MODE")
+ .unwrap_or_else(|_| "websocket".into())
+ .to_lowercase()
+ .as_str()
+ {
+ "webhook" => ConnectionMode::Webhook,
+ _ => ConnectionMode::Websocket,
+ };
+ let webhook_path = std::env::var("FEISHU_WEBHOOK_PATH")
+ .unwrap_or_else(|_| "/webhook/feishu".into());
+ let verification_token = std::env::var("FEISHU_VERIFICATION_TOKEN").ok();
+ let encrypt_key = std::env::var("FEISHU_ENCRYPT_KEY").ok();
+ let allowed_groups = parse_csv("FEISHU_ALLOWED_GROUPS");
+ let allowed_users = parse_csv("FEISHU_ALLOWED_USERS");
+ let require_mention = std::env::var("FEISHU_REQUIRE_MENTION")
+ .map(|v| v != "false" && v != "0")
+ .unwrap_or(true);
+ let dedupe_ttl_secs = std::env::var("FEISHU_DEDUPE_TTL_SECS")
+ .ok()
+ .and_then(|v| v.parse().ok())
+ .unwrap_or(300);
+ let message_limit = std::env::var("FEISHU_MESSAGE_LIMIT")
+ .ok()
+ .and_then(|v| v.parse().ok())
+ .unwrap_or(4000);
+
+ Some(Self {
+ app_id,
+ app_secret,
+ domain,
+ connection_mode,
+ webhook_path,
+ verification_token,
+ encrypt_key,
+ allowed_groups,
+ allowed_users,
+ require_mention,
+ dedupe_ttl_secs,
+ message_limit,
+ })
+ }
+
+ /// API base URL for the configured domain.
+ pub fn api_base(&self) -> String {
+ if self.domain == "lark" {
+ "https://open.larksuite.com".into()
+ } else {
+ "https://open.feishu.cn".into()
+ }
+ }
+}
+
+fn parse_csv(var: &str) -> Vec {
+ std::env::var(var)
+ .unwrap_or_default()
+ .split(',')
+ .map(|s| s.trim().to_string())
+ .filter(|s| !s.is_empty())
+ .collect()
+}
+
+// ---------------------------------------------------------------------------
+// Feishu event types (im.message.receive_v1)
+// ---------------------------------------------------------------------------
+
+mod event_types {
+ use super::*;
+
+ #[derive(Debug, Deserialize)]
+ pub struct FeishuEventEnvelope {
+ pub header: Option,
+ pub event: Option,
+ pub challenge: Option,
+ #[serde(rename = "type")]
+ pub event_type_field: Option,
+ }
+
+ #[derive(Debug, Deserialize)]
+ pub struct FeishuEventHeader {
+ pub event_id: Option,
+ pub event_type: Option,
+ }
+
+ #[derive(Debug, Deserialize)]
+ pub struct FeishuEventBody {
+ pub sender: Option,
+ pub message: Option,
+ }
+
+ #[derive(Debug, Deserialize)]
+ pub struct FeishuSender {
+ pub sender_id: Option,
+ pub sender_type: Option,
+ }
+
+ #[derive(Debug, Deserialize)]
+ pub struct FeishuSenderId {
+ pub open_id: Option,
+ }
+
+ #[derive(Debug, Deserialize)]
+ pub struct FeishuMessage {
+ pub message_id: Option,
+ pub chat_id: Option,
+ pub chat_type: Option,
+ pub message_type: Option,
+ pub content: Option,
+ pub mentions: Option>,
+ pub root_id: Option,
+ pub parent_id: Option,
+ }
+
+ #[derive(Debug, Deserialize)]
+ pub struct FeishuMention {
+ pub key: Option,
+ pub id: Option,
+ pub name: Option,
+ }
+
+ #[derive(Debug, Deserialize)]
+ pub struct FeishuMentionId {
+ pub open_id: Option,
+ }
+
+ /// Parse a feishu im.message.receive_v1 event into a GatewayEvent.
+ /// Returns None if the event should be skipped (non-text, bot message, etc).
+ pub fn parse_message_event(
+ envelope: &FeishuEventEnvelope,
+ bot_open_id: Option<&str>,
+ config: &FeishuConfig,
+ ) -> Option {
+ let _header = envelope.header.as_ref()?;
+ let event = envelope.event.as_ref()?;
+ let msg = event.message.as_ref()?;
+ let sender = event.sender.as_ref()?;
+
+ if msg.message_type.as_deref() != Some("text") {
+ return None;
+ }
+ // Skip bot messages (sender_type: "bot" or "app")
+ if matches!(sender.sender_type.as_deref(), Some("bot") | Some("app")) {
+ return None;
+ }
+
+ let sender_open_id = sender.sender_id.as_ref()?.open_id.as_deref()?;
+ if let Some(bot_id) = bot_open_id {
+ if sender_open_id == bot_id {
+ return None;
+ }
+ }
+
+ // User allowlist: if configured, only allow listed users
+ if !config.allowed_users.is_empty()
+ && !config.allowed_users.iter().any(|u| u == sender_open_id)
+ {
+ return None;
+ }
+
+ let chat_id = msg.chat_id.as_deref()?;
+ let message_id = msg.message_id.as_deref()?;
+
+ // Group allowlist: if configured, only allow listed groups
+ let is_group = msg.chat_type.as_deref() != Some("p2p");
+ if is_group
+ && !config.allowed_groups.is_empty()
+ && !config.allowed_groups.iter().any(|g| g == chat_id)
+ {
+ return None;
+ }
+
+ let content_json: serde_json::Value = msg.content.as_deref()
+ .and_then(|s| serde_json::from_str(s).ok())?;
+ let raw_text = content_json.get("text")?.as_str()?;
+ if raw_text.trim().is_empty() {
+ return None;
+ }
+
+ let (clean_text, mention_ids) = extract_mentions(
+ raw_text,
+ msg.mentions.as_deref().unwrap_or(&[]),
+ bot_open_id,
+ );
+ if clean_text.trim().is_empty() {
+ return None;
+ }
+
+ let channel_type = match msg.chat_type.as_deref() {
+ Some("p2p") => "direct",
+ _ => "group",
+ };
+
+ // Gateway-side mention gating: in groups, skip if require_mention
+ // is true and bot is not mentioned
+ if channel_type == "group" && config.require_mention {
+ if let Some(bot_id) = bot_open_id {
+ let bot_mentioned = mention_ids.iter().any(|id| id == bot_id);
+ if !bot_mentioned {
+ return None;
+ }
+ }
+ }
+
+ let thread_id = msg.root_id.clone().or_else(|| msg.parent_id.clone());
+
+ Some(GatewayEvent::new(
+ "feishu",
+ ChannelInfo {
+ id: chat_id.to_string(),
+ channel_type: channel_type.to_string(),
+ thread_id,
+ },
+ SenderInfo {
+ id: sender_open_id.to_string(),
+ name: sender_open_id.to_string(),
+ display_name: sender_open_id.to_string(),
+ is_bot: false,
+ },
+ clean_text.trim(),
+ message_id,
+ mention_ids,
+ ))
+ }
+
+ fn extract_mentions(
+ raw_text: &str,
+ mentions: &[FeishuMention],
+ bot_open_id: Option<&str>,
+ ) -> (String, Vec) {
+ let mut text = raw_text.to_string();
+ let mut ids = Vec::new();
+ for m in mentions {
+ let open_id = m.id.as_ref().and_then(|id| id.open_id.as_deref());
+ if let Some(oid) = open_id {
+ ids.push(oid.to_string());
+ if let Some(key) = m.key.as_deref() {
+ if bot_open_id == Some(oid) {
+ text = text.replacen(key, "", 1);
+ }
+ }
+ }
+ }
+ (text, ids)
+ }
+}
+
+pub use event_types::*;
+
+// ---------------------------------------------------------------------------
+// Deduplication
+// ---------------------------------------------------------------------------
+
+pub struct DedupeCache {
+ seen: std::sync::Mutex>,
+ ttl_secs: u64,
+ max_size: usize,
+}
+
+impl DedupeCache {
+ pub fn new(ttl_secs: u64) -> Self {
+ Self {
+ seen: std::sync::Mutex::new(HashMap::new()),
+ ttl_secs,
+ max_size: 10_000,
+ }
+ }
+
+ /// Returns true if this id was already seen (duplicate).
+ pub fn is_duplicate(&self, id: &str) -> bool {
+ let mut map = self.seen.lock().unwrap_or_else(|e| e.into_inner());
+ // Lazy sweep
+ if map.len() >= self.max_size {
+ map.retain(|_, ts| ts.elapsed().as_secs() < self.ttl_secs);
+ }
+ if let Some(ts) = map.get(id) {
+ if ts.elapsed().as_secs() < self.ttl_secs {
+ return true;
+ }
+ }
+ map.insert(id.to_string(), Instant::now());
+ false
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Token cache
+// ---------------------------------------------------------------------------
+
+pub struct FeishuTokenCache {
+ /// (token, created_at, ttl_secs)
+ token: RwLock