Skip to content

Commit 489ff92

Browse files
m1hengclaude
andcommitted
feat: add Feishu (Lark) adapter for chat SDK
Implements a full Feishu/Lark adapter with: - Webhook handling (event v2.0, URL verification, AES-256-CBC decryption) - Message send/reply/edit/delete using Feishu Open API v1 - Rich text (post) and interactive card support - Reaction emoji mapping (verified against official Feishu emoji docs) - Thread ID encoding (feishu:{chatId}:{rootId}:{dm}) - Markdown ↔ Feishu post format conversion via mdast - Auto-refreshing tenant_access_token management Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0f85031 commit 489ff92

File tree

17 files changed

+2321
-10
lines changed

17 files changed

+2321
-10
lines changed

.changeset/brave-dragons-fly.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@chat-adapter/feishu": minor
3+
---
4+
5+
Add Feishu (Lark) adapter for chat SDK
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
{
2+
"name": "@chat-adapter/feishu",
3+
"version": "4.14.0",
4+
"description": "Feishu (Lark) adapter for chat",
5+
"type": "module",
6+
"main": "./dist/index.js",
7+
"module": "./dist/index.js",
8+
"types": "./dist/index.d.ts",
9+
"exports": {
10+
".": {
11+
"types": "./dist/index.d.ts",
12+
"import": "./dist/index.js"
13+
}
14+
},
15+
"files": [
16+
"dist"
17+
],
18+
"scripts": {
19+
"build": "tsup",
20+
"dev": "tsup --watch",
21+
"test": "vitest run --coverage",
22+
"test:watch": "vitest",
23+
"typecheck": "tsc --noEmit",
24+
"clean": "rm -rf dist"
25+
},
26+
"dependencies": {
27+
"@chat-adapter/shared": "workspace:*",
28+
"chat": "workspace:*"
29+
},
30+
"devDependencies": {
31+
"@types/node": "^22.10.2",
32+
"tsup": "^8.3.5",
33+
"typescript": "^5.7.2",
34+
"vitest": "^2.1.8"
35+
},
36+
"repository": {
37+
"type": "git",
38+
"url": "git+https://github.com/vercel/chat.git",
39+
"directory": "packages/adapter-feishu"
40+
},
41+
"homepage": "https://github.com/vercel/chat#readme",
42+
"bugs": {
43+
"url": "https://github.com/vercel/chat/issues"
44+
},
45+
"publishConfig": {
46+
"access": "public"
47+
},
48+
"keywords": [
49+
"chat",
50+
"feishu",
51+
"lark",
52+
"bot",
53+
"adapter"
54+
],
55+
"license": "MIT"
56+
}

