@openclaw/feishu 2026.3.11 → 2026.3.12
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/package.json +1 -1
- package/src/accounts.test.ts +19 -0
- package/src/accounts.ts +5 -1
- package/src/channel.ts +2 -2
- package/src/config-schema.test.ts +63 -2
- package/src/config-schema.ts +30 -9
- package/src/monitor.account.ts +20 -4
- package/src/monitor.reaction.test.ts +30 -3
- package/src/monitor.webhook-security.test.ts +19 -0
- package/src/onboarding.ts +31 -0
- package/src/send.ts +7 -12
- package/src/types.ts +14 -0
package/package.json
CHANGED
package/src/accounts.test.ts
CHANGED
|
@@ -241,6 +241,25 @@ describe("resolveFeishuCredentials", () => {
|
|
|
241
241
|
domain: "feishu",
|
|
242
242
|
});
|
|
243
243
|
});
|
|
244
|
+
|
|
245
|
+
it("does not resolve encryptKey SecretRefs outside webhook mode", () => {
|
|
246
|
+
const creds = resolveFeishuCredentials(
|
|
247
|
+
asConfig({
|
|
248
|
+
connectionMode: "websocket",
|
|
249
|
+
appId: "cli_123",
|
|
250
|
+
appSecret: "secret_456",
|
|
251
|
+
encryptKey: { source: "file", provider: "default", id: "path/to/secret" } as never,
|
|
252
|
+
}),
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
expect(creds).toEqual({
|
|
256
|
+
appId: "cli_123",
|
|
257
|
+
appSecret: "secret_456", // pragma: allowlist secret
|
|
258
|
+
encryptKey: undefined,
|
|
259
|
+
verificationToken: undefined,
|
|
260
|
+
domain: "feishu",
|
|
261
|
+
});
|
|
262
|
+
});
|
|
244
263
|
});
|
|
245
264
|
|
|
246
265
|
describe("resolveFeishuAccount", () => {
|
package/src/accounts.ts
CHANGED
|
@@ -169,10 +169,14 @@ export function resolveFeishuCredentials(
|
|
|
169
169
|
if (!appId || !appSecret) {
|
|
170
170
|
return null;
|
|
171
171
|
}
|
|
172
|
+
const connectionMode = cfg?.connectionMode ?? "websocket";
|
|
172
173
|
return {
|
|
173
174
|
appId,
|
|
174
175
|
appSecret,
|
|
175
|
-
encryptKey:
|
|
176
|
+
encryptKey:
|
|
177
|
+
connectionMode === "webhook"
|
|
178
|
+
? resolveSecretLike(cfg?.encryptKey, "channels.feishu.encryptKey")
|
|
179
|
+
: normalizeString(cfg?.encryptKey),
|
|
176
180
|
verificationToken: resolveSecretLike(
|
|
177
181
|
cfg?.verificationToken,
|
|
178
182
|
"channels.feishu.verificationToken",
|
package/src/channel.ts
CHANGED
|
@@ -129,7 +129,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|
|
129
129
|
defaultAccount: { type: "string" },
|
|
130
130
|
appId: { type: "string" },
|
|
131
131
|
appSecret: secretInputJsonSchema,
|
|
132
|
-
encryptKey:
|
|
132
|
+
encryptKey: secretInputJsonSchema,
|
|
133
133
|
verificationToken: secretInputJsonSchema,
|
|
134
134
|
domain: {
|
|
135
135
|
oneOf: [
|
|
@@ -170,7 +170,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|
|
170
170
|
name: { type: "string" },
|
|
171
171
|
appId: { type: "string" },
|
|
172
172
|
appSecret: secretInputJsonSchema,
|
|
173
|
-
encryptKey:
|
|
173
|
+
encryptKey: secretInputJsonSchema,
|
|
174
174
|
verificationToken: secretInputJsonSchema,
|
|
175
175
|
domain: { type: "string", enum: ["feishu", "lark"] },
|
|
176
176
|
connectionMode: { type: "string", enum: ["websocket", "webhook"] },
|
|
@@ -47,7 +47,7 @@ describe("FeishuConfigSchema webhook validation", () => {
|
|
|
47
47
|
}
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
-
it("
|
|
50
|
+
it("rejects top-level webhook mode without encryptKey", () => {
|
|
51
51
|
const result = FeishuConfigSchema.safeParse({
|
|
52
52
|
connectionMode: "webhook",
|
|
53
53
|
verificationToken: "token_top",
|
|
@@ -55,6 +55,21 @@ describe("FeishuConfigSchema webhook validation", () => {
|
|
|
55
55
|
appSecret: "secret_top", // pragma: allowlist secret
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
+
expect(result.success).toBe(false);
|
|
59
|
+
if (!result.success) {
|
|
60
|
+
expect(result.error.issues.some((issue) => issue.path.join(".") === "encryptKey")).toBe(true);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("accepts top-level webhook mode with verificationToken and encryptKey", () => {
|
|
65
|
+
const result = FeishuConfigSchema.safeParse({
|
|
66
|
+
connectionMode: "webhook",
|
|
67
|
+
verificationToken: "token_top",
|
|
68
|
+
encryptKey: "encrypt_top",
|
|
69
|
+
appId: "cli_top",
|
|
70
|
+
appSecret: "secret_top", // pragma: allowlist secret
|
|
71
|
+
});
|
|
72
|
+
|
|
58
73
|
expect(result.success).toBe(true);
|
|
59
74
|
});
|
|
60
75
|
|
|
@@ -79,9 +94,30 @@ describe("FeishuConfigSchema webhook validation", () => {
|
|
|
79
94
|
}
|
|
80
95
|
});
|
|
81
96
|
|
|
82
|
-
it("
|
|
97
|
+
it("rejects account webhook mode without encryptKey", () => {
|
|
98
|
+
const result = FeishuConfigSchema.safeParse({
|
|
99
|
+
accounts: {
|
|
100
|
+
main: {
|
|
101
|
+
connectionMode: "webhook",
|
|
102
|
+
verificationToken: "token_main",
|
|
103
|
+
appId: "cli_main",
|
|
104
|
+
appSecret: "secret_main", // pragma: allowlist secret
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(result.success).toBe(false);
|
|
110
|
+
if (!result.success) {
|
|
111
|
+
expect(
|
|
112
|
+
result.error.issues.some((issue) => issue.path.join(".") === "accounts.main.encryptKey"),
|
|
113
|
+
).toBe(true);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("accepts account webhook mode inheriting top-level verificationToken and encryptKey", () => {
|
|
83
118
|
const result = FeishuConfigSchema.safeParse({
|
|
84
119
|
verificationToken: "token_top",
|
|
120
|
+
encryptKey: "encrypt_top",
|
|
85
121
|
accounts: {
|
|
86
122
|
main: {
|
|
87
123
|
connectionMode: "webhook",
|
|
@@ -102,6 +138,31 @@ describe("FeishuConfigSchema webhook validation", () => {
|
|
|
102
138
|
provider: "default",
|
|
103
139
|
id: "FEISHU_VERIFICATION_TOKEN",
|
|
104
140
|
},
|
|
141
|
+
encryptKey: "encrypt_top",
|
|
142
|
+
appId: "cli_top",
|
|
143
|
+
appSecret: {
|
|
144
|
+
source: "env",
|
|
145
|
+
provider: "default",
|
|
146
|
+
id: "FEISHU_APP_SECRET",
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
expect(result.success).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("accepts SecretRef encryptKey in webhook mode", () => {
|
|
154
|
+
const result = FeishuConfigSchema.safeParse({
|
|
155
|
+
connectionMode: "webhook",
|
|
156
|
+
verificationToken: {
|
|
157
|
+
source: "env",
|
|
158
|
+
provider: "default",
|
|
159
|
+
id: "FEISHU_VERIFICATION_TOKEN",
|
|
160
|
+
},
|
|
161
|
+
encryptKey: {
|
|
162
|
+
source: "env",
|
|
163
|
+
provider: "default",
|
|
164
|
+
id: "FEISHU_ENCRYPT_KEY",
|
|
165
|
+
},
|
|
105
166
|
appId: "cli_top",
|
|
106
167
|
appSecret: {
|
|
107
168
|
source: "env",
|
package/src/config-schema.ts
CHANGED
|
@@ -186,7 +186,7 @@ export const FeishuAccountConfigSchema = z
|
|
|
186
186
|
name: z.string().optional(), // Display name for this account
|
|
187
187
|
appId: z.string().optional(),
|
|
188
188
|
appSecret: buildSecretInputSchema().optional(),
|
|
189
|
-
encryptKey:
|
|
189
|
+
encryptKey: buildSecretInputSchema().optional(),
|
|
190
190
|
verificationToken: buildSecretInputSchema().optional(),
|
|
191
191
|
domain: FeishuDomainSchema.optional(),
|
|
192
192
|
connectionMode: FeishuConnectionModeSchema.optional(),
|
|
@@ -204,7 +204,7 @@ export const FeishuConfigSchema = z
|
|
|
204
204
|
// Top-level credentials (backward compatible for single-account mode)
|
|
205
205
|
appId: z.string().optional(),
|
|
206
206
|
appSecret: buildSecretInputSchema().optional(),
|
|
207
|
-
encryptKey:
|
|
207
|
+
encryptKey: buildSecretInputSchema().optional(),
|
|
208
208
|
verificationToken: buildSecretInputSchema().optional(),
|
|
209
209
|
domain: FeishuDomainSchema.optional().default("feishu"),
|
|
210
210
|
connectionMode: FeishuConnectionModeSchema.optional().default("websocket"),
|
|
@@ -240,13 +240,23 @@ export const FeishuConfigSchema = z
|
|
|
240
240
|
|
|
241
241
|
const defaultConnectionMode = value.connectionMode ?? "websocket";
|
|
242
242
|
const defaultVerificationTokenConfigured = hasConfiguredSecretInput(value.verificationToken);
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
243
|
+
const defaultEncryptKeyConfigured = hasConfiguredSecretInput(value.encryptKey);
|
|
244
|
+
if (defaultConnectionMode === "webhook") {
|
|
245
|
+
if (!defaultVerificationTokenConfigured) {
|
|
246
|
+
ctx.addIssue({
|
|
247
|
+
code: z.ZodIssueCode.custom,
|
|
248
|
+
path: ["verificationToken"],
|
|
249
|
+
message:
|
|
250
|
+
'channels.feishu.connectionMode="webhook" requires channels.feishu.verificationToken',
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
if (!defaultEncryptKeyConfigured) {
|
|
254
|
+
ctx.addIssue({
|
|
255
|
+
code: z.ZodIssueCode.custom,
|
|
256
|
+
path: ["encryptKey"],
|
|
257
|
+
message: 'channels.feishu.connectionMode="webhook" requires channels.feishu.encryptKey',
|
|
258
|
+
});
|
|
259
|
+
}
|
|
250
260
|
}
|
|
251
261
|
|
|
252
262
|
for (const [accountId, account] of Object.entries(value.accounts ?? {})) {
|
|
@@ -259,6 +269,8 @@ export const FeishuConfigSchema = z
|
|
|
259
269
|
}
|
|
260
270
|
const accountVerificationTokenConfigured =
|
|
261
271
|
hasConfiguredSecretInput(account.verificationToken) || defaultVerificationTokenConfigured;
|
|
272
|
+
const accountEncryptKeyConfigured =
|
|
273
|
+
hasConfiguredSecretInput(account.encryptKey) || defaultEncryptKeyConfigured;
|
|
262
274
|
if (!accountVerificationTokenConfigured) {
|
|
263
275
|
ctx.addIssue({
|
|
264
276
|
code: z.ZodIssueCode.custom,
|
|
@@ -268,6 +280,15 @@ export const FeishuConfigSchema = z
|
|
|
268
280
|
"a verificationToken (account-level or top-level)",
|
|
269
281
|
});
|
|
270
282
|
}
|
|
283
|
+
if (!accountEncryptKeyConfigured) {
|
|
284
|
+
ctx.addIssue({
|
|
285
|
+
code: z.ZodIssueCode.custom,
|
|
286
|
+
path: ["accounts", accountId, "encryptKey"],
|
|
287
|
+
message:
|
|
288
|
+
`channels.feishu.accounts.${accountId}.connectionMode="webhook" requires ` +
|
|
289
|
+
"an encryptKey (account-level or top-level)",
|
|
290
|
+
});
|
|
291
|
+
}
|
|
271
292
|
}
|
|
272
293
|
|
|
273
294
|
if (value.dmPolicy === "open") {
|
package/src/monitor.account.ts
CHANGED
|
@@ -24,14 +24,14 @@ import { botNames, botOpenIds } from "./monitor.state.js";
|
|
|
24
24
|
import { monitorWebhook, monitorWebSocket } from "./monitor.transport.js";
|
|
25
25
|
import { getFeishuRuntime } from "./runtime.js";
|
|
26
26
|
import { getMessageFeishu } from "./send.js";
|
|
27
|
-
import type { ResolvedFeishuAccount } from "./types.js";
|
|
27
|
+
import type { FeishuChatType, ResolvedFeishuAccount } from "./types.js";
|
|
28
28
|
|
|
29
29
|
const FEISHU_REACTION_VERIFY_TIMEOUT_MS = 1_500;
|
|
30
30
|
|
|
31
31
|
export type FeishuReactionCreatedEvent = {
|
|
32
32
|
message_id: string;
|
|
33
33
|
chat_id?: string;
|
|
34
|
-
chat_type?:
|
|
34
|
+
chat_type?: string;
|
|
35
35
|
reaction_type?: { emoji_type?: string };
|
|
36
36
|
operator_type?: string;
|
|
37
37
|
user_id?: { open_id?: string };
|
|
@@ -105,10 +105,19 @@ export async function resolveReactionSyntheticEvent(
|
|
|
105
105
|
return null;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
const fallbackChatType = reactedMsg.chatType;
|
|
109
|
+
const normalizedEventChatType = normalizeFeishuChatType(event.chat_type);
|
|
110
|
+
const resolvedChatType = normalizedEventChatType ?? fallbackChatType;
|
|
111
|
+
if (!resolvedChatType) {
|
|
112
|
+
logger?.(
|
|
113
|
+
`feishu[${accountId}]: skipping reaction ${emoji} on ${messageId} without chat type context`,
|
|
114
|
+
);
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
108
118
|
const syntheticChatIdRaw = event.chat_id ?? reactedMsg.chatId;
|
|
109
119
|
const syntheticChatId = syntheticChatIdRaw?.trim() ? syntheticChatIdRaw : `p2p:${senderId}`;
|
|
110
|
-
const syntheticChatType:
|
|
111
|
-
event.chat_type === "group" ? "group" : "p2p";
|
|
120
|
+
const syntheticChatType: FeishuChatType = resolvedChatType;
|
|
112
121
|
return {
|
|
113
122
|
sender: {
|
|
114
123
|
sender_id: { open_id: senderId },
|
|
@@ -126,6 +135,10 @@ export async function resolveReactionSyntheticEvent(
|
|
|
126
135
|
};
|
|
127
136
|
}
|
|
128
137
|
|
|
138
|
+
function normalizeFeishuChatType(value: unknown): FeishuChatType | undefined {
|
|
139
|
+
return value === "group" || value === "private" || value === "p2p" ? value : undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
129
142
|
type RegisterEventHandlersContext = {
|
|
130
143
|
cfg: ClawdbotConfig;
|
|
131
144
|
accountId: string;
|
|
@@ -521,6 +534,9 @@ export async function monitorSingleAccount(params: MonitorSingleAccountParams):
|
|
|
521
534
|
if (connectionMode === "webhook" && !account.verificationToken?.trim()) {
|
|
522
535
|
throw new Error(`Feishu account "${accountId}" webhook mode requires verificationToken`);
|
|
523
536
|
}
|
|
537
|
+
if (connectionMode === "webhook" && !account.encryptKey?.trim()) {
|
|
538
|
+
throw new Error(`Feishu account "${accountId}" webhook mode requires encryptKey`);
|
|
539
|
+
}
|
|
524
540
|
|
|
525
541
|
const warmupCount = await warmupDedupFromDisk(accountId, log);
|
|
526
542
|
if (warmupCount > 0) {
|
|
@@ -51,10 +51,11 @@ function makeReactionEvent(
|
|
|
51
51
|
};
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
function createFetchedReactionMessage(chatId: string) {
|
|
54
|
+
function createFetchedReactionMessage(chatId: string, chatType?: "p2p" | "group" | "private") {
|
|
55
55
|
return {
|
|
56
56
|
messageId: "om_msg1",
|
|
57
57
|
chatId,
|
|
58
|
+
chatType,
|
|
58
59
|
senderOpenId: "ou_bot",
|
|
59
60
|
content: "hello",
|
|
60
61
|
contentType: "text",
|
|
@@ -64,13 +65,15 @@ function createFetchedReactionMessage(chatId: string) {
|
|
|
64
65
|
async function resolveReactionWithLookup(params: {
|
|
65
66
|
event?: FeishuReactionCreatedEvent;
|
|
66
67
|
lookupChatId: string;
|
|
68
|
+
lookupChatType?: "p2p" | "group" | "private";
|
|
67
69
|
}) {
|
|
68
70
|
return await resolveReactionSyntheticEvent({
|
|
69
71
|
cfg,
|
|
70
72
|
accountId: "default",
|
|
71
73
|
event: params.event ?? makeReactionEvent(),
|
|
72
74
|
botOpenId: "ou_bot",
|
|
73
|
-
fetchMessage: async () =>
|
|
75
|
+
fetchMessage: async () =>
|
|
76
|
+
createFetchedReactionMessage(params.lookupChatId, params.lookupChatType),
|
|
74
77
|
uuid: () => "fixed-uuid",
|
|
75
78
|
});
|
|
76
79
|
}
|
|
@@ -268,6 +271,7 @@ describe("resolveReactionSyntheticEvent", () => {
|
|
|
268
271
|
fetchMessage: async () => ({
|
|
269
272
|
messageId: "om_msg1",
|
|
270
273
|
chatId: "oc_group",
|
|
274
|
+
chatType: "group",
|
|
271
275
|
senderOpenId: "ou_other",
|
|
272
276
|
senderType: "user",
|
|
273
277
|
content: "hello",
|
|
@@ -293,6 +297,7 @@ describe("resolveReactionSyntheticEvent", () => {
|
|
|
293
297
|
fetchMessage: async () => ({
|
|
294
298
|
messageId: "om_msg1",
|
|
295
299
|
chatId: "oc_group",
|
|
300
|
+
chatType: "group",
|
|
296
301
|
senderOpenId: "ou_other",
|
|
297
302
|
senderType: "user",
|
|
298
303
|
content: "hello",
|
|
@@ -348,21 +353,43 @@ describe("resolveReactionSyntheticEvent", () => {
|
|
|
348
353
|
it("falls back to reacted message chat_id when event chat_id is absent", async () => {
|
|
349
354
|
const result = await resolveReactionWithLookup({
|
|
350
355
|
lookupChatId: "oc_group_from_lookup",
|
|
356
|
+
lookupChatType: "group",
|
|
351
357
|
});
|
|
352
358
|
|
|
353
359
|
expect(result?.message.chat_id).toBe("oc_group_from_lookup");
|
|
354
|
-
expect(result?.message.chat_type).toBe("
|
|
360
|
+
expect(result?.message.chat_type).toBe("group");
|
|
355
361
|
});
|
|
356
362
|
|
|
357
363
|
it("falls back to sender p2p chat when lookup returns empty chat_id", async () => {
|
|
358
364
|
const result = await resolveReactionWithLookup({
|
|
359
365
|
lookupChatId: "",
|
|
366
|
+
lookupChatType: "p2p",
|
|
360
367
|
});
|
|
361
368
|
|
|
362
369
|
expect(result?.message.chat_id).toBe("p2p:ou_user1");
|
|
363
370
|
expect(result?.message.chat_type).toBe("p2p");
|
|
364
371
|
});
|
|
365
372
|
|
|
373
|
+
it("drops reactions without chat context when lookup does not provide chat_type", async () => {
|
|
374
|
+
const result = await resolveReactionWithLookup({
|
|
375
|
+
lookupChatId: "oc_group_from_lookup",
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
expect(result).toBeNull();
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("drops reactions when event chat_type is invalid and lookup cannot recover it", async () => {
|
|
382
|
+
const result = await resolveReactionWithLookup({
|
|
383
|
+
event: makeReactionEvent({
|
|
384
|
+
chat_id: "oc_group_from_event",
|
|
385
|
+
chat_type: "bogus" as "group",
|
|
386
|
+
}),
|
|
387
|
+
lookupChatId: "oc_group_from_lookup",
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
expect(result).toBeNull();
|
|
391
|
+
});
|
|
392
|
+
|
|
366
393
|
it("logs and drops reactions when lookup throws", async () => {
|
|
367
394
|
const log = vi.fn();
|
|
368
395
|
const event = makeReactionEvent();
|
|
@@ -64,6 +64,7 @@ function buildConfig(params: {
|
|
|
64
64
|
path: string;
|
|
65
65
|
port: number;
|
|
66
66
|
verificationToken?: string;
|
|
67
|
+
encryptKey?: string;
|
|
67
68
|
}): ClawdbotConfig {
|
|
68
69
|
return {
|
|
69
70
|
channels: {
|
|
@@ -78,6 +79,7 @@ function buildConfig(params: {
|
|
|
78
79
|
webhookHost: "127.0.0.1",
|
|
79
80
|
webhookPort: params.port,
|
|
80
81
|
webhookPath: params.path,
|
|
82
|
+
encryptKey: params.encryptKey,
|
|
81
83
|
verificationToken: params.verificationToken,
|
|
82
84
|
},
|
|
83
85
|
},
|
|
@@ -91,6 +93,7 @@ async function withRunningWebhookMonitor(
|
|
|
91
93
|
accountId: string;
|
|
92
94
|
path: string;
|
|
93
95
|
verificationToken: string;
|
|
96
|
+
encryptKey: string;
|
|
94
97
|
},
|
|
95
98
|
run: (url: string) => Promise<void>,
|
|
96
99
|
) {
|
|
@@ -99,6 +102,7 @@ async function withRunningWebhookMonitor(
|
|
|
99
102
|
accountId: params.accountId,
|
|
100
103
|
path: params.path,
|
|
101
104
|
port,
|
|
105
|
+
encryptKey: params.encryptKey,
|
|
102
106
|
verificationToken: params.verificationToken,
|
|
103
107
|
});
|
|
104
108
|
|
|
@@ -141,6 +145,19 @@ describe("Feishu webhook security hardening", () => {
|
|
|
141
145
|
);
|
|
142
146
|
});
|
|
143
147
|
|
|
148
|
+
it("rejects webhook mode without encryptKey", async () => {
|
|
149
|
+
probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
|
|
150
|
+
|
|
151
|
+
const cfg = buildConfig({
|
|
152
|
+
accountId: "missing-encrypt-key",
|
|
153
|
+
path: "/hook-missing-encrypt",
|
|
154
|
+
port: await getFreePort(),
|
|
155
|
+
verificationToken: "verify_token",
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await expect(monitorFeishuProvider({ config: cfg })).rejects.toThrow(/requires encryptKey/i);
|
|
159
|
+
});
|
|
160
|
+
|
|
144
161
|
it("returns 415 for POST requests without json content type", async () => {
|
|
145
162
|
probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
|
|
146
163
|
await withRunningWebhookMonitor(
|
|
@@ -148,6 +165,7 @@ describe("Feishu webhook security hardening", () => {
|
|
|
148
165
|
accountId: "content-type",
|
|
149
166
|
path: "/hook-content-type",
|
|
150
167
|
verificationToken: "verify_token",
|
|
168
|
+
encryptKey: "encrypt_key",
|
|
151
169
|
},
|
|
152
170
|
async (url) => {
|
|
153
171
|
const response = await fetch(url, {
|
|
@@ -169,6 +187,7 @@ describe("Feishu webhook security hardening", () => {
|
|
|
169
187
|
accountId: "rate-limit",
|
|
170
188
|
path: "/hook-rate-limit",
|
|
171
189
|
verificationToken: "verify_token",
|
|
190
|
+
encryptKey: "encrypt_key",
|
|
172
191
|
},
|
|
173
192
|
async (url) => {
|
|
174
193
|
let saw429 = false;
|
package/src/onboarding.ts
CHANGED
|
@@ -370,6 +370,37 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
|
370
370
|
},
|
|
371
371
|
};
|
|
372
372
|
}
|
|
373
|
+
const currentEncryptKey = (next.channels?.feishu as FeishuConfig | undefined)?.encryptKey;
|
|
374
|
+
const encryptKeyPromptState = buildSingleChannelSecretPromptState({
|
|
375
|
+
accountConfigured: hasConfiguredSecretInput(currentEncryptKey),
|
|
376
|
+
hasConfigToken: hasConfiguredSecretInput(currentEncryptKey),
|
|
377
|
+
allowEnv: false,
|
|
378
|
+
});
|
|
379
|
+
const encryptKeyResult = await promptSingleChannelSecretInput({
|
|
380
|
+
cfg: next,
|
|
381
|
+
prompter,
|
|
382
|
+
providerHint: "feishu-webhook",
|
|
383
|
+
credentialLabel: "encrypt key",
|
|
384
|
+
accountConfigured: encryptKeyPromptState.accountConfigured,
|
|
385
|
+
canUseEnv: encryptKeyPromptState.canUseEnv,
|
|
386
|
+
hasConfigToken: encryptKeyPromptState.hasConfigToken,
|
|
387
|
+
envPrompt: "",
|
|
388
|
+
keepPrompt: "Feishu encrypt key already configured. Keep it?",
|
|
389
|
+
inputPrompt: "Enter Feishu encrypt key",
|
|
390
|
+
preferredEnvVar: "FEISHU_ENCRYPT_KEY",
|
|
391
|
+
});
|
|
392
|
+
if (encryptKeyResult.action === "set") {
|
|
393
|
+
next = {
|
|
394
|
+
...next,
|
|
395
|
+
channels: {
|
|
396
|
+
...next.channels,
|
|
397
|
+
feishu: {
|
|
398
|
+
...next.channels?.feishu,
|
|
399
|
+
encryptKey: encryptKeyResult.value,
|
|
400
|
+
},
|
|
401
|
+
},
|
|
402
|
+
};
|
|
403
|
+
}
|
|
373
404
|
const currentWebhookPath = (next.channels?.feishu as FeishuConfig | undefined)?.webhookPath;
|
|
374
405
|
const webhookPath = String(
|
|
375
406
|
await prompter.text({
|
package/src/send.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { parsePostContent } from "./post.js";
|
|
|
7
7
|
import { getFeishuRuntime } from "./runtime.js";
|
|
8
8
|
import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js";
|
|
9
9
|
import { resolveFeishuSendTarget } from "./send-target.js";
|
|
10
|
-
import type { FeishuSendResult } from "./types.js";
|
|
10
|
+
import type { FeishuChatType, FeishuMessageInfo, FeishuSendResult } from "./types.js";
|
|
11
11
|
|
|
12
12
|
const WITHDRAWN_REPLY_ERROR_CODES = new Set([230011, 231003]);
|
|
13
13
|
|
|
@@ -74,17 +74,6 @@ async function sendFallbackDirect(
|
|
|
74
74
|
return toFeishuSendResult(response, params.receiveId);
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
export type FeishuMessageInfo = {
|
|
78
|
-
messageId: string;
|
|
79
|
-
chatId: string;
|
|
80
|
-
senderId?: string;
|
|
81
|
-
senderOpenId?: string;
|
|
82
|
-
senderType?: string;
|
|
83
|
-
content: string;
|
|
84
|
-
contentType: string;
|
|
85
|
-
createTime?: number;
|
|
86
|
-
};
|
|
87
|
-
|
|
88
77
|
function parseInteractiveCardContent(parsed: unknown): string {
|
|
89
78
|
if (!parsed || typeof parsed !== "object") {
|
|
90
79
|
return "[Interactive Card]";
|
|
@@ -184,6 +173,7 @@ export async function getMessageFeishu(params: {
|
|
|
184
173
|
items?: Array<{
|
|
185
174
|
message_id?: string;
|
|
186
175
|
chat_id?: string;
|
|
176
|
+
chat_type?: FeishuChatType;
|
|
187
177
|
msg_type?: string;
|
|
188
178
|
body?: { content?: string };
|
|
189
179
|
sender?: {
|
|
@@ -195,6 +185,7 @@ export async function getMessageFeishu(params: {
|
|
|
195
185
|
}>;
|
|
196
186
|
message_id?: string;
|
|
197
187
|
chat_id?: string;
|
|
188
|
+
chat_type?: FeishuChatType;
|
|
198
189
|
msg_type?: string;
|
|
199
190
|
body?: { content?: string };
|
|
200
191
|
sender?: {
|
|
@@ -228,6 +219,10 @@ export async function getMessageFeishu(params: {
|
|
|
228
219
|
return {
|
|
229
220
|
messageId: item.message_id ?? messageId,
|
|
230
221
|
chatId: item.chat_id ?? "",
|
|
222
|
+
chatType:
|
|
223
|
+
item.chat_type === "group" || item.chat_type === "private" || item.chat_type === "p2p"
|
|
224
|
+
? item.chat_type
|
|
225
|
+
: undefined,
|
|
231
226
|
senderId: item.sender?.id,
|
|
232
227
|
senderOpenId: item.sender?.id_type === "open_id" ? item.sender?.id : undefined,
|
|
233
228
|
senderType: item.sender?.sender_type,
|
package/src/types.ts
CHANGED
|
@@ -60,6 +60,20 @@ export type FeishuSendResult = {
|
|
|
60
60
|
chatId: string;
|
|
61
61
|
};
|
|
62
62
|
|
|
63
|
+
export type FeishuChatType = "p2p" | "group" | "private";
|
|
64
|
+
|
|
65
|
+
export type FeishuMessageInfo = {
|
|
66
|
+
messageId: string;
|
|
67
|
+
chatId: string;
|
|
68
|
+
chatType?: FeishuChatType;
|
|
69
|
+
senderId?: string;
|
|
70
|
+
senderOpenId?: string;
|
|
71
|
+
senderType?: string;
|
|
72
|
+
content: string;
|
|
73
|
+
contentType: string;
|
|
74
|
+
createTime?: number;
|
|
75
|
+
};
|
|
76
|
+
|
|
63
77
|
export type FeishuProbeResult = BaseProbeResult<string> & {
|
|
64
78
|
appId?: string;
|
|
65
79
|
botName?: string;
|