@tobeyoureyes/feishu 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,372 @@
1
+ /**
2
+ * Feishu WebSocket long connection for receiving events
3
+ *
4
+ * Uses the official @larksuiteoapi/node-sdk for WebSocket support.
5
+ * Benefits:
6
+ * - No public IP or domain needed
7
+ * - No firewall configuration
8
+ * - Authentication only at connection time
9
+ * - Plaintext messages after connection (no decryption needed)
10
+ */
11
+
12
+ import type { ResolvedFeishuAccount, FeishuMessageReceiveEvent } from "./types.js";
13
+
14
+ /** WebSocket connection state */
15
+ export type WSConnectionState = "disconnected" | "connecting" | "connected" | "reconnecting";
16
+
17
+ /** Event handler for incoming messages */
18
+ export type MessageHandler = (event: FeishuMessageReceiveEvent) => Promise<void>;
19
+
20
+ /** WebSocket client interface */
21
+ export interface FeishuWSClient {
22
+ state: WSConnectionState;
23
+ start(): Promise<void>;
24
+ stop(): Promise<void>;
25
+ onMessage(handler: MessageHandler): void;
26
+ onStateChange(handler: (state: WSConnectionState) => void): void;
27
+ }
28
+
29
+ /** WebSocket client options */
30
+ export interface WSClientOptions {
31
+ account: ResolvedFeishuAccount;
32
+ onMessage?: MessageHandler;
33
+ onStateChange?: (state: WSConnectionState) => void;
34
+ onError?: (error: Error) => void;
35
+ }
36
+
37
+ /**
38
+ * Create a WebSocket client using the official Lark SDK
39
+ *
40
+ * Note: This requires @larksuiteoapi/node-sdk to be installed.
41
+ * The SDK handles:
42
+ * - Connection establishment and authentication
43
+ * - Automatic reconnection
44
+ * - Heartbeat/keepalive
45
+ * - Message parsing
46
+ */
47
+ export async function createWSClient(options: WSClientOptions): Promise<FeishuWSClient> {
48
+ const { account, onMessage, onStateChange, onError } = options;
49
+
50
+ let state: WSConnectionState = "disconnected";
51
+ let messageHandler: MessageHandler | undefined = onMessage;
52
+ let stateHandler: ((state: WSConnectionState) => void) | undefined = onStateChange;
53
+ let wsClient: unknown = null;
54
+ let larkSdk: typeof import("@larksuiteoapi/node-sdk") | null = null;
55
+
56
+ const setState = (newState: WSConnectionState) => {
57
+ state = newState;
58
+ stateHandler?.(newState);
59
+ };
60
+
61
+ // Try to import the official SDK
62
+ try {
63
+ larkSdk = await import("@larksuiteoapi/node-sdk");
64
+ } catch {
65
+ throw new Error(
66
+ "WebSocket mode requires @larksuiteoapi/node-sdk. " +
67
+ "Install it with: npm install @larksuiteoapi/node-sdk"
68
+ );
69
+ }
70
+
71
+ const client: FeishuWSClient = {
72
+ get state() {
73
+ return state;
74
+ },
75
+
76
+ async start() {
77
+ if (!larkSdk) {
78
+ throw new Error("Lark SDK not available");
79
+ }
80
+
81
+ if (!account.appId || !account.appSecret) {
82
+ throw new Error("appId and appSecret are required for WebSocket connection");
83
+ }
84
+
85
+ setState("connecting");
86
+
87
+ try {
88
+ const baseConfig = {
89
+ appId: account.appId,
90
+ appSecret: account.appSecret,
91
+ domain: account.domain === "lark" ? larkSdk.Domain.Lark : larkSdk.Domain.Feishu,
92
+ };
93
+
94
+ // Create WebSocket client
95
+ wsClient = new larkSdk.WSClient({
96
+ ...baseConfig,
97
+ loggerLevel: larkSdk.LoggerLevel.info,
98
+ });
99
+
100
+ // Create event dispatcher
101
+ const eventDispatcher = new larkSdk.EventDispatcher({}).register({
102
+ "im.message.receive_v1": async (data: unknown) => {
103
+ if (messageHandler) {
104
+ try {
105
+ // Convert SDK event format to our format
106
+ const event = convertSdkEvent(data);
107
+ await messageHandler(event);
108
+ } catch (err) {
109
+ onError?.(err instanceof Error ? err : new Error(String(err)));
110
+ }
111
+ }
112
+ },
113
+ });
114
+
115
+ // Start the WebSocket client
116
+ await (wsClient as { start: (opts: { eventDispatcher: unknown }) => Promise<void> }).start({
117
+ eventDispatcher,
118
+ });
119
+
120
+ setState("connected");
121
+ } catch (err) {
122
+ setState("disconnected");
123
+ throw err;
124
+ }
125
+ },
126
+
127
+ async stop() {
128
+ if (wsClient && typeof (wsClient as { close?: () => void }).close === "function") {
129
+ (wsClient as { close: () => void }).close();
130
+ }
131
+ wsClient = null;
132
+ setState("disconnected");
133
+ },
134
+
135
+ onMessage(handler: MessageHandler) {
136
+ messageHandler = handler;
137
+ },
138
+
139
+ onStateChange(handler: (state: WSConnectionState) => void) {
140
+ stateHandler = handler;
141
+ },
142
+ };
143
+
144
+ return client;
145
+ }
146
+
147
+ /**
148
+ * Convert SDK event format to our internal format
149
+ */
150
+ function convertSdkEvent(data: unknown): FeishuMessageReceiveEvent {
151
+ const event = data as {
152
+ message?: {
153
+ message_id?: string;
154
+ chat_id?: string;
155
+ chat_type?: string;
156
+ message_type?: string;
157
+ content?: string;
158
+ mentions?: Array<{ key: string; id: { open_id: string }; name: string }>;
159
+ root_id?: string;
160
+ parent_id?: string;
161
+ };
162
+ sender?: {
163
+ sender_id?: { open_id?: string; user_id?: string; union_id?: string };
164
+ sender_type?: string;
165
+ };
166
+ };
167
+
168
+ return {
169
+ schema: "2.0",
170
+ header: {
171
+ event_id: "",
172
+ event_type: "im.message.receive_v1",
173
+ create_time: String(Date.now()),
174
+ token: "",
175
+ app_id: "",
176
+ tenant_key: "",
177
+ },
178
+ event: {
179
+ message: {
180
+ message_id: event.message?.message_id || "",
181
+ chat_id: event.message?.chat_id || "",
182
+ chat_type: event.message?.chat_type || "p2p",
183
+ message_type: event.message?.message_type || "text",
184
+ content: event.message?.content || "",
185
+ mentions: event.message?.mentions,
186
+ root_id: event.message?.root_id,
187
+ parent_id: event.message?.parent_id,
188
+ },
189
+ sender: {
190
+ sender_id: event.sender?.sender_id || {},
191
+ sender_type: event.sender?.sender_type || "user",
192
+ },
193
+ },
194
+ };
195
+ }
196
+
197
+ /**
198
+ * Simple native WebSocket implementation (fallback when SDK is not available)
199
+ *
200
+ * This is a basic implementation that connects to Feishu's WebSocket endpoint.
201
+ * For production use, the official SDK is recommended.
202
+ */
203
+ export async function createNativeWSClient(options: WSClientOptions): Promise<FeishuWSClient> {
204
+ const { account, onMessage, onStateChange, onError } = options;
205
+
206
+ let state: WSConnectionState = "disconnected";
207
+ let messageHandler: MessageHandler | undefined = onMessage;
208
+ let stateHandler: ((state: WSConnectionState) => void) | undefined = onStateChange;
209
+ let ws: WebSocket | null = null;
210
+ let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
211
+ let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
212
+ let reconnectAttempts = 0;
213
+ const maxReconnectAttempts = 10;
214
+ const baseReconnectDelay = 1000;
215
+
216
+ const setState = (newState: WSConnectionState) => {
217
+ state = newState;
218
+ stateHandler?.(newState);
219
+ };
220
+
221
+ const cleanup = () => {
222
+ if (heartbeatInterval) {
223
+ clearInterval(heartbeatInterval);
224
+ heartbeatInterval = null;
225
+ }
226
+ if (reconnectTimeout) {
227
+ clearTimeout(reconnectTimeout);
228
+ reconnectTimeout = null;
229
+ }
230
+ if (ws) {
231
+ ws.close();
232
+ ws = null;
233
+ }
234
+ };
235
+
236
+ const connect = async (): Promise<void> => {
237
+ if (!account.appId || !account.appSecret) {
238
+ throw new Error("appId and appSecret are required");
239
+ }
240
+
241
+ setState("connecting");
242
+
243
+ // Get ticket for WebSocket connection
244
+ const ticketUrl = `${account.apiBase}/callback/ws/endpoint`;
245
+ const tokenResponse = await fetch(`${account.apiBase}/auth/v3/tenant_access_token/internal`, {
246
+ method: "POST",
247
+ headers: { "Content-Type": "application/json" },
248
+ body: JSON.stringify({ app_id: account.appId, app_secret: account.appSecret }),
249
+ });
250
+
251
+ const tokenData = await tokenResponse.json() as { tenant_access_token?: string };
252
+ if (!tokenData.tenant_access_token) {
253
+ throw new Error("Failed to get access token");
254
+ }
255
+
256
+ const ticketResponse = await fetch(ticketUrl, {
257
+ method: "POST",
258
+ headers: {
259
+ "Authorization": `Bearer ${tokenData.tenant_access_token}`,
260
+ "Content-Type": "application/json",
261
+ },
262
+ body: JSON.stringify({}),
263
+ });
264
+
265
+ const ticketData = await ticketResponse.json() as {
266
+ code?: number;
267
+ data?: { url?: string };
268
+ };
269
+
270
+ if (ticketData.code !== 0 || !ticketData.data?.url) {
271
+ throw new Error(`Failed to get WebSocket URL: ${JSON.stringify(ticketData)}`);
272
+ }
273
+
274
+ const wsUrl = ticketData.data.url;
275
+
276
+ return new Promise((resolve, reject) => {
277
+ try {
278
+ ws = new WebSocket(wsUrl);
279
+
280
+ ws.onopen = () => {
281
+ setState("connected");
282
+ reconnectAttempts = 0;
283
+
284
+ // Start heartbeat
285
+ heartbeatInterval = setInterval(() => {
286
+ if (ws?.readyState === WebSocket.OPEN) {
287
+ ws.send(JSON.stringify({ type: "ping" }));
288
+ }
289
+ }, 30000);
290
+
291
+ resolve();
292
+ };
293
+
294
+ ws.onmessage = (event) => {
295
+ try {
296
+ const data = JSON.parse(event.data as string) as {
297
+ type?: string;
298
+ header?: { event_type?: string };
299
+ event?: unknown;
300
+ };
301
+
302
+ // Handle pong
303
+ if (data.type === "pong") {
304
+ return;
305
+ }
306
+
307
+ // Handle message event
308
+ if (data.header?.event_type === "im.message.receive_v1" && messageHandler) {
309
+ const feishuEvent = data as unknown as FeishuMessageReceiveEvent;
310
+ messageHandler(feishuEvent).catch((err) => {
311
+ onError?.(err instanceof Error ? err : new Error(String(err)));
312
+ });
313
+ }
314
+ } catch (err) {
315
+ onError?.(err instanceof Error ? err : new Error(String(err)));
316
+ }
317
+ };
318
+
319
+ ws.onerror = (event) => {
320
+ onError?.(new Error(`WebSocket error: ${event}`));
321
+ };
322
+
323
+ ws.onclose = () => {
324
+ cleanup();
325
+ setState("disconnected");
326
+
327
+ // Attempt reconnection
328
+ if (reconnectAttempts < maxReconnectAttempts) {
329
+ setState("reconnecting");
330
+ const delay = baseReconnectDelay * Math.pow(2, reconnectAttempts);
331
+ reconnectAttempts++;
332
+
333
+ reconnectTimeout = setTimeout(() => {
334
+ connect().catch((err) => {
335
+ onError?.(err instanceof Error ? err : new Error(String(err)));
336
+ });
337
+ }, delay);
338
+ }
339
+ };
340
+ } catch (err) {
341
+ setState("disconnected");
342
+ reject(err);
343
+ }
344
+ });
345
+ };
346
+
347
+ const client: FeishuWSClient = {
348
+ get state() {
349
+ return state;
350
+ },
351
+
352
+ async start() {
353
+ await connect();
354
+ },
355
+
356
+ async stop() {
357
+ reconnectAttempts = maxReconnectAttempts; // Prevent reconnection
358
+ cleanup();
359
+ setState("disconnected");
360
+ },
361
+
362
+ onMessage(handler: MessageHandler) {
363
+ messageHandler = handler;
364
+ },
365
+
366
+ onStateChange(handler: (state: WSConnectionState) => void) {
367
+ stateHandler = handler;
368
+ },
369
+ };
370
+
371
+ return client;
372
+ }