@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.
- package/README.md +290 -0
- package/index.ts +18 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +42 -0
- package/src/api.ts +1160 -0
- package/src/auth.ts +133 -0
- package/src/channel.ts +883 -0
- package/src/context.ts +292 -0
- package/src/dedupe.ts +85 -0
- package/src/dispatch.ts +185 -0
- package/src/history.ts +130 -0
- package/src/inbound.ts +83 -0
- package/src/message.ts +386 -0
- package/src/runtime.ts +14 -0
- package/src/types.ts +330 -0
- package/src/webhook.ts +549 -0
- package/src/websocket.ts +372 -0
package/src/websocket.ts
ADDED
|
@@ -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
|
+
}
|