|
| 1 | +--- |
| 2 | +title: Lark / Feishu |
| 3 | +description: Chat SDK adapter for Lark / Feishu. WebSocket long-connection event subscription, native cardkit typewriter streaming, interactive cards, and reactions. |
| 4 | +packageName: "@larksuite/vercel-chat-adapter" |
| 5 | +slug: lark |
| 6 | +type: platform |
| 7 | +tagline: Chat SDK adapter for Lark / Feishu, built on the official @larksuiteoapi/node-sdk. WebSocket long-connection event delivery, native cardkit streaming, interactive cards, and reactions. |
| 8 | +community: true |
| 9 | +vendorOfficial: true |
| 10 | +author: Lark / Feishu |
| 11 | +mdxBody: true |
| 12 | +features: |
| 13 | + postMessage: yes |
| 14 | + editMessage: yes |
| 15 | + deleteMessage: yes |
| 16 | + fileUploads: |
| 17 | + status: partial |
| 18 | + label: Via SDK channel.send |
| 19 | + streaming: |
| 20 | + status: yes |
| 21 | + label: Native cardkit typewriter |
| 22 | + scheduledMessages: no |
| 23 | + cardFormat: |
| 24 | + status: yes |
| 25 | + label: Lark interactive cards |
| 26 | + buttons: yes |
| 27 | + linkButtons: yes |
| 28 | + selectMenus: |
| 29 | + status: yes |
| 30 | + label: Card select / overflow |
| 31 | + tables: |
| 32 | + status: partial |
| 33 | + label: Markdown tables |
| 34 | + fields: |
| 35 | + status: yes |
| 36 | + label: Card section fields |
| 37 | + imagesInCards: |
| 38 | + status: yes |
| 39 | + label: ImageElement |
| 40 | + modals: no |
| 41 | + slashCommands: no |
| 42 | + mentions: yes |
| 43 | + addReactions: yes |
| 44 | + removeReactions: yes |
| 45 | + typingIndicator: no |
| 46 | + directMessages: yes |
| 47 | + ephemeralMessages: no |
| 48 | + userLookup: no |
| 49 | + customApiEndpoint: no |
| 50 | + fetchMessages: yes |
| 51 | + fetchSingleMessage: yes |
| 52 | + fetchThreadInfo: yes |
| 53 | + fetchChannelMessages: yes |
| 54 | + listThreads: |
| 55 | + status: yes |
| 56 | + label: Client-side grouping |
| 57 | + fetchChannelInfo: yes |
| 58 | + postChannelMessage: no |
| 59 | +--- |
| 60 | + |
| 61 | +## Install |
| 62 | + |
| 63 | +<PackageInstall package="@larksuite/vercel-chat-adapter" /> |
| 64 | + |
| 65 | +## Quick start |
| 66 | + |
| 67 | +```typescript title="lib/bot.ts" lineNumbers |
| 68 | +import { Chat } from "chat"; |
| 69 | +import { createMemoryState } from "@chat-adapter/state-memory"; |
| 70 | +import { createLarkAdapter } from "@larksuite/vercel-chat-adapter"; |
| 71 | + |
| 72 | +const bot = new Chat({ |
| 73 | + userName: "mybot", |
| 74 | + adapters: { |
| 75 | + lark: createLarkAdapter(), |
| 76 | + }, |
| 77 | + state: createMemoryState(), |
| 78 | +}); |
| 79 | + |
| 80 | +bot.onNewMention(async (thread, message) => { |
| 81 | + await thread.subscribe(); |
| 82 | + await thread.post(`You said: ${message.text}`); |
| 83 | +}); |
| 84 | + |
| 85 | +bot.onDirectMessage(async (thread, message) => { |
| 86 | + await thread.post(`Got your DM: ${message.text}`); |
| 87 | +}); |
| 88 | + |
| 89 | +await bot.initialize(); |
| 90 | +``` |
| 91 | + |
| 92 | +`bot.initialize()` opens the Lark WebSocket connection and keeps it alive until `bot.shutdown()` is called. The process stays alive as long as the WS is open, so no separate server is needed in a long-running environment. |
| 93 | + |
| 94 | +The adapter auto-detects `LARK_APP_ID`, `LARK_APP_SECRET`, and `LARK_BOT_USERNAME` from environment variables when no explicit config is passed. |
| 95 | + |
| 96 | +## Creating a Lark app |
| 97 | + |
| 98 | +### Option A — scan-to-create (recommended) |
| 99 | + |
| 100 | +`registerLarkApp` drives Lark's official scan-to-create flow: the SDK generates a one-time URL, you render it as a QR code, the user scans with the Lark mobile app and approves, and you get back `client_id` / `client_secret` — with the permissions and event subscriptions this adapter needs already configured. |
| 101 | + |
| 102 | +```typescript title="scripts/register-app.ts" lineNumbers |
| 103 | +import { |
| 104 | + registerLarkApp, |
| 105 | + createLarkAdapter, |
| 106 | +} from "@larksuite/vercel-chat-adapter"; |
| 107 | +import qrcode from "qrcode-terminal"; // pnpm add -D qrcode-terminal |
| 108 | + |
| 109 | +const { client_id, client_secret } = await registerLarkApp({ |
| 110 | + onQRCodeReady: ({ url }) => { |
| 111 | + console.log("Scan this QR with your Lark mobile app:"); |
| 112 | + qrcode.generate(url, { small: true }); |
| 113 | + }, |
| 114 | + onStatusChange: ({ status }) => console.log("status:", status), |
| 115 | +}); |
| 116 | + |
| 117 | +console.log("LARK_APP_ID=", client_id); |
| 118 | +console.log("LARK_APP_SECRET=", client_secret); |
| 119 | +``` |
| 120 | + |
| 121 | +You only need to run this once. Persist the returned credentials and feed them back via `LARK_APP_ID` / `LARK_APP_SECRET` in subsequent runs. |
| 122 | + |
| 123 | +### Option B — create via developer console |
| 124 | + |
| 125 | +Go to the developer console and create an **Intelligent Agent** app: |
| 126 | + |
| 127 | +- Lark: [open.larksuite.com/app](https://open.larksuite.com/app) |
| 128 | +- Feishu: [open.feishu.cn/app](https://open.feishu.cn/app) |
| 129 | + |
| 130 | +Grab the app's `client_id` and `client_secret` and pass them as `appId` / `appSecret` (or set `LARK_APP_ID` / `LARK_APP_SECRET`). |
| 131 | + |
| 132 | +## Configuration |
| 133 | + |
| 134 | +<TypeTable |
| 135 | + type={{ |
| 136 | + appId: { |
| 137 | + type: "string", |
| 138 | + description: |
| 139 | + "Lark app ID. Auto-detected from `LARK_APP_ID` when omitted.", |
| 140 | + }, |
| 141 | + appSecret: { |
| 142 | + type: "string", |
| 143 | + description: |
| 144 | + "Lark app secret. Auto-detected from `LARK_APP_SECRET` when omitted.", |
| 145 | + }, |
| 146 | + userName: { |
| 147 | + type: "string", |
| 148 | + description: |
| 149 | + "Bot display name. Defaults to `LARK_BOT_USERNAME` or `\"bot\"`.", |
| 150 | + }, |
| 151 | + logger: { |
| 152 | + type: "Logger", |
| 153 | + description: |
| 154 | + "Chat SDK-compatible logger. Defaults to `ConsoleLogger(\"info\", \"lark\")`.", |
| 155 | + }, |
| 156 | + }} |
| 157 | +/> |
| 158 | + |
| 159 | +### Environment variables |
| 160 | + |
| 161 | +| Variable | Description | |
| 162 | +| --- | --- | |
| 163 | +| `LARK_APP_ID` | Lark app ID. Overridden by `config.appId`. | |
| 164 | +| `LARK_APP_SECRET` | Lark app secret. Overridden by `config.appSecret`. | |
| 165 | +| `LARK_BOT_USERNAME` | Bot display name. Overridden by `config.userName`. | |
| 166 | + |
| 167 | +## Transport |
| 168 | + |
| 169 | +WebSocket only. `handleWebhook()` returns HTTP 501. Webhook transport is on the roadmap; for now, Lark's "long-connection" mode is the intended delivery channel and works in production. |
| 170 | + |
| 171 | +This means you can run a Lark bot without exposing an HTTP endpoint — the SDK initiates an outbound WebSocket to Lark's servers and receives events through it. Long-running environments (a Node process, a worker, a VM) are the natural fit. Serverless platforms that recycle the process on every request won't keep the connection alive. |
| 172 | + |
| 173 | +## Streaming |
| 174 | + |
| 175 | +`bot.adapter.stream()` uses Lark's native **cardkit typewriter** API. Chunks emitted from your stream handler are appended directly inside a single card message; no `post + edit` polling is involved. |
| 176 | + |
| 177 | +```typescript |
| 178 | +await thread.stream(async (controller) => { |
| 179 | + for await (const chunk of llmStream) { |
| 180 | + controller.write(chunk); |
| 181 | + } |
| 182 | +}); |
| 183 | +``` |
| 184 | + |
| 185 | +If the thread has a `rootId`, the streamed reply is posted as a thread reply (via the SDK's `replyTo` parameter). |
| 186 | + |
| 187 | +## ID encoding |
| 188 | + |
| 189 | +Lark thread IDs encode as `lark:{chatId}:{rootId}`: |
| 190 | + |
| 191 | +- `chatId` — `oc_*` for both group and p2p chats; `ou_*` for `openDM()` placeholders before the first message is delivered. |
| 192 | +- `rootId` — the message's `root_id` if it is a reply, otherwise its own `message_id` (the message is its own root). |
| 193 | + |
| 194 | +Lark's native `thread_id` (topic containers, `omt_*`) is **not** used as the `rootId` segment — it's a topic container ID, not a message ID, and can't be used as `replyTo` on the send API. |
| 195 | + |
| 196 | +### DM detection |
| 197 | + |
| 198 | +Lark's p2p chat IDs share the `oc_*` prefix with group chats, so `isDM()` relies on a chat-type cache populated by inbound events. The first DM after a process restart may route through `onNewMention` until the cache catches up. |
| 199 | + |
| 200 | +## Message history |
| 201 | + |
| 202 | +`fetchMessages` is implemented on top of `im.v1.messages.list` plus the SDK's `normalize()` — which covers Lark's 23 native message types and produces the same `NormalizedMessage` shape as live events. |
| 203 | + |
| 204 | +`listThreads` is derived client-side by grouping list results on `root_id`. Paginate carefully for very active chats; there is no native server-side list-threads API. |
| 205 | + |
| 206 | +`author.isMe` is resolved consistently for **historical** bot-authored messages, not just live events — the adapter maps the historical entry's `app_id` back to the bot's `open_id` via the SDK's `botIdentity` resolver. |
| 207 | + |
| 208 | +## Safety layer |
| 209 | + |
| 210 | +`LarkChannel`'s built-in safety features (stale-message detection, dedup, per-chat queue, text batch) are **disabled** by the adapter. Chat SDK's per-thread lock plus the state adapter handles message deduplication and subscription consistency — running the SDK's safety on top of Chat SDK's would double-process or drop messages. |
| 211 | + |
| 212 | +## Multi-app / multi-tenant |
| 213 | + |
| 214 | +Single-app only at present. A future version may support `setInstallation()` for multi-tenant fan-out — open an issue if you need it. |
| 215 | + |
| 216 | +## Limitations |
| 217 | + |
| 218 | +The following operations are not supported and throw `NotImplementedError`: |
| 219 | + |
| 220 | +- `handleWebhook` — returns HTTP 501; WebSocket transport only. |
| 221 | +- `startTyping` — Lark has no typing-indicator API. |
| 222 | +- `postChannelMessage` — Lark requires every message to belong to a chat (no channel-level top-level messages distinct from threads). |
| 223 | +- `scheduleMessage`, `openModal`, `postEphemeral` — not yet implemented. |
| 224 | + |
| 225 | +## Feature support |
| 226 | + |
| 227 | +<FeatureSupport /> |
0 commit comments