@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/channel.ts
ADDED
|
@@ -0,0 +1,883 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feishu channel plugin implementation
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
ChannelPlugin,
|
|
7
|
+
ChannelMeta,
|
|
8
|
+
ChannelCapabilities,
|
|
9
|
+
OpenClawConfig,
|
|
10
|
+
} from "openclaw/plugin-sdk";
|
|
11
|
+
import {
|
|
12
|
+
DEFAULT_ACCOUNT_ID,
|
|
13
|
+
normalizeAccountId,
|
|
14
|
+
formatPairingApproveHint,
|
|
15
|
+
PAIRING_APPROVED_MESSAGE,
|
|
16
|
+
deleteAccountFromConfigSection,
|
|
17
|
+
setAccountEnabledInConfigSection,
|
|
18
|
+
applyAccountNameToChannelSection,
|
|
19
|
+
migrateBaseNameToDefaultAccount,
|
|
20
|
+
loadWebMedia,
|
|
21
|
+
} from "openclaw/plugin-sdk";
|
|
22
|
+
|
|
23
|
+
import type {
|
|
24
|
+
ResolvedFeishuAccount,
|
|
25
|
+
FeishuChannelConfig,
|
|
26
|
+
FeishuAccountConfig,
|
|
27
|
+
FeishuDomain,
|
|
28
|
+
FeishuConnectionMode,
|
|
29
|
+
FeishuRenderMode,
|
|
30
|
+
} from "./types.js";
|
|
31
|
+
import * as api from "./api.js";
|
|
32
|
+
import { hasValidCredentials } from "./auth.js";
|
|
33
|
+
import { createWSClient, type FeishuWSClient } from "./websocket.js";
|
|
34
|
+
import { parseWebhookEvent, type FeishuInboundMessage } from "./webhook.js";
|
|
35
|
+
import { createFeishuDedupeCache } from "./dedupe.js";
|
|
36
|
+
import { createGroupHistoryManager, type HistoryEntry } from "./history.js";
|
|
37
|
+
import { buildFeishuMessageContext } from "./context.js";
|
|
38
|
+
import { dispatchFeishuMessage, createDefaultReplySender, createDefaultMediaSender } from "./dispatch.js";
|
|
39
|
+
|
|
40
|
+
// ============ Helper Functions ============
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Fetch reply context if message is a reply
|
|
44
|
+
*/
|
|
45
|
+
async function fetchReplyContext(
|
|
46
|
+
account: ResolvedFeishuAccount,
|
|
47
|
+
inbound: FeishuInboundMessage,
|
|
48
|
+
log?: { warn?: (msg: string) => void },
|
|
49
|
+
): Promise<FeishuInboundMessage> {
|
|
50
|
+
if (!inbound.parentId) {
|
|
51
|
+
return inbound;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const parentResult = await api.getMessage(account, inbound.parentId);
|
|
56
|
+
if (parentResult.ok && parentResult.message) {
|
|
57
|
+
return {
|
|
58
|
+
...inbound,
|
|
59
|
+
replyToBody: parentResult.message.body,
|
|
60
|
+
replyToSenderId: parentResult.message.senderId,
|
|
61
|
+
replyToId: inbound.parentId,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
} catch (err) {
|
|
65
|
+
log?.warn?.(`failed to fetch parent message ${inbound.parentId}: ${String(err)}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return inbound;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Channel metadata
|
|
72
|
+
const meta: ChannelMeta = {
|
|
73
|
+
id: "feishu",
|
|
74
|
+
label: "Feishu",
|
|
75
|
+
selectionLabel: "Feishu (飞书)",
|
|
76
|
+
docsPath: "/channels/feishu",
|
|
77
|
+
docsLabel: "feishu",
|
|
78
|
+
blurb: "Feishu/Lark enterprise messaging platform",
|
|
79
|
+
order: 75,
|
|
80
|
+
quickstartAllowFrom: true,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// Channel capabilities
|
|
84
|
+
const capabilities: ChannelCapabilities = {
|
|
85
|
+
chatTypes: ["direct", "group"],
|
|
86
|
+
reactions: false, // Feishu has limited reaction support
|
|
87
|
+
threads: false, // No native thread support in the same way as Slack
|
|
88
|
+
media: true,
|
|
89
|
+
nativeCommands: false,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// ============ Account Resolution ============
|
|
93
|
+
|
|
94
|
+
function getFeishuConfig(cfg: OpenClawConfig): FeishuChannelConfig | undefined {
|
|
95
|
+
return (cfg.channels as Record<string, unknown>)?.feishu as FeishuChannelConfig | undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function listFeishuAccountIds(cfg: OpenClawConfig): string[] {
|
|
99
|
+
const feishuConfig = getFeishuConfig(cfg);
|
|
100
|
+
if (!feishuConfig) {
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const accountIds: string[] = [];
|
|
105
|
+
|
|
106
|
+
// Check if default account has credentials
|
|
107
|
+
if (feishuConfig.appId || feishuConfig.appSecret) {
|
|
108
|
+
accountIds.push(DEFAULT_ACCOUNT_ID);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Add named accounts
|
|
112
|
+
if (feishuConfig.accounts) {
|
|
113
|
+
for (const accountId of Object.keys(feishuConfig.accounts)) {
|
|
114
|
+
if (!accountIds.includes(accountId)) {
|
|
115
|
+
accountIds.push(accountId);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return accountIds;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Get API base URL for the domain */
|
|
124
|
+
function getApiBase(domain: FeishuDomain): string {
|
|
125
|
+
return domain === "lark"
|
|
126
|
+
? "https://open.larksuite.com/open-apis"
|
|
127
|
+
: "https://open.feishu.cn/open-apis";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Get WebSocket URL for the domain */
|
|
131
|
+
function getWsUrl(domain: FeishuDomain): string {
|
|
132
|
+
return domain === "lark"
|
|
133
|
+
? "wss://open.larksuite.com/open-apis/ws/v1"
|
|
134
|
+
: "wss://open.feishu.cn/open-apis/ws/v1";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function resolveFeishuAccount(params: {
|
|
138
|
+
cfg: OpenClawConfig;
|
|
139
|
+
accountId?: string | null;
|
|
140
|
+
}): ResolvedFeishuAccount {
|
|
141
|
+
const { cfg, accountId: rawAccountId } = params;
|
|
142
|
+
const accountId = normalizeAccountId(rawAccountId ?? DEFAULT_ACCOUNT_ID);
|
|
143
|
+
const feishuConfig = getFeishuConfig(cfg);
|
|
144
|
+
|
|
145
|
+
// Get account-specific config
|
|
146
|
+
const accountConfig = feishuConfig?.accounts?.[accountId];
|
|
147
|
+
const isNamedAccount = accountId !== DEFAULT_ACCOUNT_ID;
|
|
148
|
+
|
|
149
|
+
// Resolve credentials - check account config first, then fall back to base config
|
|
150
|
+
const appId = accountConfig?.appId ?? (isNamedAccount ? undefined : feishuConfig?.appId);
|
|
151
|
+
const appSecret = accountConfig?.appSecret ?? (isNamedAccount ? undefined : feishuConfig?.appSecret);
|
|
152
|
+
|
|
153
|
+
// Check environment variables as fallback
|
|
154
|
+
const envAppId = process.env.FEISHU_APP_ID;
|
|
155
|
+
const envAppSecret = process.env.FEISHU_APP_SECRET;
|
|
156
|
+
|
|
157
|
+
const resolvedAppId = appId ?? (accountId === DEFAULT_ACCOUNT_ID ? envAppId : undefined);
|
|
158
|
+
const resolvedAppSecret = appSecret ?? (accountId === DEFAULT_ACCOUNT_ID ? envAppSecret : undefined);
|
|
159
|
+
|
|
160
|
+
// Determine credential sources
|
|
161
|
+
const appIdSource = appId ? "config" : (resolvedAppId ? "env" : "none");
|
|
162
|
+
const appSecretSource = appSecret ? "config" : (resolvedAppSecret ? "env" : "none");
|
|
163
|
+
|
|
164
|
+
// Resolve domain and connection mode
|
|
165
|
+
const domain: FeishuDomain = accountConfig?.domain ?? feishuConfig?.domain ?? "feishu";
|
|
166
|
+
const connectionMode: FeishuConnectionMode = accountConfig?.connectionMode ?? feishuConfig?.connectionMode ?? "websocket";
|
|
167
|
+
const renderMode: FeishuRenderMode = accountConfig?.renderMode ?? feishuConfig?.renderMode ?? "auto";
|
|
168
|
+
const requireMention = accountConfig?.requireMention ?? feishuConfig?.requireMention ?? true;
|
|
169
|
+
|
|
170
|
+
// Merge configuration
|
|
171
|
+
const config: FeishuAccountConfig = {
|
|
172
|
+
appId: resolvedAppId,
|
|
173
|
+
appSecret: resolvedAppSecret,
|
|
174
|
+
domain,
|
|
175
|
+
connectionMode,
|
|
176
|
+
webhookPath: accountConfig?.webhookPath ?? feishuConfig?.webhookPath,
|
|
177
|
+
verificationToken: accountConfig?.verificationToken ?? feishuConfig?.verificationToken,
|
|
178
|
+
encryptKey: accountConfig?.encryptKey ?? feishuConfig?.encryptKey,
|
|
179
|
+
dmPolicy: accountConfig?.dmPolicy ?? feishuConfig?.dmPolicy ?? "pairing",
|
|
180
|
+
allowFrom: accountConfig?.allowFrom ?? feishuConfig?.allowFrom,
|
|
181
|
+
groupPolicy: accountConfig?.groupPolicy ?? feishuConfig?.groupPolicy ?? "allowlist",
|
|
182
|
+
requireMention,
|
|
183
|
+
mediaMaxMb: accountConfig?.mediaMaxMb ?? feishuConfig?.mediaMaxMb ?? 30,
|
|
184
|
+
renderMode,
|
|
185
|
+
groups: accountConfig?.groups ?? feishuConfig?.groups,
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// Determine if account is enabled
|
|
189
|
+
const enabled = accountConfig?.enabled ?? feishuConfig?.enabled ?? false;
|
|
190
|
+
|
|
191
|
+
// Determine if account is configured
|
|
192
|
+
const configured = Boolean(resolvedAppId && resolvedAppSecret);
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
accountId,
|
|
196
|
+
name: accountConfig?.name,
|
|
197
|
+
enabled,
|
|
198
|
+
configured,
|
|
199
|
+
appId: resolvedAppId,
|
|
200
|
+
appSecret: resolvedAppSecret,
|
|
201
|
+
appIdSource,
|
|
202
|
+
appSecretSource,
|
|
203
|
+
apiBase: getApiBase(domain),
|
|
204
|
+
wsUrl: getWsUrl(domain),
|
|
205
|
+
domain,
|
|
206
|
+
connectionMode,
|
|
207
|
+
webhookPath: config.webhookPath,
|
|
208
|
+
verificationToken: config.verificationToken,
|
|
209
|
+
encryptKey: config.encryptKey,
|
|
210
|
+
renderMode,
|
|
211
|
+
requireMention,
|
|
212
|
+
config,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function resolveDefaultFeishuAccountId(cfg: OpenClawConfig): string {
|
|
217
|
+
const accountIds = listFeishuAccountIds(cfg);
|
|
218
|
+
return accountIds.length > 0 ? accountIds[0] : DEFAULT_ACCOUNT_ID;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ============ Media Helpers ============
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Resolve Feishu file type from MIME type
|
|
225
|
+
*/
|
|
226
|
+
function resolveFeishuFileType(contentType: string): "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream" {
|
|
227
|
+
const mimeMap: Record<string, "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream"> = {
|
|
228
|
+
"audio/opus": "opus",
|
|
229
|
+
"audio/ogg": "opus",
|
|
230
|
+
"video/mp4": "mp4",
|
|
231
|
+
"application/pdf": "pdf",
|
|
232
|
+
"application/msword": "doc",
|
|
233
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "doc",
|
|
234
|
+
"application/vnd.ms-excel": "xls",
|
|
235
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xls",
|
|
236
|
+
"application/vnd.ms-powerpoint": "ppt",
|
|
237
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation": "ppt",
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
return mimeMap[contentType] ?? "stream";
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ============ Target Normalization ============
|
|
244
|
+
|
|
245
|
+
function normalizeFeishuMessagingTarget(raw: string): string | undefined {
|
|
246
|
+
if (!raw) {
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const trimmed = raw.trim();
|
|
251
|
+
|
|
252
|
+
// Already a chat_id or open_id format
|
|
253
|
+
if (trimmed.startsWith("oc_") || trimmed.startsWith("ou_")) {
|
|
254
|
+
return trimmed;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Handle feishu: prefix
|
|
258
|
+
if (trimmed.toLowerCase().startsWith("feishu:")) {
|
|
259
|
+
return trimmed.slice(7).trim();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Handle chat: prefix
|
|
263
|
+
if (trimmed.toLowerCase().startsWith("chat:")) {
|
|
264
|
+
return trimmed.slice(5).trim();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Handle user: prefix
|
|
268
|
+
if (trimmed.toLowerCase().startsWith("user:")) {
|
|
269
|
+
return trimmed.slice(5).trim();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return trimmed;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function looksLikeFeishuTargetId(raw: string, normalized?: string): boolean {
|
|
276
|
+
const id = normalized ?? raw;
|
|
277
|
+
// Feishu IDs typically start with oc_ (chat) or ou_ (user)
|
|
278
|
+
return id.startsWith("oc_") || id.startsWith("ou_");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ============ Channel Plugin ============
|
|
282
|
+
|
|
283
|
+
export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|
284
|
+
id: "feishu",
|
|
285
|
+
meta,
|
|
286
|
+
capabilities,
|
|
287
|
+
streaming: {
|
|
288
|
+
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
|
|
289
|
+
},
|
|
290
|
+
reload: { configPrefixes: ["channels.feishu"] },
|
|
291
|
+
|
|
292
|
+
// Configuration adapter
|
|
293
|
+
config: {
|
|
294
|
+
listAccountIds: (cfg) => listFeishuAccountIds(cfg),
|
|
295
|
+
resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg, accountId }),
|
|
296
|
+
defaultAccountId: (cfg) => resolveDefaultFeishuAccountId(cfg),
|
|
297
|
+
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
|
298
|
+
setAccountEnabledInConfigSection({
|
|
299
|
+
cfg,
|
|
300
|
+
sectionKey: "feishu",
|
|
301
|
+
accountId,
|
|
302
|
+
enabled,
|
|
303
|
+
allowTopLevel: true,
|
|
304
|
+
}),
|
|
305
|
+
deleteAccount: ({ cfg, accountId }) =>
|
|
306
|
+
deleteAccountFromConfigSection({
|
|
307
|
+
cfg,
|
|
308
|
+
sectionKey: "feishu",
|
|
309
|
+
accountId,
|
|
310
|
+
clearBaseFields: ["appId", "appSecret", "webhookPath", "verificationToken", "encryptKey", "name"],
|
|
311
|
+
}),
|
|
312
|
+
isConfigured: (account) => account.configured,
|
|
313
|
+
describeAccount: (account) => ({
|
|
314
|
+
accountId: account.accountId,
|
|
315
|
+
name: account.name,
|
|
316
|
+
enabled: account.enabled,
|
|
317
|
+
configured: account.configured,
|
|
318
|
+
appIdSource: account.appIdSource,
|
|
319
|
+
appSecretSource: account.appSecretSource,
|
|
320
|
+
}),
|
|
321
|
+
resolveAllowFrom: ({ cfg, accountId }) =>
|
|
322
|
+
(resolveFeishuAccount({ cfg, accountId }).config.allowFrom ?? []).map((entry) => String(entry)),
|
|
323
|
+
formatAllowFrom: ({ allowFrom }) =>
|
|
324
|
+
allowFrom
|
|
325
|
+
.map((entry) => String(entry).trim())
|
|
326
|
+
.filter(Boolean)
|
|
327
|
+
.map((entry) => entry.toLowerCase()),
|
|
328
|
+
},
|
|
329
|
+
|
|
330
|
+
// Pairing adapter
|
|
331
|
+
pairing: {
|
|
332
|
+
idLabel: "feishuUserId",
|
|
333
|
+
normalizeAllowEntry: (entry) => entry.replace(/^feishu:/i, ""),
|
|
334
|
+
notifyApproval: async ({ id, cfg }) => {
|
|
335
|
+
const account = resolveFeishuAccount({ cfg, accountId: DEFAULT_ACCOUNT_ID });
|
|
336
|
+
if (hasValidCredentials(account)) {
|
|
337
|
+
await api.sendText(account, id, PAIRING_APPROVED_MESSAGE, { receiveIdType: "open_id" });
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
|
|
342
|
+
// Security adapter
|
|
343
|
+
security: {
|
|
344
|
+
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
|
345
|
+
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
|
346
|
+
const feishuConfig = getFeishuConfig(cfg);
|
|
347
|
+
const useAccountPath = Boolean(feishuConfig?.accounts?.[resolvedAccountId]);
|
|
348
|
+
const basePath = useAccountPath
|
|
349
|
+
? `channels.feishu.accounts.${resolvedAccountId}.`
|
|
350
|
+
: "channels.feishu.";
|
|
351
|
+
return {
|
|
352
|
+
policy: account.config.dmPolicy ?? "pairing",
|
|
353
|
+
allowFrom: account.config.allowFrom ?? [],
|
|
354
|
+
policyPath: `${basePath}dmPolicy`,
|
|
355
|
+
allowFromPath: basePath,
|
|
356
|
+
approveHint: formatPairingApproveHint("feishu"),
|
|
357
|
+
normalizeEntry: (raw) => raw.replace(/^feishu:/i, "").trim(),
|
|
358
|
+
};
|
|
359
|
+
},
|
|
360
|
+
collectWarnings: ({ account, cfg }) => {
|
|
361
|
+
const warnings: string[] = [];
|
|
362
|
+
const defaultGroupPolicy = (cfg.channels as Record<string, { groupPolicy?: string }>)?.defaults?.groupPolicy;
|
|
363
|
+
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
|
364
|
+
|
|
365
|
+
if (groupPolicy === "open") {
|
|
366
|
+
warnings.push(
|
|
367
|
+
`- Feishu groups: groupPolicy="open" allows any group member to trigger the bot. Set channels.feishu.groupPolicy="allowlist" to restrict access.`,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return warnings;
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
|
|
375
|
+
// Messaging adapter
|
|
376
|
+
messaging: {
|
|
377
|
+
normalizeTarget: normalizeFeishuMessagingTarget,
|
|
378
|
+
targetResolver: {
|
|
379
|
+
looksLikeId: looksLikeFeishuTargetId,
|
|
380
|
+
hint: "<chat_id|open_id|feishu:ID>",
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
|
|
384
|
+
// Setup adapter
|
|
385
|
+
setup: {
|
|
386
|
+
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
|
387
|
+
applyAccountName: ({ cfg, accountId, name }) =>
|
|
388
|
+
applyAccountNameToChannelSection({
|
|
389
|
+
cfg,
|
|
390
|
+
channelKey: "feishu",
|
|
391
|
+
accountId,
|
|
392
|
+
name,
|
|
393
|
+
}),
|
|
394
|
+
validateInput: ({ input }) => {
|
|
395
|
+
// Check for app credentials
|
|
396
|
+
const hasAppId = Boolean(input.token || input.useEnv);
|
|
397
|
+
const hasAppSecret = Boolean(input.tokenFile || input.useEnv);
|
|
398
|
+
|
|
399
|
+
if (!hasAppId && !hasAppSecret && !input.useEnv) {
|
|
400
|
+
return "Feishu requires --app-id and --app-secret (or --use-env with FEISHU_APP_ID and FEISHU_APP_SECRET).";
|
|
401
|
+
}
|
|
402
|
+
return null;
|
|
403
|
+
},
|
|
404
|
+
applyAccountConfig: ({ cfg, accountId, input }) => {
|
|
405
|
+
const namedConfig = applyAccountNameToChannelSection({
|
|
406
|
+
cfg,
|
|
407
|
+
channelKey: "feishu",
|
|
408
|
+
accountId,
|
|
409
|
+
name: input.name,
|
|
410
|
+
});
|
|
411
|
+
const next =
|
|
412
|
+
accountId !== DEFAULT_ACCOUNT_ID
|
|
413
|
+
? migrateBaseNameToDefaultAccount({
|
|
414
|
+
cfg: namedConfig,
|
|
415
|
+
channelKey: "feishu",
|
|
416
|
+
})
|
|
417
|
+
: namedConfig;
|
|
418
|
+
|
|
419
|
+
const feishuUpdate: Record<string, unknown> = {
|
|
420
|
+
...(next.channels as Record<string, unknown>)?.feishu,
|
|
421
|
+
enabled: true,
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
if (!input.useEnv) {
|
|
425
|
+
if (input.token) {
|
|
426
|
+
feishuUpdate.appId = input.token;
|
|
427
|
+
}
|
|
428
|
+
if (input.tokenFile) {
|
|
429
|
+
feishuUpdate.appSecret = input.tokenFile;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
434
|
+
return {
|
|
435
|
+
...next,
|
|
436
|
+
channels: {
|
|
437
|
+
...(next.channels as Record<string, unknown>),
|
|
438
|
+
feishu: feishuUpdate,
|
|
439
|
+
},
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const existingAccounts = (feishuUpdate as { accounts?: Record<string, unknown> }).accounts;
|
|
444
|
+
return {
|
|
445
|
+
...next,
|
|
446
|
+
channels: {
|
|
447
|
+
...(next.channels as Record<string, unknown>),
|
|
448
|
+
feishu: {
|
|
449
|
+
...feishuUpdate,
|
|
450
|
+
accounts: {
|
|
451
|
+
...existingAccounts,
|
|
452
|
+
[accountId]: {
|
|
453
|
+
...existingAccounts?.[accountId],
|
|
454
|
+
enabled: true,
|
|
455
|
+
...(input.token ? { appId: input.token } : {}),
|
|
456
|
+
...(input.tokenFile ? { appSecret: input.tokenFile } : {}),
|
|
457
|
+
},
|
|
458
|
+
},
|
|
459
|
+
},
|
|
460
|
+
},
|
|
461
|
+
};
|
|
462
|
+
},
|
|
463
|
+
},
|
|
464
|
+
|
|
465
|
+
// Outbound adapter
|
|
466
|
+
outbound: {
|
|
467
|
+
deliveryMode: "direct",
|
|
468
|
+
chunker: null,
|
|
469
|
+
textChunkLimit: 4000,
|
|
470
|
+
sendText: async ({ to, text, accountId, cfg, replyToId }) => {
|
|
471
|
+
const account = resolveFeishuAccount({ cfg, accountId });
|
|
472
|
+
|
|
473
|
+
if (!hasValidCredentials(account)) {
|
|
474
|
+
return {
|
|
475
|
+
channel: "feishu",
|
|
476
|
+
ok: false,
|
|
477
|
+
error: "Feishu account not configured (missing appId or appSecret)",
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Determine receive_id_type based on target format
|
|
482
|
+
const receiveIdType = to.startsWith("ou_") ? "open_id" : "chat_id";
|
|
483
|
+
|
|
484
|
+
const result = await api.sendText(account, to, text, {
|
|
485
|
+
receiveIdType,
|
|
486
|
+
replyToId: replyToId ?? undefined,
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
return {
|
|
490
|
+
channel: "feishu",
|
|
491
|
+
ok: result.ok,
|
|
492
|
+
messageId: result.messageId,
|
|
493
|
+
error: result.error,
|
|
494
|
+
};
|
|
495
|
+
},
|
|
496
|
+
sendMedia: async ({ to, text, mediaUrl, accountId, cfg, replyToId }) => {
|
|
497
|
+
const account = resolveFeishuAccount({ cfg, accountId });
|
|
498
|
+
|
|
499
|
+
if (!hasValidCredentials(account)) {
|
|
500
|
+
return {
|
|
501
|
+
channel: "feishu",
|
|
502
|
+
ok: false,
|
|
503
|
+
error: "Feishu account not configured (missing appId or appSecret)",
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const receiveIdType = to.startsWith("ou_") ? "open_id" : "chat_id";
|
|
508
|
+
const mediaMaxBytes = (account.config.mediaMaxMb ?? 30) * 1024 * 1024;
|
|
509
|
+
|
|
510
|
+
// If no media URL, just send text
|
|
511
|
+
if (!mediaUrl) {
|
|
512
|
+
const result = await api.sendText(account, to, text, {
|
|
513
|
+
receiveIdType,
|
|
514
|
+
replyToId: replyToId ?? undefined,
|
|
515
|
+
});
|
|
516
|
+
return {
|
|
517
|
+
channel: "feishu",
|
|
518
|
+
ok: result.ok,
|
|
519
|
+
messageId: result.messageId,
|
|
520
|
+
error: result.error,
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
try {
|
|
525
|
+
// Load media from URL or local path
|
|
526
|
+
const media = await loadWebMedia(mediaUrl, mediaMaxBytes);
|
|
527
|
+
|
|
528
|
+
if (!media.ok) {
|
|
529
|
+
// Fallback to sending URL as text
|
|
530
|
+
const messageText = text ? `${text}\n\n${mediaUrl}` : mediaUrl;
|
|
531
|
+
const result = await api.sendText(account, to, messageText, {
|
|
532
|
+
receiveIdType,
|
|
533
|
+
replyToId: replyToId ?? undefined,
|
|
534
|
+
});
|
|
535
|
+
return {
|
|
536
|
+
channel: "feishu",
|
|
537
|
+
ok: result.ok,
|
|
538
|
+
messageId: result.messageId,
|
|
539
|
+
error: media.error,
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Determine if it's an image based on content type
|
|
544
|
+
const isImage = media.contentType?.startsWith("image/") ?? false;
|
|
545
|
+
const fileName = media.fileName ?? "file";
|
|
546
|
+
|
|
547
|
+
// Send caption text first if provided
|
|
548
|
+
if (text) {
|
|
549
|
+
await api.sendText(account, to, text, {
|
|
550
|
+
receiveIdType,
|
|
551
|
+
replyToId: replyToId ?? undefined,
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (isImage) {
|
|
556
|
+
// Upload and send image
|
|
557
|
+
const uploadResult = await api.uploadImage(account, media.buffer, fileName);
|
|
558
|
+
if (!uploadResult.ok || !uploadResult.imageKey) {
|
|
559
|
+
return {
|
|
560
|
+
channel: "feishu",
|
|
561
|
+
ok: false,
|
|
562
|
+
error: uploadResult.error ?? "Failed to upload image",
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const sendResult = await api.sendImage(account, to, uploadResult.imageKey, {
|
|
567
|
+
receiveIdType,
|
|
568
|
+
replyToId: text ? undefined : replyToId ?? undefined,
|
|
569
|
+
});
|
|
570
|
+
return {
|
|
571
|
+
channel: "feishu",
|
|
572
|
+
ok: sendResult.ok,
|
|
573
|
+
messageId: sendResult.messageId,
|
|
574
|
+
error: sendResult.error,
|
|
575
|
+
};
|
|
576
|
+
} else {
|
|
577
|
+
// Upload and send file
|
|
578
|
+
// Determine file type for Feishu API
|
|
579
|
+
const fileType = resolveFeishuFileType(media.contentType ?? "application/octet-stream");
|
|
580
|
+
const uploadResult = await api.uploadFile(account, media.buffer, fileName, fileType);
|
|
581
|
+
|
|
582
|
+
if (!uploadResult.ok || !uploadResult.fileKey) {
|
|
583
|
+
return {
|
|
584
|
+
channel: "feishu",
|
|
585
|
+
ok: false,
|
|
586
|
+
error: uploadResult.error ?? "Failed to upload file",
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const sendResult = await api.sendFile(account, to, uploadResult.fileKey, {
|
|
591
|
+
receiveIdType,
|
|
592
|
+
replyToId: text ? undefined : replyToId ?? undefined,
|
|
593
|
+
});
|
|
594
|
+
return {
|
|
595
|
+
channel: "feishu",
|
|
596
|
+
ok: sendResult.ok,
|
|
597
|
+
messageId: sendResult.messageId,
|
|
598
|
+
error: sendResult.error,
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
} catch (err) {
|
|
602
|
+
// Fallback to sending URL as text on error
|
|
603
|
+
const messageText = text ? `${text}\n\n${mediaUrl}` : mediaUrl;
|
|
604
|
+
const result = await api.sendText(account, to, messageText, {
|
|
605
|
+
receiveIdType,
|
|
606
|
+
replyToId: replyToId ?? undefined,
|
|
607
|
+
});
|
|
608
|
+
return {
|
|
609
|
+
channel: "feishu",
|
|
610
|
+
ok: result.ok,
|
|
611
|
+
messageId: result.messageId,
|
|
612
|
+
error: err instanceof Error ? err.message : String(err),
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
},
|
|
616
|
+
},
|
|
617
|
+
|
|
618
|
+
// Status adapter
|
|
619
|
+
status: {
|
|
620
|
+
defaultRuntime: {
|
|
621
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
622
|
+
running: false,
|
|
623
|
+
lastStartAt: null,
|
|
624
|
+
lastStopAt: null,
|
|
625
|
+
lastError: null,
|
|
626
|
+
},
|
|
627
|
+
collectStatusIssues: (accounts) =>
|
|
628
|
+
accounts.flatMap((account) => {
|
|
629
|
+
const lastError = typeof account.lastError === "string" ? account.lastError.trim() : "";
|
|
630
|
+
if (!lastError) {
|
|
631
|
+
return [];
|
|
632
|
+
}
|
|
633
|
+
return [
|
|
634
|
+
{
|
|
635
|
+
channel: "feishu",
|
|
636
|
+
accountId: account.accountId,
|
|
637
|
+
kind: "runtime",
|
|
638
|
+
message: `Channel error: ${lastError}`,
|
|
639
|
+
},
|
|
640
|
+
];
|
|
641
|
+
}),
|
|
642
|
+
buildChannelSummary: ({ snapshot }) => ({
|
|
643
|
+
configured: snapshot.configured ?? false,
|
|
644
|
+
appIdSource: snapshot.appIdSource ?? "none",
|
|
645
|
+
appSecretSource: snapshot.appSecretSource ?? "none",
|
|
646
|
+
running: snapshot.running ?? false,
|
|
647
|
+
lastStartAt: snapshot.lastStartAt ?? null,
|
|
648
|
+
lastStopAt: snapshot.lastStopAt ?? null,
|
|
649
|
+
lastError: snapshot.lastError ?? null,
|
|
650
|
+
probe: snapshot.probe,
|
|
651
|
+
lastProbeAt: snapshot.lastProbeAt ?? null,
|
|
652
|
+
}),
|
|
653
|
+
probeAccount: async ({ account, timeoutMs }) => {
|
|
654
|
+
if (!hasValidCredentials(account)) {
|
|
655
|
+
return { ok: false, error: "missing credentials" };
|
|
656
|
+
}
|
|
657
|
+
return await api.probeFeishu(account, timeoutMs);
|
|
658
|
+
},
|
|
659
|
+
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
|
660
|
+
accountId: account.accountId,
|
|
661
|
+
name: account.name,
|
|
662
|
+
enabled: account.enabled,
|
|
663
|
+
configured: account.configured,
|
|
664
|
+
appIdSource: account.appIdSource,
|
|
665
|
+
appSecretSource: account.appSecretSource,
|
|
666
|
+
running: runtime?.running ?? false,
|
|
667
|
+
lastStartAt: runtime?.lastStartAt ?? null,
|
|
668
|
+
lastStopAt: runtime?.lastStopAt ?? null,
|
|
669
|
+
lastError: runtime?.lastError ?? null,
|
|
670
|
+
probe,
|
|
671
|
+
lastInboundAt: runtime?.lastInboundAt ?? null,
|
|
672
|
+
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
|
673
|
+
}),
|
|
674
|
+
},
|
|
675
|
+
|
|
676
|
+
// Gateway adapter
|
|
677
|
+
gateway: {
|
|
678
|
+
startAccount: async (ctx) => {
|
|
679
|
+
const account = ctx.account;
|
|
680
|
+
const connectionMode = account.connectionMode;
|
|
681
|
+
const logPrefix = `[${account.accountId}]`;
|
|
682
|
+
|
|
683
|
+
ctx.log?.info(`${logPrefix} starting Feishu provider (mode: ${connectionMode})`);
|
|
684
|
+
|
|
685
|
+
// Set initial status
|
|
686
|
+
ctx.setStatus({
|
|
687
|
+
accountId: account.accountId,
|
|
688
|
+
configured: account.configured,
|
|
689
|
+
appIdSource: account.appIdSource,
|
|
690
|
+
appSecretSource: account.appSecretSource,
|
|
691
|
+
connectionMode,
|
|
692
|
+
running: true,
|
|
693
|
+
lastStartAt: Date.now(),
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
if (!hasValidCredentials(account)) {
|
|
697
|
+
ctx.log?.error(`${logPrefix} Feishu credentials not configured`);
|
|
698
|
+
ctx.setStatus({
|
|
699
|
+
...ctx.getStatus(),
|
|
700
|
+
running: false,
|
|
701
|
+
lastError: "Credentials not configured",
|
|
702
|
+
});
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Probe the API to verify credentials and get bot info
|
|
707
|
+
const probe = await api.probeFeishu(account);
|
|
708
|
+
if (!probe.ok) {
|
|
709
|
+
ctx.log?.error(`${logPrefix} Feishu API probe failed: ${probe.error}`);
|
|
710
|
+
ctx.setStatus({
|
|
711
|
+
...ctx.getStatus(),
|
|
712
|
+
running: false,
|
|
713
|
+
lastError: probe.error,
|
|
714
|
+
});
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Get bot's open_id for mention detection
|
|
719
|
+
const botOpenId = (probe as { bot?: { open_id?: string } }).bot?.open_id;
|
|
720
|
+
|
|
721
|
+
// Initialize message deduplication cache
|
|
722
|
+
const dedupe = createFeishuDedupeCache();
|
|
723
|
+
|
|
724
|
+
// Initialize group history manager
|
|
725
|
+
const history = createGroupHistoryManager();
|
|
726
|
+
|
|
727
|
+
// Create reply senders
|
|
728
|
+
const sendReply = createDefaultReplySender(account);
|
|
729
|
+
const sendMedia = createDefaultMediaSender(account);
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Handle inbound message - build context, dispatch to agent, send reply
|
|
733
|
+
*/
|
|
734
|
+
async function handleInboundMessage(inbound: FeishuInboundMessage): Promise<void> {
|
|
735
|
+
// Deduplication check
|
|
736
|
+
if (dedupe.isProcessed(inbound.messageId)) {
|
|
737
|
+
ctx.log?.debug(`${logPrefix} skipping duplicate message: ${inbound.messageId}`);
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
dedupe.markProcessed(inbound.messageId);
|
|
741
|
+
|
|
742
|
+
ctx.log?.info(`${logPrefix} received: ${inbound.text?.slice(0, 50)}...`);
|
|
743
|
+
ctx.setStatus({ ...ctx.getStatus(), lastInboundAt: Date.now() });
|
|
744
|
+
|
|
745
|
+
// Fetch reply context if this is a reply
|
|
746
|
+
const messageWithContext = await fetchReplyContext(account, inbound, ctx.log);
|
|
747
|
+
|
|
748
|
+
// Prepare group history
|
|
749
|
+
const isGroup = inbound.chatType === "group";
|
|
750
|
+
const historyEntry: HistoryEntry | undefined = isGroup
|
|
751
|
+
? {
|
|
752
|
+
sender: inbound.senderOpenId ?? inbound.senderId,
|
|
753
|
+
body: inbound.displayText ?? inbound.text ?? "",
|
|
754
|
+
timestamp: inbound.createTime,
|
|
755
|
+
messageId: inbound.messageId,
|
|
756
|
+
}
|
|
757
|
+
: undefined;
|
|
758
|
+
|
|
759
|
+
// Get pending history for context
|
|
760
|
+
const pendingHistory = isGroup ? history.get(inbound.chatId) : [];
|
|
761
|
+
|
|
762
|
+
try {
|
|
763
|
+
// Build message context using local module
|
|
764
|
+
const context = await buildFeishuMessageContext({
|
|
765
|
+
message: messageWithContext,
|
|
766
|
+
account,
|
|
767
|
+
cfg: ctx.cfg,
|
|
768
|
+
botOpenId,
|
|
769
|
+
pendingHistory,
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
if (!context) {
|
|
773
|
+
// Not a direct message to bot - record to history for future context
|
|
774
|
+
if (historyEntry) {
|
|
775
|
+
history.record(inbound.chatId, historyEntry);
|
|
776
|
+
}
|
|
777
|
+
ctx.log?.debug(`${logPrefix} message blocked by policy`);
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// Dispatch to agent using local module
|
|
782
|
+
await dispatchFeishuMessage({
|
|
783
|
+
context,
|
|
784
|
+
cfg: ctx.cfg,
|
|
785
|
+
onSendReply: async (params) => {
|
|
786
|
+
const result = await sendReply(params);
|
|
787
|
+
ctx.setStatus({ ...ctx.getStatus(), lastOutboundAt: Date.now() });
|
|
788
|
+
return result;
|
|
789
|
+
},
|
|
790
|
+
onSendMedia: async (params) => {
|
|
791
|
+
const result = await sendMedia(params);
|
|
792
|
+
ctx.setStatus({ ...ctx.getStatus(), lastOutboundAt: Date.now() });
|
|
793
|
+
return result;
|
|
794
|
+
},
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
// Clear group history after bot replies
|
|
798
|
+
if (isGroup) {
|
|
799
|
+
history.clear(inbound.chatId);
|
|
800
|
+
}
|
|
801
|
+
} catch (err) {
|
|
802
|
+
ctx.log?.error(`${logPrefix} message handling failed: ${String(err)}`);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// WebSocket mode: establish long connection
|
|
807
|
+
if (connectionMode === "websocket") {
|
|
808
|
+
let wsClient: FeishuWSClient | null = null;
|
|
809
|
+
|
|
810
|
+
try {
|
|
811
|
+
wsClient = await createWSClient({
|
|
812
|
+
account,
|
|
813
|
+
onMessage: async (event) => {
|
|
814
|
+
ctx.log?.debug(`${logPrefix} received message via WebSocket`);
|
|
815
|
+
const result = parseWebhookEvent(event, account);
|
|
816
|
+
if (result.type === "message" && result.message) {
|
|
817
|
+
await handleInboundMessage(result.message as FeishuInboundMessage);
|
|
818
|
+
}
|
|
819
|
+
},
|
|
820
|
+
onStateChange: (state) => {
|
|
821
|
+
ctx.log?.info(`${logPrefix} WebSocket state: ${state}`);
|
|
822
|
+
ctx.setStatus({
|
|
823
|
+
...ctx.getStatus(),
|
|
824
|
+
wsState: state,
|
|
825
|
+
running: state === "connected" || state === "reconnecting",
|
|
826
|
+
});
|
|
827
|
+
},
|
|
828
|
+
onError: (error) => {
|
|
829
|
+
ctx.log?.error(`${logPrefix} WebSocket error: ${error.message}`);
|
|
830
|
+
},
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
await wsClient.start();
|
|
834
|
+
ctx.log?.info(`${logPrefix} Feishu WebSocket connected`);
|
|
835
|
+
|
|
836
|
+
// Keep running until abort
|
|
837
|
+
return new Promise<void>((resolve) => {
|
|
838
|
+
ctx.abortSignal.addEventListener("abort", async () => {
|
|
839
|
+
ctx.log?.info(`${logPrefix} stopping Feishu WebSocket`);
|
|
840
|
+
if (wsClient) {
|
|
841
|
+
await wsClient.stop();
|
|
842
|
+
}
|
|
843
|
+
ctx.setStatus({
|
|
844
|
+
...ctx.getStatus(),
|
|
845
|
+
running: false,
|
|
846
|
+
wsState: "disconnected",
|
|
847
|
+
lastStopAt: Date.now(),
|
|
848
|
+
});
|
|
849
|
+
resolve();
|
|
850
|
+
});
|
|
851
|
+
});
|
|
852
|
+
} catch (err) {
|
|
853
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
854
|
+
ctx.log?.error(`${logPrefix} WebSocket connection failed: ${errorMsg}`);
|
|
855
|
+
ctx.setStatus({
|
|
856
|
+
...ctx.getStatus(),
|
|
857
|
+
running: false,
|
|
858
|
+
lastError: `WebSocket: ${errorMsg}`,
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
// Fall back to webhook mode if WebSocket fails
|
|
862
|
+
ctx.log?.info(`${logPrefix} falling back to webhook mode`);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// Webhook mode: wait for incoming HTTP requests
|
|
867
|
+
ctx.log?.info(`${logPrefix} Feishu provider started in webhook mode`);
|
|
868
|
+
|
|
869
|
+
// Keep the gateway running until abort
|
|
870
|
+
return new Promise<void>((resolve) => {
|
|
871
|
+
ctx.abortSignal.addEventListener("abort", () => {
|
|
872
|
+
ctx.log?.info(`${logPrefix} Feishu provider stopping`);
|
|
873
|
+
ctx.setStatus({
|
|
874
|
+
...ctx.getStatus(),
|
|
875
|
+
running: false,
|
|
876
|
+
lastStopAt: Date.now(),
|
|
877
|
+
});
|
|
878
|
+
resolve();
|
|
879
|
+
});
|
|
880
|
+
});
|
|
881
|
+
},
|
|
882
|
+
},
|
|
883
|
+
};
|