packages/adapter-feishu/src/api.ts

Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
1+
/**
2+
* Feishu API client with automatic token management.
3+
*
4+
* Handles tenant_access_token acquisition, caching, and auto-refresh.
5+
* All Feishu Open API HTTP calls are centralized here (SSOT).
6+
*/
7+
8+
import {
9+
AdapterRateLimitError,
10+
AuthenticationError,
11+
NetworkError,
12+
} from "@chat-adapter/shared";
13+
import type { Logger } from "chat";
14+
15+
import type {
16+
FeishuApiResponse,
17+
FeishuChatInfo,
18+
FeishuMessageListResponse,
19+
FeishuMessageResponse,
20+
FeishuReactionListResponse,
21+
FeishuReactionResponse,
22+
FeishuTokenResponse,
23+
} from "./types";
24+
25+
const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; // Refresh 5 minutes before expiry
26+
27+
export class FeishuApiClient {
28+
private readonly apiBaseUrl: string;
29+
private readonly appId: string;
30+
private readonly appSecret: string;
31+
private readonly logger: Logger;
32+
33+
private tenantAccessToken: string | null = null;
34+
private tokenExpiresAt = 0;
35+
private tokenRefreshPromise: Promise<void> | null = null;
36+
37+
constructor(config: {
38+
apiBaseUrl: string;
39+
appId: string;
40+
appSecret: string;
41+
logger: Logger;
42+
}) {
43+
this.appId = config.appId;
44+
this.appSecret = config.appSecret;
45+
this.apiBaseUrl = config.apiBaseUrl;
46+
this.logger = config.logger;
47+
}
48+
49+
// ===========================================================================
50+
// Token Management
51+
// ===========================================================================
52+
53+
private async ensureToken(): Promise<string> {
54+
if (
55+
this.tenantAccessToken &&
56+
Date.now() < this.tokenExpiresAt - TOKEN_REFRESH_BUFFER_MS
57+
) {
58+
return this.tenantAccessToken;
59+
}
60+
61+
// Prevent concurrent token refreshes
62+
if (!this.tokenRefreshPromise) {
63+
this.tokenRefreshPromise = this.refreshToken();
64+
}
65+
66+
try {
67+
await this.tokenRefreshPromise;
68+
} finally {
69+
this.tokenRefreshPromise = null;
70+
}
71+
72+
return this.tenantAccessToken as string;
73+
}
74+
75+
private async refreshToken(): Promise<void> {
76+
const url = `${this.apiBaseUrl}/auth/v3/tenant_access_token/internal`;
77+
const response = await fetch(url, {
78+
method: "POST",
79+
headers: { "Content-Type": "application/json; charset=utf-8" },
80+
body: JSON.stringify({
81+
app_id: this.appId,
82+
app_secret: this.appSecret,
83+
}),
84+
});
85+
86+
if (!response.ok) {
87+
throw new AuthenticationError(
88+
"feishu",
89+
`Token request failed: ${response.status} ${response.statusText}`
90+
);
91+
}
92+
93+
const data = (await response.json()) as FeishuTokenResponse;
94+
if (data.code !== 0) {
95+
throw new AuthenticationError(
96+
"feishu",
97+
`Token request error: ${data.msg} (code: ${data.code})`
98+
);
99+
}
100+
101+
this.tenantAccessToken = data.tenant_access_token;
102+
this.tokenExpiresAt = Date.now() + data.expire * 1000;
103+
this.logger.debug("Feishu token refreshed", {
104+
expiresIn: data.expire,
105+
});
106+
}
107+
108+
// ===========================================================================
109+
// Generic Request
110+
// ===========================================================================
111+
112+
async request<T>(
113+
method: string,
114+
path: string,
115+
body?: unknown,
116+
retried = false
117+
): Promise<T> {
118+
const token = await this.ensureToken();
119+
const url = `${this.apiBaseUrl}${path}`;
120+
121+
let response: Response;
122+
try {
123+
response = await fetch(url, {
124+
method,
125+
headers: {
126+
Authorization: `Bearer ${token}`,
127+
"Content-Type": "application/json; charset=utf-8",
128+
},
129+
body: body ? JSON.stringify(body) : undefined,
130+
});
131+
} catch (error) {
132+
throw new NetworkError(
133+
"feishu",
134+
`Request to ${method} ${path} failed`,
135+
error instanceof Error ? error : undefined
136+
);
137+
}
138+
139+
// Rate limited
140+
if (response.status === 429) {
141+
const retryAfter = response.headers.get("retry-after");
142+
throw new AdapterRateLimitError(
143+
"feishu",
144+
retryAfter ? Number.parseInt(retryAfter, 10) : undefined
145+
);
146+
}
147+
148+
// Auth expired — retry once with fresh token
149+
if (response.status === 401 && !retried) {
150+
this.tenantAccessToken = null;
151+
this.tokenExpiresAt = 0;
152+
return this.request<T>(method, path, body, true);
153+
}
154+
155+
const data = (await response.json()) as FeishuApiResponse<T>;
156+
157+
// Feishu returns 200 with error codes in body
158+
if (data.code !== 0) {
159+
// Rate limit error code
160+
if (data.code === 99991400) {
161+
throw new AdapterRateLimitError("feishu");
162+
}
163+
// Auth error codes
164+
if (data.code === 99991661 || data.code === 99991663) {
165+
if (!retried) {
166+
this.tenantAccessToken = null;
167+
this.tokenExpiresAt = 0;
168+
return this.request<T>(method, path, body, true);
169+
}
170+
throw new AuthenticationError(
171+
"feishu",
172+
`${data.msg} (code: ${data.code})`
173+
);
174+
}
175+
throw new NetworkError(
176+
"feishu",
177+
`Feishu API error: ${data.msg} (code: ${data.code})`
178+
);
179+
}
180+
181+
return data.data as T;
182+
}
183+
184+
// ===========================================================================
185+
// Message APIs
186+
// ===========================================================================
187+
188+
async sendMessage(
189+
receiveId: string,
190+
msgType: string,
191+
content: string,
192+
receiveIdType = "chat_id"
193+
): Promise<FeishuMessageResponse> {
194+
return this.request<FeishuMessageResponse>(
195+
"POST",
196+
`/im/v1/messages?receive_id_type=${receiveIdType}`,
197+
{ receive_id: receiveId, msg_type: msgType, content }
198+
);
199+
}
200+
201+
async replyMessage(
202+
messageId: string,
203+
msgType: string,
204+
content: string
205+
): Promise<FeishuMessageResponse> {
206+
return this.request<FeishuMessageResponse>(
207+
"POST",
208+
`/im/v1/messages/${messageId}/reply`,
209+
{ msg_type: msgType, content }
210+
);
211+
}
212+
213+
async editMessage(
214+
messageId: string,
215+
msgType: string,
216+
content: string
217+
): Promise<FeishuMessageResponse> {
218+
return this.request<FeishuMessageResponse>(
219+
"PUT",
220+
`/im/v1/messages/${messageId}`,
221+
{ msg_type: msgType, content }
222+
);
223+
}
224+
225+
async patchMessageCard(
226+
messageId: string,
227+
content: string
228+
): Promise<FeishuMessageResponse> {
229+
return this.request<FeishuMessageResponse>(
230+
"PATCH",
231+
`/im/v1/messages/${messageId}`,
232+
{ content }
233+
);
234+
}
235+
236+
async deleteMessage(messageId: string): Promise<void> {
237+
await this.request<void>("DELETE", `/im/v1/messages/${messageId}`);
238+
}
239+
240+
async getMessage(messageId: string): Promise<FeishuMessageResponse> {
241+
return this.request<FeishuMessageResponse>(
242+
"GET",
243+
`/im/v1/messages/${messageId}`
244+
);
245+
}
246+
247+
async listMessages(
248+
containerId: string,
249+
options?: {
250+
containerIdType?: string;
251+
pageSize?: number;
252+
pageToken?: string;
253+
startTime?: string;
254+
endTime?: string;
255+
sortType?: "ByCreateTimeAsc" | "ByCreateTimeDesc";
256+
}
257+
): Promise<FeishuMessageListResponse> {
258+
const params = new URLSearchParams({
259+
container_id_type: options?.containerIdType ?? "chat",
260+
container_id: containerId,
261+
});
262+
if (options?.pageSize) {
263+
params.set("page_size", String(options.pageSize));
264+
}
265+
if (options?.pageToken) {
266+
params.set("page_token", options.pageToken);
267+
}
268+
if (options?.startTime) {
269+
params.set("start_time", options.startTime);
270+
}
271+
if (options?.endTime) {
272+
params.set("end_time", options.endTime);
273+
}
274+
if (options?.sortType) {
275+
params.set("sort_type", options.sortType);
276+
}
277+
return this.request<FeishuMessageListResponse>(
278+
"GET",
279+
`/im/v1/messages?${params.toString()}`
280+
);
281+
}
282+
283+
// ===========================================================================
284+
// Chat APIs
285+
// ===========================================================================
286+
287+
async getChatInfo(chatId: string): Promise<FeishuChatInfo> {
288+
return this.request<FeishuChatInfo>("GET", `/im/v1/chats/${chatId}`);
289+
}
290+
291+
// ===========================================================================
292+
// Reaction APIs
293+
// ===========================================================================
294+
295+
async addReaction(
296+
messageId: string,
297+
emojiType: string
298+
): Promise<FeishuReactionResponse> {
299+
return this.request<FeishuReactionResponse>(
300+
"POST",
301+
`/im/v1/messages/${messageId}/reactions`,
302+
{ reaction_type: { emoji_type: emojiType } }
303+
);
304+
}
305+
306+
async removeReaction(messageId: string, reactionId: string): Promise<void> {
307+
await this.request<void>(
308+
"DELETE",
309+
`/im/v1/messages/${messageId}/reactions/${reactionId}`
310+
);
311+
}
312+
313+
async listReactions(
314+
messageId: string,
315+
emojiType?: string
316+
): Promise<FeishuReactionListResponse> {
317+
const params = new URLSearchParams();
318+
if (emojiType) {
319+
params.set("reaction_type", emojiType);
320+
}
321+
const query = params.toString();
322+
const path = `/im/v1/messages/${messageId}/reactions${query ? `?${query}` : ""}`;
323+
return this.request<FeishuReactionListResponse>("GET", path);
324+
}
325+
}

0 commit comments

Comments
 (0)