@botcord/daemon 0.2.58 → 0.2.60
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/dist/config.d.ts +4 -1
- package/dist/config.js +2 -2
- package/dist/cross-room.js +3 -1
- package/dist/daemon-config-map.js +6 -0
- package/dist/daemon.js +21 -1
- package/dist/diagnostics.d.ts +1 -0
- package/dist/diagnostics.js +35 -6
- package/dist/gateway/channels/feishu-registration.d.ts +35 -0
- package/dist/gateway/channels/feishu-registration.js +101 -0
- package/dist/gateway/channels/feishu.d.ts +16 -0
- package/dist/gateway/channels/feishu.js +459 -0
- package/dist/gateway/channels/index.d.ts +2 -0
- package/dist/gateway/channels/index.js +2 -0
- package/dist/gateway/channels/login-session.d.ts +9 -1
- package/dist/gateway/channels/login-session.js +1 -1
- package/dist/gateway/channels/wechat.js +26 -2
- package/dist/gateway/dispatcher.d.ts +3 -0
- package/dist/gateway/dispatcher.js +190 -30
- package/dist/gateway/policy-resolver.d.ts +10 -6
- package/dist/gateway/types.d.ts +1 -1
- package/dist/gateway-control.d.ts +8 -1
- package/dist/gateway-control.js +171 -18
- package/dist/index.js +9 -3
- package/dist/log.d.ts +9 -0
- package/dist/log.js +89 -1
- package/dist/provision.js +7 -1
- package/package.json +2 -1
- package/src/__tests__/cross-room.test.ts +2 -0
- package/src/__tests__/diagnostics.test.ts +37 -1
- package/src/__tests__/gateway-control.test.ts +84 -0
- package/src/__tests__/log.test.ts +28 -1
- package/src/__tests__/policy-updated-handler.test.ts +5 -7
- package/src/__tests__/third-party-gateway.test.ts +28 -0
- package/src/__tests__/wechat-channel.test.ts +47 -0
- package/src/config.ts +6 -3
- package/src/cross-room.ts +3 -1
- package/src/daemon-config-map.ts +3 -0
- package/src/daemon.ts +24 -3
- package/src/diagnostics.ts +36 -6
- package/src/gateway/__tests__/dispatcher.test.ts +62 -4
- package/src/gateway/__tests__/feishu-channel.test.ts +306 -0
- package/src/gateway/channels/feishu-registration.ts +155 -0
- package/src/gateway/channels/feishu.ts +554 -0
- package/src/gateway/channels/index.ts +6 -0
- package/src/gateway/channels/login-session.ts +10 -2
- package/src/gateway/channels/wechat.ts +29 -2
- package/src/gateway/dispatcher.ts +216 -29
- package/src/gateway/policy-resolver.ts +19 -11
- package/src/gateway/types.ts +1 -1
- package/src/gateway-control.ts +188 -17
- package/src/index.ts +9 -3
- package/src/log.ts +100 -1
- package/src/provision.ts +13 -1
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
import * as Lark from "@larksuiteoapi/node-sdk";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type {
|
|
5
|
+
ChannelAdapter,
|
|
6
|
+
ChannelSendContext,
|
|
7
|
+
ChannelSendResult,
|
|
8
|
+
ChannelStartContext,
|
|
9
|
+
ChannelStatusSnapshot,
|
|
10
|
+
ChannelStopContext,
|
|
11
|
+
ChannelTypingContext,
|
|
12
|
+
GatewayOutboundAttachment,
|
|
13
|
+
GatewayInboundMessage,
|
|
14
|
+
} from "../types.js";
|
|
15
|
+
import { sanitizeUntrustedContent } from "./sanitize.js";
|
|
16
|
+
import { loadGatewaySecret } from "./secret-store.js";
|
|
17
|
+
import { GatewayStateStore } from "./state-store.js";
|
|
18
|
+
import { splitText } from "./text-split.js";
|
|
19
|
+
import type { FeishuDomain } from "./feishu-registration.js";
|
|
20
|
+
|
|
21
|
+
const FEISHU_PROVIDER = "feishu" as const;
|
|
22
|
+
const DEFAULT_SPLIT_AT = 4000;
|
|
23
|
+
const MAX_SEEN_MESSAGES = 2048;
|
|
24
|
+
|
|
25
|
+
export interface FeishuChannelOptions {
|
|
26
|
+
id: string;
|
|
27
|
+
accountId: string;
|
|
28
|
+
appId?: string;
|
|
29
|
+
appSecret?: string;
|
|
30
|
+
domain?: FeishuDomain;
|
|
31
|
+
allowedSenderIds?: string[];
|
|
32
|
+
allowedChatIds?: string[];
|
|
33
|
+
splitAt?: number;
|
|
34
|
+
secretFile?: string;
|
|
35
|
+
stateFile?: string;
|
|
36
|
+
stateDebounceMs?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface FeishuSecret {
|
|
40
|
+
appSecret?: string;
|
|
41
|
+
[key: string]: unknown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface FeishuEventSender {
|
|
45
|
+
sender_id?: {
|
|
46
|
+
open_id?: string;
|
|
47
|
+
user_id?: string;
|
|
48
|
+
union_id?: string;
|
|
49
|
+
};
|
|
50
|
+
sender_type?: string;
|
|
51
|
+
tenant_key?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface FeishuEventMessage {
|
|
55
|
+
message_id?: string;
|
|
56
|
+
root_id?: string;
|
|
57
|
+
parent_id?: string;
|
|
58
|
+
create_time?: string;
|
|
59
|
+
chat_id?: string;
|
|
60
|
+
chat_type?: string;
|
|
61
|
+
message_type?: string;
|
|
62
|
+
content?: string;
|
|
63
|
+
mentions?: Array<{ id?: { open_id?: string; user_id?: string }; name?: string }>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface FeishuMessageEvent {
|
|
67
|
+
sender?: FeishuEventSender;
|
|
68
|
+
message?: FeishuEventMessage;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface FeishuProviderState {
|
|
72
|
+
seenMessageIds?: Record<string, number>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface FeishuApiResponse {
|
|
76
|
+
code?: number;
|
|
77
|
+
msg?: string;
|
|
78
|
+
data?: Record<string, unknown>;
|
|
79
|
+
[key: string]: unknown;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
type FeishuClient = { request(args: unknown): Promise<unknown> };
|
|
83
|
+
|
|
84
|
+
function sdkDomain(domain: FeishuDomain | undefined): unknown {
|
|
85
|
+
const sdk = Lark as unknown as {
|
|
86
|
+
Domain?: { Feishu?: unknown; Lark?: unknown };
|
|
87
|
+
};
|
|
88
|
+
return domain === "lark" ? sdk.Domain?.Lark : sdk.Domain?.Feishu;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function parseMessageContent(content: string | undefined): Record<string, unknown> | null {
|
|
92
|
+
if (!content) return null;
|
|
93
|
+
try {
|
|
94
|
+
const parsed = JSON.parse(content) as Record<string, unknown>;
|
|
95
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
96
|
+
} catch {
|
|
97
|
+
return { text: content };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function parseInboundText(message: FeishuEventMessage): string | null {
|
|
102
|
+
const parsed = parseMessageContent(message.content);
|
|
103
|
+
if (!parsed) return null;
|
|
104
|
+
if (typeof parsed.text === "string") return parsed.text;
|
|
105
|
+
if (message.message_type === "image" && typeof parsed.image_key === "string") {
|
|
106
|
+
return `[image: ${parsed.image_key}]`;
|
|
107
|
+
}
|
|
108
|
+
if (message.message_type === "file" && typeof parsed.file_key === "string") {
|
|
109
|
+
const name = typeof parsed.file_name === "string" ? ` ${parsed.file_name}` : "";
|
|
110
|
+
return `[file${name}: ${parsed.file_key}]`;
|
|
111
|
+
}
|
|
112
|
+
if (message.message_type === "audio" && typeof parsed.file_key === "string") {
|
|
113
|
+
return `[audio: ${parsed.file_key}]`;
|
|
114
|
+
}
|
|
115
|
+
if (message.message_type === "media" && typeof parsed.file_key === "string") {
|
|
116
|
+
return `[video: ${parsed.file_key}]`;
|
|
117
|
+
}
|
|
118
|
+
if (message.message_type) return `[${message.message_type} message]`;
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function senderLabel(event: FeishuMessageEvent): string | undefined {
|
|
123
|
+
const mentions = event.message?.mentions ?? [];
|
|
124
|
+
const senderOpenId = event.sender?.sender_id?.open_id;
|
|
125
|
+
const hit = mentions.find((m) => m.id?.open_id && m.id.open_id === senderOpenId);
|
|
126
|
+
return typeof hit?.name === "string" && hit.name ? hit.name : undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function createFeishuChannel(opts: FeishuChannelOptions): ChannelAdapter {
|
|
130
|
+
const splitAt = opts.splitAt && opts.splitAt > 0 ? opts.splitAt : DEFAULT_SPLIT_AT;
|
|
131
|
+
const allowedSenderIds = new Set((opts.allowedSenderIds ?? []).map(String));
|
|
132
|
+
const allowedChatIds = new Set((opts.allowedChatIds ?? []).map(String));
|
|
133
|
+
let appSecret = opts.appSecret;
|
|
134
|
+
let wsClient: { start(opts: unknown): unknown; close(opts?: unknown): unknown } | null = null;
|
|
135
|
+
let client: FeishuClient | null = null;
|
|
136
|
+
let stateStore: GatewayStateStore | null = null;
|
|
137
|
+
let botOpenId: string | undefined;
|
|
138
|
+
let botName: string | undefined;
|
|
139
|
+
let liveSetStatus: ((patch: Partial<ChannelStatusSnapshot>) => void) | null = null;
|
|
140
|
+
|
|
141
|
+
let statusSnapshot: ChannelStatusSnapshot = {
|
|
142
|
+
channel: opts.id,
|
|
143
|
+
accountId: opts.accountId,
|
|
144
|
+
running: false,
|
|
145
|
+
connected: false,
|
|
146
|
+
reconnectAttempts: 0,
|
|
147
|
+
lastError: null,
|
|
148
|
+
provider: FEISHU_PROVIDER,
|
|
149
|
+
authorized: false,
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
function ensureState(): GatewayStateStore {
|
|
153
|
+
if (!stateStore) {
|
|
154
|
+
stateStore = new GatewayStateStore(opts.id, {
|
|
155
|
+
override: opts.stateFile,
|
|
156
|
+
debounceMs: opts.stateDebounceMs,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return stateStore;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function readProviderState(): FeishuProviderState {
|
|
163
|
+
return (ensureState().getProviderState() ?? {}) as FeishuProviderState;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function hasSeenMessage(messageId: string): boolean {
|
|
167
|
+
const seen = readProviderState().seenMessageIds ?? {};
|
|
168
|
+
return Object.prototype.hasOwnProperty.call(seen, messageId);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function rememberMessage(messageId: string): void {
|
|
172
|
+
const providerState = readProviderState();
|
|
173
|
+
const seen = { ...(providerState.seenMessageIds ?? {}), [messageId]: Date.now() };
|
|
174
|
+
const entries = Object.entries(seen).sort((a, b) => a[1] - b[1]);
|
|
175
|
+
while (entries.length > MAX_SEEN_MESSAGES) entries.shift();
|
|
176
|
+
ensureState().update({
|
|
177
|
+
providerState: {
|
|
178
|
+
...providerState,
|
|
179
|
+
seenMessageIds: Object.fromEntries(entries),
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function loadSecretIfNeeded(): string | undefined {
|
|
185
|
+
if (appSecret) return appSecret;
|
|
186
|
+
const secret = loadGatewaySecret<FeishuSecret>(opts.id, opts.secretFile);
|
|
187
|
+
if (typeof secret?.appSecret === "string" && secret.appSecret.length > 0) {
|
|
188
|
+
appSecret = secret.appSecret;
|
|
189
|
+
}
|
|
190
|
+
return appSecret;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function ensureClient(): FeishuClient {
|
|
194
|
+
if (client) return client;
|
|
195
|
+
if (!opts.appId || !loadSecretIfNeeded()) {
|
|
196
|
+
throw new Error("feishu appId/appSecret not loaded");
|
|
197
|
+
}
|
|
198
|
+
const sdk = Lark as unknown as {
|
|
199
|
+
Client: new (args: Record<string, unknown>) => { request(args: unknown): Promise<unknown> };
|
|
200
|
+
AppType?: { SelfBuild?: unknown };
|
|
201
|
+
};
|
|
202
|
+
client = new sdk.Client({
|
|
203
|
+
appId: opts.appId,
|
|
204
|
+
appSecret,
|
|
205
|
+
appType: sdk.AppType?.SelfBuild,
|
|
206
|
+
domain: sdkDomain(opts.domain),
|
|
207
|
+
});
|
|
208
|
+
return client;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function markStatus(
|
|
212
|
+
patch: Partial<ChannelStatusSnapshot>,
|
|
213
|
+
setStatus?: (patch: Partial<ChannelStatusSnapshot>) => void,
|
|
214
|
+
): void {
|
|
215
|
+
statusSnapshot = { ...statusSnapshot, ...patch };
|
|
216
|
+
(setStatus ?? liveSetStatus)?.(patch);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function probe(): Promise<void> {
|
|
220
|
+
const res = (await ensureClient().request({
|
|
221
|
+
method: "POST",
|
|
222
|
+
url: "/open-apis/bot/v1/openclaw_bot/ping",
|
|
223
|
+
data: { needBotInfo: true },
|
|
224
|
+
})) as { code?: number; msg?: string; data?: { pingBotInfo?: { botID?: string; botName?: string } } };
|
|
225
|
+
if (res.code !== 0) {
|
|
226
|
+
throw new Error(res.msg || `feishu bot ping failed: code=${res.code}`);
|
|
227
|
+
}
|
|
228
|
+
botOpenId = res.data?.pingBotInfo?.botID;
|
|
229
|
+
botName = res.data?.pingBotInfo?.botName;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function normalizeMessage(event: FeishuMessageEvent): GatewayInboundMessage | null {
|
|
233
|
+
const message = event.message;
|
|
234
|
+
const sender = event.sender;
|
|
235
|
+
if (!message || !sender) return null;
|
|
236
|
+
const chatId = message.chat_id;
|
|
237
|
+
const messageId = message.message_id;
|
|
238
|
+
const senderOpenId = sender.sender_id?.open_id;
|
|
239
|
+
if (!chatId || !messageId || !senderOpenId) return null;
|
|
240
|
+
if (botOpenId && senderOpenId === botOpenId) return null;
|
|
241
|
+
if (hasSeenMessage(messageId)) return null;
|
|
242
|
+
|
|
243
|
+
if (allowedChatIds.size > 0 && !allowedChatIds.has(chatId)) return null;
|
|
244
|
+
if (!allowedSenderIds.has(senderOpenId)) return null;
|
|
245
|
+
|
|
246
|
+
const text = parseInboundText(message);
|
|
247
|
+
if (text === null) return null;
|
|
248
|
+
rememberMessage(messageId);
|
|
249
|
+
const chatType = message.chat_type ?? "";
|
|
250
|
+
const conversationKind: "direct" | "group" =
|
|
251
|
+
chatType === "p2p" ? "direct" : "group";
|
|
252
|
+
const conversationId =
|
|
253
|
+
conversationKind === "direct" ? `feishu:user:${chatId}` : `feishu:chat:${chatId}`;
|
|
254
|
+
const receivedAt = Number(message.create_time) || Date.now();
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
id: `feishu:${messageId}`,
|
|
258
|
+
channel: opts.id,
|
|
259
|
+
accountId: opts.accountId,
|
|
260
|
+
conversation: {
|
|
261
|
+
id: conversationId,
|
|
262
|
+
kind: conversationKind,
|
|
263
|
+
threadId: message.root_id || message.parent_id || null,
|
|
264
|
+
},
|
|
265
|
+
sender: {
|
|
266
|
+
id: `feishu:user:${senderOpenId}`,
|
|
267
|
+
...(senderLabel(event) ? { name: senderLabel(event) } : {}),
|
|
268
|
+
kind: "user",
|
|
269
|
+
},
|
|
270
|
+
text: sanitizeUntrustedContent(text),
|
|
271
|
+
raw: event,
|
|
272
|
+
replyTo: messageId,
|
|
273
|
+
mentioned: true,
|
|
274
|
+
receivedAt,
|
|
275
|
+
trace: { id: `feishu:${messageId}`, streamable: true },
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function start(ctx: ChannelStartContext): Promise<void> {
|
|
280
|
+
liveSetStatus = ctx.setStatus;
|
|
281
|
+
try {
|
|
282
|
+
if (!opts.appId || !loadSecretIfNeeded()) {
|
|
283
|
+
markStatus({
|
|
284
|
+
running: false,
|
|
285
|
+
connected: false,
|
|
286
|
+
authorized: false,
|
|
287
|
+
lastError: "feishu appId/appSecret not loaded",
|
|
288
|
+
}, ctx.setStatus);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
await probe();
|
|
292
|
+
const sdk = Lark as unknown as {
|
|
293
|
+
EventDispatcher: new (args?: Record<string, unknown>) => {
|
|
294
|
+
register(handlers: Record<string, (data: unknown) => unknown>): void;
|
|
295
|
+
};
|
|
296
|
+
WSClient: new (args: Record<string, unknown>) => {
|
|
297
|
+
start(opts: unknown): unknown;
|
|
298
|
+
close(opts?: unknown): unknown;
|
|
299
|
+
};
|
|
300
|
+
LoggerLevel?: { info?: unknown };
|
|
301
|
+
};
|
|
302
|
+
const dispatcher = new sdk.EventDispatcher({});
|
|
303
|
+
dispatcher.register({
|
|
304
|
+
"im.message.receive_v1": async (data: unknown) => {
|
|
305
|
+
const normalized = normalizeMessage(data as FeishuMessageEvent);
|
|
306
|
+
if (!normalized) return;
|
|
307
|
+
markStatus({ lastInboundAt: Date.now(), connected: true, authorized: true });
|
|
308
|
+
await ctx.emit({ message: normalized });
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
wsClient = new sdk.WSClient({
|
|
312
|
+
appId: opts.appId,
|
|
313
|
+
appSecret,
|
|
314
|
+
domain: sdkDomain(opts.domain),
|
|
315
|
+
loggerLevel: sdk.LoggerLevel?.info,
|
|
316
|
+
});
|
|
317
|
+
markStatus({
|
|
318
|
+
running: true,
|
|
319
|
+
connected: true,
|
|
320
|
+
authorized: true,
|
|
321
|
+
lastError: null,
|
|
322
|
+
}, ctx.setStatus);
|
|
323
|
+
Promise.resolve(wsClient.start({ eventDispatcher: dispatcher })).catch((err) => {
|
|
324
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
325
|
+
markStatus({
|
|
326
|
+
running: false,
|
|
327
|
+
connected: false,
|
|
328
|
+
authorized: false,
|
|
329
|
+
lastError: message,
|
|
330
|
+
reconnectAttempts: (statusSnapshot.reconnectAttempts ?? 0) + 1,
|
|
331
|
+
});
|
|
332
|
+
ctx.log.warn("feishu ws client failed", { err: message });
|
|
333
|
+
});
|
|
334
|
+
await new Promise<void>((resolve) => {
|
|
335
|
+
if (ctx.abortSignal.aborted) return resolve();
|
|
336
|
+
ctx.abortSignal.addEventListener("abort", () => resolve(), { once: true });
|
|
337
|
+
});
|
|
338
|
+
} catch (err) {
|
|
339
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
340
|
+
markStatus({
|
|
341
|
+
running: false,
|
|
342
|
+
connected: false,
|
|
343
|
+
authorized: false,
|
|
344
|
+
lastError: message,
|
|
345
|
+
}, ctx.setStatus);
|
|
346
|
+
throw err;
|
|
347
|
+
} finally {
|
|
348
|
+
try {
|
|
349
|
+
wsClient?.close({ force: true });
|
|
350
|
+
} catch {
|
|
351
|
+
// best effort
|
|
352
|
+
}
|
|
353
|
+
wsClient = null;
|
|
354
|
+
markStatus({ running: false, connected: false }, ctx.setStatus);
|
|
355
|
+
try {
|
|
356
|
+
stateStore?.flush();
|
|
357
|
+
} catch (err) {
|
|
358
|
+
ctx.log.warn("feishu state flush failed", {
|
|
359
|
+
err: err instanceof Error ? err.message : String(err),
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function chatIdFromConversation(conversationId: string): string | null {
|
|
366
|
+
if (conversationId.startsWith("feishu:user:")) {
|
|
367
|
+
return conversationId.slice("feishu:user:".length);
|
|
368
|
+
}
|
|
369
|
+
if (conversationId.startsWith("feishu:chat:")) {
|
|
370
|
+
return conversationId.slice("feishu:chat:".length);
|
|
371
|
+
}
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function callFeishu(args: unknown): Promise<FeishuApiResponse> {
|
|
376
|
+
const res = (await ensureClient().request(args)) as FeishuApiResponse;
|
|
377
|
+
if (res.code !== undefined && res.code !== 0) {
|
|
378
|
+
throw new Error(res.msg || `feishu api failed: code=${res.code}`);
|
|
379
|
+
}
|
|
380
|
+
return res;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function resultMessageId(res: FeishuApiResponse): string | undefined {
|
|
384
|
+
return (
|
|
385
|
+
(typeof res.data?.message_id === "string" ? res.data.message_id : undefined) ??
|
|
386
|
+
(typeof res.message_id === "string" ? res.message_id : undefined)
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function resultResourceKey(res: FeishuApiResponse, key: "image_key" | "file_key"): string {
|
|
391
|
+
const direct = res[key];
|
|
392
|
+
if (typeof direct === "string") return direct;
|
|
393
|
+
const nested = res.data?.[key];
|
|
394
|
+
if (typeof nested === "string") return nested;
|
|
395
|
+
throw new Error(`feishu upload failed: ${key} missing`);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function attachmentBytes(attachment: GatewayOutboundAttachment): Buffer {
|
|
399
|
+
if (attachment.data) return Buffer.from(attachment.data);
|
|
400
|
+
if (attachment.filePath) return readFileSync(attachment.filePath);
|
|
401
|
+
throw new Error("feishu attachment requires filePath or data");
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function fileNameForAttachment(attachment: GatewayOutboundAttachment): string {
|
|
405
|
+
if (attachment.filename) return attachment.filename;
|
|
406
|
+
if (attachment.filePath) return path.basename(attachment.filePath);
|
|
407
|
+
return "attachment";
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function isImageAttachment(attachment: GatewayOutboundAttachment): boolean {
|
|
411
|
+
if (attachment.kind === "image") return true;
|
|
412
|
+
if (attachment.contentType?.startsWith("image/")) return true;
|
|
413
|
+
return /\.(png|jpe?g|gif|webp|bmp|tiff?|ico)$/i.test(fileNameForAttachment(attachment));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function feishuFileType(attachment: GatewayOutboundAttachment): string {
|
|
417
|
+
if (attachment.kind === "video") return "mp4";
|
|
418
|
+
const name = fileNameForAttachment(attachment).toLowerCase();
|
|
419
|
+
const contentType = attachment.contentType?.toLowerCase() ?? "";
|
|
420
|
+
if (contentType.includes("pdf") || name.endsWith(".pdf")) return "pdf";
|
|
421
|
+
if (contentType.includes("word") || /\.(doc|docx)$/i.test(name)) return "doc";
|
|
422
|
+
if (contentType.includes("spreadsheet") || /\.(xls|xlsx)$/i.test(name)) return "xls";
|
|
423
|
+
if (contentType.includes("presentation") || /\.(ppt|pptx)$/i.test(name)) return "ppt";
|
|
424
|
+
if (contentType.includes("audio/ogg") || name.endsWith(".opus")) return "opus";
|
|
425
|
+
if (contentType.includes("video/mp4") || name.endsWith(".mp4")) return "mp4";
|
|
426
|
+
return "stream";
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function uploadAttachment(attachment: GatewayOutboundAttachment): Promise<{
|
|
430
|
+
msgType: "image" | "file" | "audio" | "media";
|
|
431
|
+
content: Record<string, unknown>;
|
|
432
|
+
}> {
|
|
433
|
+
const bytes = attachmentBytes(attachment);
|
|
434
|
+
if (isImageAttachment(attachment)) {
|
|
435
|
+
const res = await callFeishu({
|
|
436
|
+
method: "POST",
|
|
437
|
+
url: "/open-apis/im/v1/images",
|
|
438
|
+
data: { image_type: "message", image: bytes },
|
|
439
|
+
headers: { "Content-Type": "multipart/form-data" },
|
|
440
|
+
});
|
|
441
|
+
return { msgType: "image", content: { image_key: resultResourceKey(res, "image_key") } };
|
|
442
|
+
}
|
|
443
|
+
const fileType = feishuFileType(attachment);
|
|
444
|
+
const res = await callFeishu({
|
|
445
|
+
method: "POST",
|
|
446
|
+
url: "/open-apis/im/v1/files",
|
|
447
|
+
data: {
|
|
448
|
+
file_type: fileType,
|
|
449
|
+
file_name: fileNameForAttachment(attachment),
|
|
450
|
+
file: bytes,
|
|
451
|
+
},
|
|
452
|
+
headers: { "Content-Type": "multipart/form-data" },
|
|
453
|
+
});
|
|
454
|
+
const fileKey = resultResourceKey(res, "file_key");
|
|
455
|
+
if (fileType === "opus") return { msgType: "audio", content: { file_key: fileKey } };
|
|
456
|
+
if (fileType === "mp4") return { msgType: "media", content: { file_key: fileKey } };
|
|
457
|
+
return { msgType: "file", content: { file_key: fileKey } };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function sendPayload(args: {
|
|
461
|
+
chatId: string;
|
|
462
|
+
msgType: string;
|
|
463
|
+
content: Record<string, unknown>;
|
|
464
|
+
replyTo?: string | null;
|
|
465
|
+
replyInThread?: boolean;
|
|
466
|
+
}): Promise<string | undefined> {
|
|
467
|
+
const data: Record<string, unknown> = {
|
|
468
|
+
msg_type: args.msgType,
|
|
469
|
+
content: JSON.stringify(args.content),
|
|
470
|
+
};
|
|
471
|
+
if (args.replyTo) {
|
|
472
|
+
data.reply_in_thread = args.replyInThread ?? false;
|
|
473
|
+
const res = await callFeishu({
|
|
474
|
+
method: "POST",
|
|
475
|
+
url: `/open-apis/im/v1/messages/${encodeURIComponent(args.replyTo)}/reply`,
|
|
476
|
+
data,
|
|
477
|
+
});
|
|
478
|
+
return resultMessageId(res);
|
|
479
|
+
}
|
|
480
|
+
const res = await callFeishu({
|
|
481
|
+
method: "POST",
|
|
482
|
+
url: "/open-apis/im/v1/messages",
|
|
483
|
+
params: { receive_id_type: "chat_id" },
|
|
484
|
+
data: {
|
|
485
|
+
...data,
|
|
486
|
+
receive_id: args.chatId,
|
|
487
|
+
},
|
|
488
|
+
});
|
|
489
|
+
return resultMessageId(res);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async function send(ctx: ChannelSendContext): Promise<ChannelSendResult> {
|
|
493
|
+
const chatId = chatIdFromConversation(ctx.message.conversationId);
|
|
494
|
+
if (!chatId) {
|
|
495
|
+
throw new Error("unsupported feishu conversation id");
|
|
496
|
+
}
|
|
497
|
+
let providerMessageId: string | undefined;
|
|
498
|
+
const replyTo = ctx.message.replyTo ?? ctx.message.threadId ?? null;
|
|
499
|
+
const textParts = ctx.message.text.length > 0 ? splitText(ctx.message.text, splitAt) : [];
|
|
500
|
+
for (const part of textParts) {
|
|
501
|
+
providerMessageId = await sendPayload({
|
|
502
|
+
chatId,
|
|
503
|
+
msgType: "text",
|
|
504
|
+
content: { text: part },
|
|
505
|
+
replyTo,
|
|
506
|
+
replyInThread: Boolean(ctx.message.threadId),
|
|
507
|
+
}) ?? providerMessageId;
|
|
508
|
+
}
|
|
509
|
+
for (const attachment of ctx.message.attachments ?? []) {
|
|
510
|
+
const uploaded = await uploadAttachment(attachment);
|
|
511
|
+
providerMessageId = await sendPayload({
|
|
512
|
+
chatId,
|
|
513
|
+
msgType: uploaded.msgType,
|
|
514
|
+
content: uploaded.content,
|
|
515
|
+
replyTo,
|
|
516
|
+
replyInThread: Boolean(ctx.message.threadId),
|
|
517
|
+
}) ?? providerMessageId;
|
|
518
|
+
}
|
|
519
|
+
markStatus({ lastSendAt: Date.now() });
|
|
520
|
+
return { providerMessageId };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async function typing(ctx: ChannelTypingContext): Promise<void> {
|
|
524
|
+
ctx.log.debug("feishu typing ignored: no native bot typing API", {
|
|
525
|
+
channel: opts.id,
|
|
526
|
+
conversationId: ctx.conversationId,
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async function stop(_ctx: ChannelStopContext): Promise<void> {
|
|
531
|
+
try {
|
|
532
|
+
wsClient?.close({ force: true });
|
|
533
|
+
} catch {
|
|
534
|
+
// best effort
|
|
535
|
+
}
|
|
536
|
+
wsClient = null;
|
|
537
|
+
try {
|
|
538
|
+
stateStore?.close();
|
|
539
|
+
} catch {
|
|
540
|
+
// best effort
|
|
541
|
+
}
|
|
542
|
+
markStatus({ running: false, connected: false });
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
return {
|
|
546
|
+
id: opts.id,
|
|
547
|
+
type: FEISHU_PROVIDER,
|
|
548
|
+
start,
|
|
549
|
+
stop,
|
|
550
|
+
send,
|
|
551
|
+
typing,
|
|
552
|
+
status: () => ({ ...statusSnapshot }),
|
|
553
|
+
};
|
|
554
|
+
}
|
|
@@ -6,6 +6,12 @@ export type {
|
|
|
6
6
|
} from "./botcord.js";
|
|
7
7
|
export { createTelegramChannel, type TelegramChannelOptions } from "./telegram.js";
|
|
8
8
|
export { createWechatChannel, type WechatChannelOptions } from "./wechat.js";
|
|
9
|
+
export { createFeishuChannel, type FeishuChannelOptions } from "./feishu.js";
|
|
10
|
+
export {
|
|
11
|
+
startFeishuRegistration,
|
|
12
|
+
pollFeishuRegistration,
|
|
13
|
+
type FeishuDomain,
|
|
14
|
+
} from "./feishu-registration.js";
|
|
9
15
|
export {
|
|
10
16
|
getBotQrcode,
|
|
11
17
|
getQrcodeStatus,
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { randomBytes } from "node:crypto";
|
|
13
13
|
|
|
14
|
-
export type LoginProvider = "wechat" | "telegram";
|
|
14
|
+
export type LoginProvider = "wechat" | "telegram" | "feishu";
|
|
15
15
|
|
|
16
16
|
export interface LoginSession {
|
|
17
17
|
loginId: string;
|
|
@@ -26,6 +26,14 @@ export interface LoginSession {
|
|
|
26
26
|
baseUrl?: string;
|
|
27
27
|
/** Stored only after the user confirms the qrcode. Never returned to Hub. */
|
|
28
28
|
botToken?: string;
|
|
29
|
+
/** Feishu/Lark PersonalAgent app id returned by app registration. */
|
|
30
|
+
appId?: string;
|
|
31
|
+
/** Feishu/Lark PersonalAgent app secret returned by app registration. */
|
|
32
|
+
appSecret?: string;
|
|
33
|
+
/** Feishu/Lark tenant domain selected during registration. */
|
|
34
|
+
domain?: "feishu" | "lark";
|
|
35
|
+
/** Feishu/Lark user open_id returned by app registration. */
|
|
36
|
+
userOpenId?: string;
|
|
29
37
|
/** Masked preview safe for Hub/dashboard display. */
|
|
30
38
|
tokenPreview?: string;
|
|
31
39
|
/** Unix millis. */
|
|
@@ -127,7 +135,7 @@ export function maskTokenPreview(token: string | undefined | null): string {
|
|
|
127
135
|
* ids and racefully claim someone else's session.
|
|
128
136
|
*/
|
|
129
137
|
export function mintLoginId(provider: LoginProvider): string {
|
|
130
|
-
const prefix = provider === "wechat" ? "wxl" : "tgl";
|
|
138
|
+
const prefix = provider === "wechat" ? "wxl" : provider === "feishu" ? "fsl" : "tgl";
|
|
131
139
|
const ts = Date.now().toString(36);
|
|
132
140
|
// 32 hex chars = 128 bits of entropy — W5 regression fix from round 2.
|
|
133
141
|
const rand = randomBytes(16).toString("hex");
|
|
@@ -77,6 +77,9 @@ interface WechatSecret {
|
|
|
77
77
|
interface WechatItem {
|
|
78
78
|
type?: number;
|
|
79
79
|
text_item?: { text?: string };
|
|
80
|
+
image_item?: Record<string, unknown>;
|
|
81
|
+
file_item?: { file_name?: string; len?: unknown; [k: string]: unknown };
|
|
82
|
+
video_item?: { file_name?: string; video_size?: unknown; [k: string]: unknown };
|
|
80
83
|
[k: string]: unknown;
|
|
81
84
|
}
|
|
82
85
|
|
|
@@ -398,16 +401,40 @@ export function createWechatChannel(opts: WechatChannelOptions): ChannelAdapter
|
|
|
398
401
|
return parts.join("\n").trim();
|
|
399
402
|
}
|
|
400
403
|
|
|
404
|
+
function extractMultimodalSummary(msg: WechatInboundMsg): string {
|
|
405
|
+
const parts: string[] = [];
|
|
406
|
+
for (const item of msg.item_list ?? []) {
|
|
407
|
+
if (!item || item.type === 1) continue;
|
|
408
|
+
if (item.type === 2) {
|
|
409
|
+
parts.push("[Image]");
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
if (item.type === 5) {
|
|
413
|
+
const name = item.video_item?.file_name;
|
|
414
|
+
parts.push(name ? `[Video: ${name}]` : "[Video]");
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
if (item.type === 4) {
|
|
418
|
+
const name = item.file_item?.file_name;
|
|
419
|
+
parts.push(name ? `[File: ${name}]` : "[File]");
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
parts.push(`[Unsupported media item: type=${String(item.type ?? "unknown")}]`);
|
|
423
|
+
}
|
|
424
|
+
return parts.join("\n").trim();
|
|
425
|
+
}
|
|
426
|
+
|
|
401
427
|
function normalizeInbound(msg: WechatInboundMsg): GatewayInboundMessage | null {
|
|
402
428
|
if (msg.message_type !== 1) return null;
|
|
403
429
|
const fromUid = typeof msg.from_user_id === "string" ? msg.from_user_id : "";
|
|
404
430
|
const contextToken = typeof msg.context_token === "string" ? msg.context_token : "";
|
|
405
431
|
if (!fromUid || !contextToken) return null;
|
|
406
432
|
const text = extractText(msg);
|
|
407
|
-
|
|
433
|
+
const multimodalSummary = text ? "" : extractMultimodalSummary(msg);
|
|
434
|
+
if (!text && !multimodalSummary) return null;
|
|
408
435
|
if (!allowedSenderIds.has(fromUid)) return null;
|
|
409
436
|
|
|
410
|
-
const sanitized = sanitizeUntrustedContent(text);
|
|
437
|
+
const sanitized = sanitizeUntrustedContent(text || multimodalSummary);
|
|
411
438
|
const receivedAt = now();
|
|
412
439
|
// W10: append randomUUID() to the fallback so two messages received in
|
|
413
440
|
// the same millisecond can't collide. Trace id below already does this.
|