@openclaw/feishu 2026.3.1 → 2026.3.7
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/index.ts +2 -2
- package/package.json +1 -1
- package/src/accounts.test.ts +268 -11
- package/src/accounts.ts +101 -14
- package/src/bitable.ts +40 -28
- package/src/bot.checkBotMentioned.test.ts +9 -1
- package/src/bot.stripBotMention.test.ts +118 -22
- package/src/bot.test.ts +945 -77
- package/src/bot.ts +492 -165
- package/src/card-action.ts +1 -1
- package/src/channel.test.ts +1 -1
- package/src/channel.ts +72 -68
- package/src/chat.test.ts +2 -2
- package/src/chat.ts +1 -1
- package/src/client.test.ts +221 -4
- package/src/client.ts +70 -5
- package/src/config-schema.test.ts +33 -6
- package/src/config-schema.ts +18 -10
- package/src/dedup.ts +47 -1
- package/src/directory.test.ts +40 -0
- package/src/directory.ts +29 -50
- package/src/doc-schema.ts +16 -22
- package/src/docx-batch-insert.test.ts +90 -0
- package/src/docx-batch-insert.ts +8 -11
- package/src/docx.account-selection.test.ts +10 -16
- package/src/docx.test.ts +41 -189
- package/src/docx.ts +1 -1
- package/src/drive.ts +13 -17
- package/src/dynamic-agent.ts +1 -1
- package/src/feishu-command-handler.ts +59 -0
- package/src/media.test.ts +164 -14
- package/src/media.ts +44 -10
- package/src/mention.ts +1 -1
- package/src/monitor.account.ts +284 -25
- package/src/monitor.reaction.test.ts +395 -46
- package/src/monitor.startup.test.ts +25 -8
- package/src/monitor.startup.ts +20 -7
- package/src/monitor.state.defaults.test.ts +46 -0
- package/src/monitor.state.ts +88 -9
- package/src/monitor.test-mocks.ts +45 -0
- package/src/monitor.transport.ts +4 -1
- package/src/monitor.ts +4 -4
- package/src/monitor.webhook-security.test.ts +13 -11
- package/src/onboarding.status.test.ts +25 -0
- package/src/onboarding.test.ts +143 -0
- package/src/onboarding.ts +213 -106
- package/src/outbound.test.ts +178 -0
- package/src/outbound.ts +39 -6
- package/src/perm.ts +11 -15
- package/src/policy.test.ts +40 -0
- package/src/policy.ts +9 -10
- package/src/probe.test.ts +54 -36
- package/src/probe.ts +57 -37
- package/src/reactions.ts +1 -1
- package/src/reply-dispatcher.test.ts +216 -0
- package/src/reply-dispatcher.ts +89 -22
- package/src/runtime.ts +1 -1
- package/src/secret-input.ts +13 -0
- package/src/send-message.ts +71 -0
- package/src/send-target.test.ts +74 -0
- package/src/send-target.ts +7 -3
- package/src/send.reply-fallback.test.ts +74 -0
- package/src/send.test.ts +1 -1
- package/src/send.ts +88 -49
- package/src/streaming-card.test.ts +54 -0
- package/src/streaming-card.ts +96 -28
- package/src/targets.test.ts +29 -0
- package/src/targets.ts +25 -1
- package/src/tool-account-routing.test.ts +3 -3
- package/src/tool-account.ts +1 -1
- package/src/tool-factory-test-harness.ts +1 -1
- package/src/tool-result.test.ts +32 -0
- package/src/tool-result.ts +14 -0
- package/src/types.ts +11 -4
- package/src/typing.ts +1 -1
- package/src/wiki.ts +15 -19
|
@@ -24,11 +24,19 @@ describe("FeishuConfigSchema webhook validation", () => {
|
|
|
24
24
|
expect(result.accounts?.main?.requireMention).toBeUndefined();
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
+
it("normalizes legacy groupPolicy allowall to open", () => {
|
|
28
|
+
const result = FeishuConfigSchema.parse({
|
|
29
|
+
groupPolicy: "allowall",
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
expect(result.groupPolicy).toBe("open");
|
|
33
|
+
});
|
|
34
|
+
|
|
27
35
|
it("rejects top-level webhook mode without verificationToken", () => {
|
|
28
36
|
const result = FeishuConfigSchema.safeParse({
|
|
29
37
|
connectionMode: "webhook",
|
|
30
38
|
appId: "cli_top",
|
|
31
|
-
appSecret: "secret_top",
|
|
39
|
+
appSecret: "secret_top", // pragma: allowlist secret
|
|
32
40
|
});
|
|
33
41
|
|
|
34
42
|
expect(result.success).toBe(false);
|
|
@@ -44,7 +52,7 @@ describe("FeishuConfigSchema webhook validation", () => {
|
|
|
44
52
|
connectionMode: "webhook",
|
|
45
53
|
verificationToken: "token_top",
|
|
46
54
|
appId: "cli_top",
|
|
47
|
-
appSecret: "secret_top",
|
|
55
|
+
appSecret: "secret_top", // pragma: allowlist secret
|
|
48
56
|
});
|
|
49
57
|
|
|
50
58
|
expect(result.success).toBe(true);
|
|
@@ -56,7 +64,7 @@ describe("FeishuConfigSchema webhook validation", () => {
|
|
|
56
64
|
main: {
|
|
57
65
|
connectionMode: "webhook",
|
|
58
66
|
appId: "cli_main",
|
|
59
|
-
appSecret: "secret_main",
|
|
67
|
+
appSecret: "secret_main", // pragma: allowlist secret
|
|
60
68
|
},
|
|
61
69
|
},
|
|
62
70
|
});
|
|
@@ -78,13 +86,32 @@ describe("FeishuConfigSchema webhook validation", () => {
|
|
|
78
86
|
main: {
|
|
79
87
|
connectionMode: "webhook",
|
|
80
88
|
appId: "cli_main",
|
|
81
|
-
appSecret: "secret_main",
|
|
89
|
+
appSecret: "secret_main", // pragma: allowlist secret
|
|
82
90
|
},
|
|
83
91
|
},
|
|
84
92
|
});
|
|
85
93
|
|
|
86
94
|
expect(result.success).toBe(true);
|
|
87
95
|
});
|
|
96
|
+
|
|
97
|
+
it("accepts SecretRef verificationToken in webhook mode", () => {
|
|
98
|
+
const result = FeishuConfigSchema.safeParse({
|
|
99
|
+
connectionMode: "webhook",
|
|
100
|
+
verificationToken: {
|
|
101
|
+
source: "env",
|
|
102
|
+
provider: "default",
|
|
103
|
+
id: "FEISHU_VERIFICATION_TOKEN",
|
|
104
|
+
},
|
|
105
|
+
appId: "cli_top",
|
|
106
|
+
appSecret: {
|
|
107
|
+
source: "env",
|
|
108
|
+
provider: "default",
|
|
109
|
+
id: "FEISHU_APP_SECRET",
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(result.success).toBe(true);
|
|
114
|
+
});
|
|
88
115
|
});
|
|
89
116
|
|
|
90
117
|
describe("FeishuConfigSchema replyInThread", () => {
|
|
@@ -144,7 +171,7 @@ describe("FeishuConfigSchema defaultAccount", () => {
|
|
|
144
171
|
const result = FeishuConfigSchema.safeParse({
|
|
145
172
|
defaultAccount: "router-d",
|
|
146
173
|
accounts: {
|
|
147
|
-
"router-d": { appId: "cli_router", appSecret: "secret_router" },
|
|
174
|
+
"router-d": { appId: "cli_router", appSecret: "secret_router" }, // pragma: allowlist secret
|
|
148
175
|
},
|
|
149
176
|
});
|
|
150
177
|
|
|
@@ -155,7 +182,7 @@ describe("FeishuConfigSchema defaultAccount", () => {
|
|
|
155
182
|
const result = FeishuConfigSchema.safeParse({
|
|
156
183
|
defaultAccount: "router-d",
|
|
157
184
|
accounts: {
|
|
158
|
-
backup: { appId: "cli_backup", appSecret: "secret_backup" },
|
|
185
|
+
backup: { appId: "cli_backup", appSecret: "secret_backup" }, // pragma: allowlist secret
|
|
159
186
|
},
|
|
160
187
|
});
|
|
161
188
|
|
package/src/config-schema.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
export { z };
|
|
4
|
+
import { buildSecretInputSchema, hasConfiguredSecretInput } from "./secret-input.js";
|
|
4
5
|
|
|
5
6
|
const DmPolicySchema = z.enum(["open", "pairing", "allowlist"]);
|
|
6
|
-
const GroupPolicySchema = z.
|
|
7
|
+
const GroupPolicySchema = z.union([
|
|
8
|
+
z.enum(["open", "allowlist", "disabled"]),
|
|
9
|
+
z.literal("allowall").transform(() => "open" as const),
|
|
10
|
+
]);
|
|
7
11
|
const FeishuDomainSchema = z.union([
|
|
8
12
|
z.enum(["feishu", "lark"]),
|
|
9
13
|
z.string().url().startsWith("https://"),
|
|
@@ -110,6 +114,9 @@ const GroupSessionScopeSchema = z
|
|
|
110
114
|
* Topic session isolation mode for group chats.
|
|
111
115
|
* - "disabled" (default): All messages in a group share one session
|
|
112
116
|
* - "enabled": Messages in different topics get separate sessions
|
|
117
|
+
*
|
|
118
|
+
* Topic routing uses `root_id` when present to keep session continuity and
|
|
119
|
+
* falls back to `thread_id` when `root_id` is unavailable.
|
|
113
120
|
*/
|
|
114
121
|
const TopicSessionModeSchema = z.enum(["disabled", "enabled"]).optional();
|
|
115
122
|
const ReactionNotificationModeSchema = z.enum(["off", "own", "all"]).optional();
|
|
@@ -158,6 +165,7 @@ const FeishuSharedConfigShape = {
|
|
|
158
165
|
chunkMode: z.enum(["length", "newline"]).optional(),
|
|
159
166
|
blockStreamingCoalesce: BlockStreamingCoalesceSchema,
|
|
160
167
|
mediaMaxMb: z.number().positive().optional(),
|
|
168
|
+
httpTimeoutMs: z.number().int().positive().max(300_000).optional(),
|
|
161
169
|
heartbeat: ChannelHeartbeatVisibilitySchema,
|
|
162
170
|
renderMode: RenderModeSchema,
|
|
163
171
|
streaming: StreamingModeSchema,
|
|
@@ -177,9 +185,9 @@ export const FeishuAccountConfigSchema = z
|
|
|
177
185
|
enabled: z.boolean().optional(),
|
|
178
186
|
name: z.string().optional(), // Display name for this account
|
|
179
187
|
appId: z.string().optional(),
|
|
180
|
-
appSecret:
|
|
188
|
+
appSecret: buildSecretInputSchema().optional(),
|
|
181
189
|
encryptKey: z.string().optional(),
|
|
182
|
-
verificationToken:
|
|
190
|
+
verificationToken: buildSecretInputSchema().optional(),
|
|
183
191
|
domain: FeishuDomainSchema.optional(),
|
|
184
192
|
connectionMode: FeishuConnectionModeSchema.optional(),
|
|
185
193
|
webhookPath: z.string().optional(),
|
|
@@ -195,9 +203,9 @@ export const FeishuConfigSchema = z
|
|
|
195
203
|
defaultAccount: z.string().optional(),
|
|
196
204
|
// Top-level credentials (backward compatible for single-account mode)
|
|
197
205
|
appId: z.string().optional(),
|
|
198
|
-
appSecret:
|
|
206
|
+
appSecret: buildSecretInputSchema().optional(),
|
|
199
207
|
encryptKey: z.string().optional(),
|
|
200
|
-
verificationToken:
|
|
208
|
+
verificationToken: buildSecretInputSchema().optional(),
|
|
201
209
|
domain: FeishuDomainSchema.optional().default("feishu"),
|
|
202
210
|
connectionMode: FeishuConnectionModeSchema.optional().default("websocket"),
|
|
203
211
|
webhookPath: z.string().optional().default("/feishu/events"),
|
|
@@ -231,8 +239,8 @@ export const FeishuConfigSchema = z
|
|
|
231
239
|
}
|
|
232
240
|
|
|
233
241
|
const defaultConnectionMode = value.connectionMode ?? "websocket";
|
|
234
|
-
const
|
|
235
|
-
if (defaultConnectionMode === "webhook" && !
|
|
242
|
+
const defaultVerificationTokenConfigured = hasConfiguredSecretInput(value.verificationToken);
|
|
243
|
+
if (defaultConnectionMode === "webhook" && !defaultVerificationTokenConfigured) {
|
|
236
244
|
ctx.addIssue({
|
|
237
245
|
code: z.ZodIssueCode.custom,
|
|
238
246
|
path: ["verificationToken"],
|
|
@@ -249,9 +257,9 @@ export const FeishuConfigSchema = z
|
|
|
249
257
|
if (accountConnectionMode !== "webhook") {
|
|
250
258
|
continue;
|
|
251
259
|
}
|
|
252
|
-
const
|
|
253
|
-
account.verificationToken
|
|
254
|
-
if (!
|
|
260
|
+
const accountVerificationTokenConfigured =
|
|
261
|
+
hasConfiguredSecretInput(account.verificationToken) || defaultVerificationTokenConfigured;
|
|
262
|
+
if (!accountVerificationTokenConfigured) {
|
|
255
263
|
ctx.addIssue({
|
|
256
264
|
code: z.ZodIssueCode.custom,
|
|
257
265
|
path: ["accounts", accountId, "verificationToken"],
|
package/src/dedup.ts
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import os from "node:os";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
createDedupeCache,
|
|
5
|
+
createPersistentDedupe,
|
|
6
|
+
readJsonFileWithFallback,
|
|
7
|
+
} from "openclaw/plugin-sdk/feishu";
|
|
4
8
|
|
|
5
9
|
// Persistent TTL: 24 hours — survives restarts & WebSocket reconnects.
|
|
6
10
|
const DEDUP_TTL_MS = 24 * 60 * 60 * 1000;
|
|
7
11
|
const MEMORY_MAX_SIZE = 1_000;
|
|
8
12
|
const FILE_MAX_ENTRIES = 10_000;
|
|
13
|
+
type PersistentDedupeData = Record<string, number>;
|
|
9
14
|
|
|
10
15
|
const memoryDedupe = createDedupeCache({ ttlMs: DEDUP_TTL_MS, maxSize: MEMORY_MAX_SIZE });
|
|
11
16
|
|
|
@@ -40,6 +45,14 @@ export function tryRecordMessage(messageId: string): boolean {
|
|
|
40
45
|
return !memoryDedupe.check(messageId);
|
|
41
46
|
}
|
|
42
47
|
|
|
48
|
+
export function hasRecordedMessage(messageId: string): boolean {
|
|
49
|
+
const trimmed = messageId.trim();
|
|
50
|
+
if (!trimmed) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
return memoryDedupe.peek(trimmed);
|
|
54
|
+
}
|
|
55
|
+
|
|
43
56
|
export async function tryRecordMessagePersistent(
|
|
44
57
|
messageId: string,
|
|
45
58
|
namespace = "global",
|
|
@@ -52,3 +65,36 @@ export async function tryRecordMessagePersistent(
|
|
|
52
65
|
},
|
|
53
66
|
});
|
|
54
67
|
}
|
|
68
|
+
|
|
69
|
+
export async function hasRecordedMessagePersistent(
|
|
70
|
+
messageId: string,
|
|
71
|
+
namespace = "global",
|
|
72
|
+
log?: (...args: unknown[]) => void,
|
|
73
|
+
): Promise<boolean> {
|
|
74
|
+
const trimmed = messageId.trim();
|
|
75
|
+
if (!trimmed) {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
const now = Date.now();
|
|
79
|
+
const filePath = resolveNamespaceFilePath(namespace);
|
|
80
|
+
try {
|
|
81
|
+
const { value } = await readJsonFileWithFallback<PersistentDedupeData>(filePath, {});
|
|
82
|
+
const seenAt = value[trimmed];
|
|
83
|
+
if (typeof seenAt !== "number" || !Number.isFinite(seenAt)) {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
return DEDUP_TTL_MS <= 0 || now - seenAt < DEDUP_TTL_MS;
|
|
87
|
+
} catch (error) {
|
|
88
|
+
log?.(`feishu-dedup: persistent peek failed: ${String(error)}`);
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function warmupDedupFromDisk(
|
|
94
|
+
namespace: string,
|
|
95
|
+
log?: (...args: unknown[]) => void,
|
|
96
|
+
): Promise<number> {
|
|
97
|
+
return persistentDedupe.warmup(namespace, (error) => {
|
|
98
|
+
log?.(`feishu-dedup: warmup disk error: ${String(error)}`);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
vi.mock("./accounts.js", () => ({
|
|
5
|
+
resolveFeishuAccount: vi.fn(() => ({
|
|
6
|
+
configured: false,
|
|
7
|
+
config: {
|
|
8
|
+
allowFrom: ["user:alice", "user:bob"],
|
|
9
|
+
dms: {
|
|
10
|
+
"user:carla": {},
|
|
11
|
+
},
|
|
12
|
+
groups: {
|
|
13
|
+
"chat-1": {},
|
|
14
|
+
},
|
|
15
|
+
groupAllowFrom: ["chat-2"],
|
|
16
|
+
},
|
|
17
|
+
})),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
import { listFeishuDirectoryGroups, listFeishuDirectoryPeers } from "./directory.js";
|
|
21
|
+
|
|
22
|
+
describe("feishu directory (config-backed)", () => {
|
|
23
|
+
const cfg = {} as ClawdbotConfig;
|
|
24
|
+
|
|
25
|
+
it("merges allowFrom + dms into peer entries", async () => {
|
|
26
|
+
const peers = await listFeishuDirectoryPeers({ cfg, query: "a" });
|
|
27
|
+
expect(peers).toEqual([
|
|
28
|
+
{ kind: "user", id: "alice" },
|
|
29
|
+
{ kind: "user", id: "carla" },
|
|
30
|
+
]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("merges groups map + groupAllowFrom into group entries", async () => {
|
|
34
|
+
const groups = await listFeishuDirectoryGroups({ cfg });
|
|
35
|
+
expect(groups).toEqual([
|
|
36
|
+
{ kind: "group", id: "chat-1" },
|
|
37
|
+
{ kind: "group", id: "chat-2" },
|
|
38
|
+
]);
|
|
39
|
+
});
|
|
40
|
+
});
|
package/src/directory.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import
|
|
1
|
+
import {
|
|
2
|
+
listDirectoryGroupEntriesFromMapKeysAndAllowFrom,
|
|
3
|
+
listDirectoryUserEntriesFromAllowFromAndMapKeys,
|
|
4
|
+
} from "openclaw/plugin-sdk/compat";
|
|
5
|
+
import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
|
|
2
6
|
import { resolveFeishuAccount } from "./accounts.js";
|
|
3
7
|
import { createFeishuClient } from "./client.js";
|
|
4
8
|
import { normalizeFeishuTarget } from "./targets.js";
|
|
@@ -15,6 +19,14 @@ export type FeishuDirectoryGroup = {
|
|
|
15
19
|
name?: string;
|
|
16
20
|
};
|
|
17
21
|
|
|
22
|
+
function toFeishuDirectoryPeers(ids: string[]): FeishuDirectoryPeer[] {
|
|
23
|
+
return ids.map((id) => ({ kind: "user", id }));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function toFeishuDirectoryGroups(ids: string[]): FeishuDirectoryGroup[] {
|
|
27
|
+
return ids.map((id) => ({ kind: "group", id }));
|
|
28
|
+
}
|
|
29
|
+
|
|
18
30
|
export async function listFeishuDirectoryPeers(params: {
|
|
19
31
|
cfg: ClawdbotConfig;
|
|
20
32
|
query?: string;
|
|
@@ -22,31 +34,15 @@ export async function listFeishuDirectoryPeers(params: {
|
|
|
22
34
|
accountId?: string;
|
|
23
35
|
}): Promise<FeishuDirectoryPeer[]> {
|
|
24
36
|
const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
for (const userId of Object.keys(feishuCfg?.dms ?? {})) {
|
|
37
|
-
const trimmed = userId.trim();
|
|
38
|
-
if (trimmed) {
|
|
39
|
-
ids.add(trimmed);
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
return Array.from(ids)
|
|
44
|
-
.map((raw) => raw.trim())
|
|
45
|
-
.filter(Boolean)
|
|
46
|
-
.map((raw) => normalizeFeishuTarget(raw) ?? raw)
|
|
47
|
-
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
|
48
|
-
.slice(0, params.limit && params.limit > 0 ? params.limit : undefined)
|
|
49
|
-
.map((id) => ({ kind: "user" as const, id }));
|
|
37
|
+
const entries = listDirectoryUserEntriesFromAllowFromAndMapKeys({
|
|
38
|
+
allowFrom: account.config.allowFrom,
|
|
39
|
+
map: account.config.dms,
|
|
40
|
+
query: params.query,
|
|
41
|
+
limit: params.limit,
|
|
42
|
+
normalizeAllowFromId: (entry) => normalizeFeishuTarget(entry) ?? entry,
|
|
43
|
+
normalizeMapKeyId: (entry) => normalizeFeishuTarget(entry) ?? entry,
|
|
44
|
+
});
|
|
45
|
+
return toFeishuDirectoryPeers(entries.map((entry) => entry.id));
|
|
50
46
|
}
|
|
51
47
|
|
|
52
48
|
export async function listFeishuDirectoryGroups(params: {
|
|
@@ -56,30 +52,13 @@ export async function listFeishuDirectoryGroups(params: {
|
|
|
56
52
|
accountId?: string;
|
|
57
53
|
}): Promise<FeishuDirectoryGroup[]> {
|
|
58
54
|
const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
ids.add(trimmed);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
for (const entry of feishuCfg?.groupAllowFrom ?? []) {
|
|
71
|
-
const trimmed = String(entry).trim();
|
|
72
|
-
if (trimmed && trimmed !== "*") {
|
|
73
|
-
ids.add(trimmed);
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
return Array.from(ids)
|
|
78
|
-
.map((raw) => raw.trim())
|
|
79
|
-
.filter(Boolean)
|
|
80
|
-
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
|
81
|
-
.slice(0, params.limit && params.limit > 0 ? params.limit : undefined)
|
|
82
|
-
.map((id) => ({ kind: "group" as const, id }));
|
|
55
|
+
const entries = listDirectoryGroupEntriesFromMapKeysAndAllowFrom({
|
|
56
|
+
groups: account.config.groups,
|
|
57
|
+
allowFrom: account.config.groupAllowFrom,
|
|
58
|
+
query: params.query,
|
|
59
|
+
limit: params.limit,
|
|
60
|
+
});
|
|
61
|
+
return toFeishuDirectoryGroups(entries.map((entry) => entry.id));
|
|
83
62
|
}
|
|
84
63
|
|
|
85
64
|
export async function listFeishuDirectoryPeersLive(params: {
|
package/src/doc-schema.ts
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
import { Type, type Static } from "@sinclair/typebox";
|
|
2
2
|
|
|
3
|
+
const tableCreationProperties = {
|
|
4
|
+
doc_token: Type.String({ description: "Document token" }),
|
|
5
|
+
parent_block_id: Type.Optional(
|
|
6
|
+
Type.String({ description: "Parent block ID (default: document root)" }),
|
|
7
|
+
),
|
|
8
|
+
row_size: Type.Integer({ description: "Table row count", minimum: 1 }),
|
|
9
|
+
column_size: Type.Integer({ description: "Table column count", minimum: 1 }),
|
|
10
|
+
column_width: Type.Optional(
|
|
11
|
+
Type.Array(Type.Number({ minimum: 1 }), {
|
|
12
|
+
description: "Column widths in px (length should match column_size)",
|
|
13
|
+
}),
|
|
14
|
+
),
|
|
15
|
+
};
|
|
16
|
+
|
|
3
17
|
export const FeishuDocSchema = Type.Union([
|
|
4
18
|
Type.Object({
|
|
5
19
|
action: Type.Literal("read"),
|
|
@@ -59,17 +73,7 @@ export const FeishuDocSchema = Type.Union([
|
|
|
59
73
|
// Table creation (explicit structure)
|
|
60
74
|
Type.Object({
|
|
61
75
|
action: Type.Literal("create_table"),
|
|
62
|
-
|
|
63
|
-
parent_block_id: Type.Optional(
|
|
64
|
-
Type.String({ description: "Parent block ID (default: document root)" }),
|
|
65
|
-
),
|
|
66
|
-
row_size: Type.Integer({ description: "Table row count", minimum: 1 }),
|
|
67
|
-
column_size: Type.Integer({ description: "Table column count", minimum: 1 }),
|
|
68
|
-
column_width: Type.Optional(
|
|
69
|
-
Type.Array(Type.Number({ minimum: 1 }), {
|
|
70
|
-
description: "Column widths in px (length should match column_size)",
|
|
71
|
-
}),
|
|
72
|
-
),
|
|
76
|
+
...tableCreationProperties,
|
|
73
77
|
}),
|
|
74
78
|
Type.Object({
|
|
75
79
|
action: Type.Literal("write_table_cells"),
|
|
@@ -82,17 +86,7 @@ export const FeishuDocSchema = Type.Union([
|
|
|
82
86
|
}),
|
|
83
87
|
Type.Object({
|
|
84
88
|
action: Type.Literal("create_table_with_values"),
|
|
85
|
-
|
|
86
|
-
parent_block_id: Type.Optional(
|
|
87
|
-
Type.String({ description: "Parent block ID (default: document root)" }),
|
|
88
|
-
),
|
|
89
|
-
row_size: Type.Integer({ description: "Table row count", minimum: 1 }),
|
|
90
|
-
column_size: Type.Integer({ description: "Table column count", minimum: 1 }),
|
|
91
|
-
column_width: Type.Optional(
|
|
92
|
-
Type.Array(Type.Number({ minimum: 1 }), {
|
|
93
|
-
description: "Column widths in px (length should match column_size)",
|
|
94
|
-
}),
|
|
95
|
-
),
|
|
89
|
+
...tableCreationProperties,
|
|
96
90
|
values: Type.Array(Type.Array(Type.String()), {
|
|
97
91
|
description: "2D matrix values[row][col] to write into table cells",
|
|
98
92
|
minItems: 1,
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { BATCH_SIZE, insertBlocksInBatches } from "./docx-batch-insert.js";
|
|
3
|
+
|
|
4
|
+
function createCountingIterable<T>(values: T[]) {
|
|
5
|
+
let iterations = 0;
|
|
6
|
+
return {
|
|
7
|
+
values: {
|
|
8
|
+
[Symbol.iterator]: function* () {
|
|
9
|
+
iterations += 1;
|
|
10
|
+
yield* values;
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
getIterations: () => iterations,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("insertBlocksInBatches", () => {
|
|
18
|
+
it("builds the source block map once for large flat trees", async () => {
|
|
19
|
+
const blockCount = BATCH_SIZE + 200;
|
|
20
|
+
const blocks = Array.from({ length: blockCount }, (_, index) => ({
|
|
21
|
+
block_id: `block_${index}`,
|
|
22
|
+
block_type: 2,
|
|
23
|
+
}));
|
|
24
|
+
const counting = createCountingIterable(blocks);
|
|
25
|
+
const createMock = vi.fn(async ({ data }: { data: { children_id: string[] } }) => ({
|
|
26
|
+
code: 0,
|
|
27
|
+
data: {
|
|
28
|
+
children: data.children_id.map((id) => ({ block_id: id })),
|
|
29
|
+
},
|
|
30
|
+
}));
|
|
31
|
+
const client = {
|
|
32
|
+
docx: {
|
|
33
|
+
documentBlockDescendant: {
|
|
34
|
+
create: createMock,
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
} as any;
|
|
38
|
+
|
|
39
|
+
const result = await insertBlocksInBatches(
|
|
40
|
+
client,
|
|
41
|
+
"doc_1",
|
|
42
|
+
counting.values as any[],
|
|
43
|
+
blocks.map((block) => block.block_id),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
expect(counting.getIterations()).toBe(1);
|
|
47
|
+
expect(createMock).toHaveBeenCalledTimes(2);
|
|
48
|
+
expect(createMock.mock.calls[0]?.[0]?.data.children_id).toHaveLength(BATCH_SIZE);
|
|
49
|
+
expect(createMock.mock.calls[1]?.[0]?.data.children_id).toHaveLength(200);
|
|
50
|
+
expect(result.children).toHaveLength(blockCount);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("keeps nested descendants grouped with their root blocks", async () => {
|
|
54
|
+
const createMock = vi.fn(
|
|
55
|
+
async ({
|
|
56
|
+
data,
|
|
57
|
+
}: {
|
|
58
|
+
data: { children_id: string[]; descendants: Array<{ block_id: string }> };
|
|
59
|
+
}) => ({
|
|
60
|
+
code: 0,
|
|
61
|
+
data: {
|
|
62
|
+
children: data.children_id.map((id) => ({ block_id: id })),
|
|
63
|
+
},
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
const client = {
|
|
67
|
+
docx: {
|
|
68
|
+
documentBlockDescendant: {
|
|
69
|
+
create: createMock,
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
} as any;
|
|
73
|
+
const blocks = [
|
|
74
|
+
{ block_id: "root_a", block_type: 1, children: ["child_a"] },
|
|
75
|
+
{ block_id: "child_a", block_type: 2 },
|
|
76
|
+
{ block_id: "root_b", block_type: 1, children: ["child_b"] },
|
|
77
|
+
{ block_id: "child_b", block_type: 2 },
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
await insertBlocksInBatches(client, "doc_1", blocks as any[], ["root_a", "root_b"]);
|
|
81
|
+
|
|
82
|
+
expect(createMock).toHaveBeenCalledTimes(1);
|
|
83
|
+
expect(createMock.mock.calls[0]?.[0]?.data.children_id).toEqual(["root_a", "root_b"]);
|
|
84
|
+
expect(
|
|
85
|
+
createMock.mock.calls[0]?.[0]?.data.descendants.map(
|
|
86
|
+
(block: { block_id: string }) => block.block_id,
|
|
87
|
+
),
|
|
88
|
+
).toEqual(["root_a", "child_a", "root_b", "child_b"]);
|
|
89
|
+
});
|
|
90
|
+
});
|
package/src/docx-batch-insert.ts
CHANGED
|
@@ -14,16 +14,11 @@ export const BATCH_SIZE = 1000; // Feishu API limit per request
|
|
|
14
14
|
type Logger = { info?: (msg: string) => void };
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
|
-
* Collect all descendant blocks for a given
|
|
17
|
+
* Collect all descendant blocks for a given first-level block ID.
|
|
18
18
|
* Recursively traverses the block tree to gather all children.
|
|
19
19
|
*/
|
|
20
20
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block types
|
|
21
|
-
function collectDescendants(
|
|
22
|
-
const blockMap = new Map<string, any>();
|
|
23
|
-
for (const block of blocks) {
|
|
24
|
-
blockMap.set(block.block_id, block);
|
|
25
|
-
}
|
|
26
|
-
|
|
21
|
+
function collectDescendants(blockMap: Map<string, any>, rootId: string): any[] {
|
|
27
22
|
const result: any[] = [];
|
|
28
23
|
const visited = new Set<string>();
|
|
29
24
|
|
|
@@ -47,9 +42,7 @@ function collectDescendants(blocks: any[], firstLevelIds: string[]): any[] {
|
|
|
47
42
|
}
|
|
48
43
|
}
|
|
49
44
|
|
|
50
|
-
|
|
51
|
-
collect(id);
|
|
52
|
-
}
|
|
45
|
+
collect(rootId);
|
|
53
46
|
|
|
54
47
|
return result;
|
|
55
48
|
}
|
|
@@ -123,9 +116,13 @@ export async function insertBlocksInBatches(
|
|
|
123
116
|
const batches: { firstLevelIds: string[]; blocks: any[] }[] = [];
|
|
124
117
|
let currentBatch: { firstLevelIds: string[]; blocks: any[] } = { firstLevelIds: [], blocks: [] };
|
|
125
118
|
const usedBlockIds = new Set<string>();
|
|
119
|
+
const blockMap = new Map<string, any>();
|
|
120
|
+
for (const block of blocks) {
|
|
121
|
+
blockMap.set(block.block_id, block);
|
|
122
|
+
}
|
|
126
123
|
|
|
127
124
|
for (const firstLevelId of firstLevelBlockIds) {
|
|
128
|
-
const descendants = collectDescendants(
|
|
125
|
+
const descendants = collectDescendants(blockMap, firstLevelId);
|
|
129
126
|
const newBlocks = descendants.filter((b) => !usedBlockIds.has(b.block_id));
|
|
130
127
|
|
|
131
128
|
// A single block whose subtree exceeds the API limit cannot be split
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu";
|
|
2
2
|
import { describe, expect, test, vi } from "vitest";
|
|
3
3
|
import { registerFeishuDocTools } from "./docx.js";
|
|
4
4
|
import { createToolFactoryHarness } from "./tool-factory-test-harness.js";
|
|
@@ -21,18 +21,22 @@ vi.mock("@larksuiteoapi/node-sdk", () => {
|
|
|
21
21
|
});
|
|
22
22
|
|
|
23
23
|
describe("feishu_doc account selection", () => {
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
function createDocEnabledConfig(): OpenClawPluginApi["config"] {
|
|
25
|
+
return {
|
|
26
26
|
channels: {
|
|
27
27
|
feishu: {
|
|
28
28
|
enabled: true,
|
|
29
29
|
accounts: {
|
|
30
|
-
a: { appId: "app-a", appSecret: "sec-a", tools: { doc: true } },
|
|
31
|
-
b: { appId: "app-b", appSecret: "sec-b", tools: { doc: true } },
|
|
30
|
+
a: { appId: "app-a", appSecret: "sec-a", tools: { doc: true } }, // pragma: allowlist secret
|
|
31
|
+
b: { appId: "app-b", appSecret: "sec-b", tools: { doc: true } }, // pragma: allowlist secret
|
|
32
32
|
},
|
|
33
33
|
},
|
|
34
34
|
},
|
|
35
35
|
} as OpenClawPluginApi["config"];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
test("uses agentAccountId context when params omit accountId", async () => {
|
|
39
|
+
const cfg = createDocEnabledConfig();
|
|
36
40
|
|
|
37
41
|
const { api, resolveTool } = createToolFactoryHarness(cfg);
|
|
38
42
|
registerFeishuDocTools(api);
|
|
@@ -49,17 +53,7 @@ describe("feishu_doc account selection", () => {
|
|
|
49
53
|
});
|
|
50
54
|
|
|
51
55
|
test("explicit accountId param overrides agentAccountId context", async () => {
|
|
52
|
-
const cfg =
|
|
53
|
-
channels: {
|
|
54
|
-
feishu: {
|
|
55
|
-
enabled: true,
|
|
56
|
-
accounts: {
|
|
57
|
-
a: { appId: "app-a", appSecret: "sec-a", tools: { doc: true } },
|
|
58
|
-
b: { appId: "app-b", appSecret: "sec-b", tools: { doc: true } },
|
|
59
|
-
},
|
|
60
|
-
},
|
|
61
|
-
},
|
|
62
|
-
} as OpenClawPluginApi["config"];
|
|
56
|
+
const cfg = createDocEnabledConfig();
|
|
63
57
|
|
|
64
58
|
const { api, resolveTool } = createToolFactoryHarness(cfg);
|
|
65
59
|
registerFeishuDocTools(api);
|