@openclaw/feishu 2026.2.25 → 2026.3.2
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 -0
- package/package.json +2 -1
- package/skills/feishu-doc/SKILL.md +109 -3
- package/src/accounts.test.ts +161 -0
- package/src/accounts.ts +76 -8
- package/src/async.ts +62 -0
- package/src/bitable.ts +189 -215
- package/src/bot.card-action.test.ts +63 -0
- package/src/bot.checkBotMentioned.test.ts +56 -1
- package/src/bot.test.ts +1271 -56
- package/src/bot.ts +499 -215
- package/src/card-action.ts +79 -0
- package/src/channel.ts +26 -4
- package/src/chat-schema.ts +24 -0
- package/src/chat.test.ts +89 -0
- package/src/chat.ts +130 -0
- package/src/client.test.ts +121 -0
- package/src/client.ts +13 -0
- package/src/config-schema.test.ts +101 -1
- package/src/config-schema.ts +66 -11
- package/src/dedup.ts +47 -1
- package/src/doc-schema.ts +135 -0
- package/src/docx-batch-insert.ts +190 -0
- package/src/docx-color-text.ts +149 -0
- package/src/docx-table-ops.ts +298 -0
- package/src/docx.account-selection.test.ts +70 -0
- package/src/docx.test.ts +331 -9
- package/src/docx.ts +996 -72
- package/src/drive.ts +38 -33
- package/src/media.test.ts +227 -7
- package/src/media.ts +52 -11
- package/src/mention.ts +1 -1
- package/src/monitor.account.ts +534 -0
- package/src/monitor.reaction.test.ts +578 -0
- package/src/monitor.startup.test.ts +203 -0
- package/src/monitor.startup.ts +51 -0
- package/src/monitor.state.defaults.test.ts +46 -0
- package/src/monitor.state.ts +152 -0
- package/src/monitor.test-mocks.ts +12 -0
- package/src/monitor.transport.ts +163 -0
- package/src/monitor.ts +44 -346
- package/src/monitor.webhook-security.test.ts +53 -10
- package/src/onboarding.status.test.ts +25 -0
- package/src/onboarding.ts +144 -52
- package/src/outbound.test.ts +181 -0
- package/src/outbound.ts +94 -7
- package/src/perm.ts +37 -30
- package/src/policy.test.ts +56 -1
- package/src/policy.ts +5 -1
- package/src/post.test.ts +105 -0
- package/src/post.ts +274 -0
- package/src/probe.test.ts +271 -0
- package/src/probe.ts +131 -19
- package/src/reply-dispatcher.test.ts +300 -0
- package/src/reply-dispatcher.ts +159 -46
- package/src/secret-input.ts +19 -0
- package/src/send-target.test.ts +74 -0
- package/src/send-target.ts +6 -2
- package/src/send.reply-fallback.test.ts +105 -0
- package/src/send.test.ts +168 -0
- package/src/send.ts +143 -18
- package/src/streaming-card.ts +131 -43
- package/src/targets.test.ts +55 -1
- package/src/targets.ts +32 -7
- package/src/tool-account-routing.test.ts +129 -0
- package/src/tool-account.ts +70 -0
- package/src/tool-factory-test-harness.ts +76 -0
- package/src/tools-config.test.ts +21 -0
- package/src/tools-config.ts +2 -1
- package/src/types.ts +10 -1
- package/src/typing.test.ts +144 -0
- package/src/typing.ts +140 -10
- package/src/wiki.ts +55 -50
package/src/config-schema.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
|
1
2
|
import { z } from "zod";
|
|
2
3
|
export { z };
|
|
4
|
+
import { buildSecretInputSchema, hasConfiguredSecretInput } from "./secret-input.js";
|
|
3
5
|
|
|
4
6
|
const DmPolicySchema = z.enum(["open", "pairing", "allowlist"]);
|
|
5
7
|
const GroupPolicySchema = z.enum(["open", "allowlist", "disabled"]);
|
|
@@ -82,6 +84,7 @@ const DynamicAgentCreationSchema = z
|
|
|
82
84
|
const FeishuToolsConfigSchema = z
|
|
83
85
|
.object({
|
|
84
86
|
doc: z.boolean().optional(), // Document operations (default: true)
|
|
87
|
+
chat: z.boolean().optional(), // Chat info + member query operations (default: true)
|
|
85
88
|
wiki: z.boolean().optional(), // Knowledge base operations (default: true, requires doc)
|
|
86
89
|
drive: z.boolean().optional(), // Cloud storage operations (default: true)
|
|
87
90
|
perm: z.boolean().optional(), // Permission management (default: false, sensitive)
|
|
@@ -91,14 +94,39 @@ const FeishuToolsConfigSchema = z
|
|
|
91
94
|
.optional();
|
|
92
95
|
|
|
93
96
|
/**
|
|
97
|
+
* Group session scope for routing Feishu group messages.
|
|
98
|
+
* - "group" (default): one session per group chat
|
|
99
|
+
* - "group_sender": one session per (group + sender)
|
|
100
|
+
* - "group_topic": one session per group topic thread (falls back to group if no topic)
|
|
101
|
+
* - "group_topic_sender": one session per (group + topic thread + sender),
|
|
102
|
+
* falls back to (group + sender) if no topic
|
|
103
|
+
*/
|
|
104
|
+
const GroupSessionScopeSchema = z
|
|
105
|
+
.enum(["group", "group_sender", "group_topic", "group_topic_sender"])
|
|
106
|
+
.optional();
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @deprecated Use groupSessionScope instead.
|
|
110
|
+
*
|
|
94
111
|
* Topic session isolation mode for group chats.
|
|
95
112
|
* - "disabled" (default): All messages in a group share one session
|
|
96
113
|
* - "enabled": Messages in different topics get separate sessions
|
|
97
114
|
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
115
|
+
* Topic routing uses `root_id` when present to keep session continuity and
|
|
116
|
+
* falls back to `thread_id` when `root_id` is unavailable.
|
|
100
117
|
*/
|
|
101
118
|
const TopicSessionModeSchema = z.enum(["disabled", "enabled"]).optional();
|
|
119
|
+
const ReactionNotificationModeSchema = z.enum(["off", "own", "all"]).optional();
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Reply-in-thread mode for group chats.
|
|
123
|
+
* - "disabled" (default): Bot replies are normal inline replies
|
|
124
|
+
* - "enabled": Bot replies create or continue a Feishu topic thread
|
|
125
|
+
*
|
|
126
|
+
* When enabled, the Feishu reply API is called with `reply_in_thread: true`,
|
|
127
|
+
* causing the reply to appear as a topic (话题) under the original message.
|
|
128
|
+
*/
|
|
129
|
+
const ReplyInThreadSchema = z.enum(["disabled", "enabled"]).optional();
|
|
102
130
|
|
|
103
131
|
export const FeishuGroupSchema = z
|
|
104
132
|
.object({
|
|
@@ -108,7 +136,9 @@ export const FeishuGroupSchema = z
|
|
|
108
136
|
enabled: z.boolean().optional(),
|
|
109
137
|
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
|
110
138
|
systemPrompt: z.string().optional(),
|
|
139
|
+
groupSessionScope: GroupSessionScopeSchema,
|
|
111
140
|
topicSessionMode: TopicSessionModeSchema,
|
|
141
|
+
replyInThread: ReplyInThreadSchema,
|
|
112
142
|
})
|
|
113
143
|
.strict();
|
|
114
144
|
|
|
@@ -122,6 +152,7 @@ const FeishuSharedConfigShape = {
|
|
|
122
152
|
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
|
123
153
|
groupPolicy: GroupPolicySchema.optional(),
|
|
124
154
|
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
|
155
|
+
groupSenderAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
|
125
156
|
requireMention: z.boolean().optional(),
|
|
126
157
|
groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(),
|
|
127
158
|
historyLimit: z.number().int().min(0).optional(),
|
|
@@ -135,6 +166,10 @@ const FeishuSharedConfigShape = {
|
|
|
135
166
|
renderMode: RenderModeSchema,
|
|
136
167
|
streaming: StreamingModeSchema,
|
|
137
168
|
tools: FeishuToolsConfigSchema,
|
|
169
|
+
replyInThread: ReplyInThreadSchema,
|
|
170
|
+
reactionNotifications: ReactionNotificationModeSchema,
|
|
171
|
+
typingIndicator: z.boolean().optional(),
|
|
172
|
+
resolveSenderNames: z.boolean().optional(),
|
|
138
173
|
};
|
|
139
174
|
|
|
140
175
|
/**
|
|
@@ -146,42 +181,62 @@ export const FeishuAccountConfigSchema = z
|
|
|
146
181
|
enabled: z.boolean().optional(),
|
|
147
182
|
name: z.string().optional(), // Display name for this account
|
|
148
183
|
appId: z.string().optional(),
|
|
149
|
-
appSecret:
|
|
184
|
+
appSecret: buildSecretInputSchema().optional(),
|
|
150
185
|
encryptKey: z.string().optional(),
|
|
151
|
-
verificationToken:
|
|
186
|
+
verificationToken: buildSecretInputSchema().optional(),
|
|
152
187
|
domain: FeishuDomainSchema.optional(),
|
|
153
188
|
connectionMode: FeishuConnectionModeSchema.optional(),
|
|
154
189
|
webhookPath: z.string().optional(),
|
|
155
190
|
...FeishuSharedConfigShape,
|
|
191
|
+
groupSessionScope: GroupSessionScopeSchema,
|
|
192
|
+
topicSessionMode: TopicSessionModeSchema,
|
|
156
193
|
})
|
|
157
194
|
.strict();
|
|
158
195
|
|
|
159
196
|
export const FeishuConfigSchema = z
|
|
160
197
|
.object({
|
|
161
198
|
enabled: z.boolean().optional(),
|
|
199
|
+
defaultAccount: z.string().optional(),
|
|
162
200
|
// Top-level credentials (backward compatible for single-account mode)
|
|
163
201
|
appId: z.string().optional(),
|
|
164
|
-
appSecret:
|
|
202
|
+
appSecret: buildSecretInputSchema().optional(),
|
|
165
203
|
encryptKey: z.string().optional(),
|
|
166
|
-
verificationToken:
|
|
204
|
+
verificationToken: buildSecretInputSchema().optional(),
|
|
167
205
|
domain: FeishuDomainSchema.optional().default("feishu"),
|
|
168
206
|
connectionMode: FeishuConnectionModeSchema.optional().default("websocket"),
|
|
169
207
|
webhookPath: z.string().optional().default("/feishu/events"),
|
|
170
208
|
...FeishuSharedConfigShape,
|
|
171
209
|
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
|
210
|
+
reactionNotifications: ReactionNotificationModeSchema.optional().default("own"),
|
|
172
211
|
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
|
173
212
|
requireMention: z.boolean().optional().default(true),
|
|
213
|
+
groupSessionScope: GroupSessionScopeSchema,
|
|
174
214
|
topicSessionMode: TopicSessionModeSchema,
|
|
175
215
|
// Dynamic agent creation for DM users
|
|
176
216
|
dynamicAgentCreation: DynamicAgentCreationSchema,
|
|
217
|
+
// Optimization flags
|
|
218
|
+
typingIndicator: z.boolean().optional().default(true),
|
|
219
|
+
resolveSenderNames: z.boolean().optional().default(true),
|
|
177
220
|
// Multi-account configuration
|
|
178
221
|
accounts: z.record(z.string(), FeishuAccountConfigSchema.optional()).optional(),
|
|
179
222
|
})
|
|
180
223
|
.strict()
|
|
181
224
|
.superRefine((value, ctx) => {
|
|
225
|
+
const defaultAccount = value.defaultAccount?.trim();
|
|
226
|
+
if (defaultAccount && value.accounts && Object.keys(value.accounts).length > 0) {
|
|
227
|
+
const normalizedDefaultAccount = normalizeAccountId(defaultAccount);
|
|
228
|
+
if (!Object.prototype.hasOwnProperty.call(value.accounts, normalizedDefaultAccount)) {
|
|
229
|
+
ctx.addIssue({
|
|
230
|
+
code: z.ZodIssueCode.custom,
|
|
231
|
+
path: ["defaultAccount"],
|
|
232
|
+
message: `channels.feishu.defaultAccount="${defaultAccount}" does not match a configured account key`,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
182
237
|
const defaultConnectionMode = value.connectionMode ?? "websocket";
|
|
183
|
-
const
|
|
184
|
-
if (defaultConnectionMode === "webhook" && !
|
|
238
|
+
const defaultVerificationTokenConfigured = hasConfiguredSecretInput(value.verificationToken);
|
|
239
|
+
if (defaultConnectionMode === "webhook" && !defaultVerificationTokenConfigured) {
|
|
185
240
|
ctx.addIssue({
|
|
186
241
|
code: z.ZodIssueCode.custom,
|
|
187
242
|
path: ["verificationToken"],
|
|
@@ -198,9 +253,9 @@ export const FeishuConfigSchema = z
|
|
|
198
253
|
if (accountConnectionMode !== "webhook") {
|
|
199
254
|
continue;
|
|
200
255
|
}
|
|
201
|
-
const
|
|
202
|
-
account.verificationToken
|
|
203
|
-
if (!
|
|
256
|
+
const accountVerificationTokenConfigured =
|
|
257
|
+
hasConfiguredSecretInput(account.verificationToken) || defaultVerificationTokenConfigured;
|
|
258
|
+
if (!accountVerificationTokenConfigured) {
|
|
204
259
|
ctx.addIssue({
|
|
205
260
|
code: z.ZodIssueCode.custom,
|
|
206
261
|
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";
|
|
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
|
+
}
|
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"),
|
|
@@ -17,10 +31,24 @@ export const FeishuDocSchema = Type.Union([
|
|
|
17
31
|
doc_token: Type.String({ description: "Document token" }),
|
|
18
32
|
content: Type.String({ description: "Markdown content to append to end of document" }),
|
|
19
33
|
}),
|
|
34
|
+
Type.Object({
|
|
35
|
+
action: Type.Literal("insert"),
|
|
36
|
+
doc_token: Type.String({ description: "Document token" }),
|
|
37
|
+
content: Type.String({ description: "Markdown content to insert" }),
|
|
38
|
+
after_block_id: Type.String({
|
|
39
|
+
description: "Insert content after this block ID. Use list_blocks to find block IDs.",
|
|
40
|
+
}),
|
|
41
|
+
}),
|
|
20
42
|
Type.Object({
|
|
21
43
|
action: Type.Literal("create"),
|
|
22
44
|
title: Type.String({ description: "Document title" }),
|
|
23
45
|
folder_token: Type.Optional(Type.String({ description: "Target folder token (optional)" })),
|
|
46
|
+
grant_to_requester: Type.Optional(
|
|
47
|
+
Type.Boolean({
|
|
48
|
+
description:
|
|
49
|
+
"Grant edit permission to the trusted requesting Feishu user from runtime context (default: true).",
|
|
50
|
+
}),
|
|
51
|
+
),
|
|
24
52
|
}),
|
|
25
53
|
Type.Object({
|
|
26
54
|
action: Type.Literal("list_blocks"),
|
|
@@ -42,6 +70,113 @@ export const FeishuDocSchema = Type.Union([
|
|
|
42
70
|
doc_token: Type.String({ description: "Document token" }),
|
|
43
71
|
block_id: Type.String({ description: "Block ID" }),
|
|
44
72
|
}),
|
|
73
|
+
// Table creation (explicit structure)
|
|
74
|
+
Type.Object({
|
|
75
|
+
action: Type.Literal("create_table"),
|
|
76
|
+
...tableCreationProperties,
|
|
77
|
+
}),
|
|
78
|
+
Type.Object({
|
|
79
|
+
action: Type.Literal("write_table_cells"),
|
|
80
|
+
doc_token: Type.String({ description: "Document token" }),
|
|
81
|
+
table_block_id: Type.String({ description: "Table block ID" }),
|
|
82
|
+
values: Type.Array(Type.Array(Type.String()), {
|
|
83
|
+
description: "2D matrix values[row][col] to write into table cells",
|
|
84
|
+
minItems: 1,
|
|
85
|
+
}),
|
|
86
|
+
}),
|
|
87
|
+
Type.Object({
|
|
88
|
+
action: Type.Literal("create_table_with_values"),
|
|
89
|
+
...tableCreationProperties,
|
|
90
|
+
values: Type.Array(Type.Array(Type.String()), {
|
|
91
|
+
description: "2D matrix values[row][col] to write into table cells",
|
|
92
|
+
minItems: 1,
|
|
93
|
+
}),
|
|
94
|
+
}),
|
|
95
|
+
// Table row/column manipulation
|
|
96
|
+
Type.Object({
|
|
97
|
+
action: Type.Literal("insert_table_row"),
|
|
98
|
+
doc_token: Type.String({ description: "Document token" }),
|
|
99
|
+
block_id: Type.String({ description: "Table block ID" }),
|
|
100
|
+
row_index: Type.Optional(
|
|
101
|
+
Type.Number({ description: "Row index to insert at (-1 for end, default: -1)" }),
|
|
102
|
+
),
|
|
103
|
+
}),
|
|
104
|
+
Type.Object({
|
|
105
|
+
action: Type.Literal("insert_table_column"),
|
|
106
|
+
doc_token: Type.String({ description: "Document token" }),
|
|
107
|
+
block_id: Type.String({ description: "Table block ID" }),
|
|
108
|
+
column_index: Type.Optional(
|
|
109
|
+
Type.Number({ description: "Column index to insert at (-1 for end, default: -1)" }),
|
|
110
|
+
),
|
|
111
|
+
}),
|
|
112
|
+
Type.Object({
|
|
113
|
+
action: Type.Literal("delete_table_rows"),
|
|
114
|
+
doc_token: Type.String({ description: "Document token" }),
|
|
115
|
+
block_id: Type.String({ description: "Table block ID" }),
|
|
116
|
+
row_start: Type.Number({ description: "Start row index (0-based)" }),
|
|
117
|
+
row_count: Type.Optional(Type.Number({ description: "Number of rows to delete (default: 1)" })),
|
|
118
|
+
}),
|
|
119
|
+
Type.Object({
|
|
120
|
+
action: Type.Literal("delete_table_columns"),
|
|
121
|
+
doc_token: Type.String({ description: "Document token" }),
|
|
122
|
+
block_id: Type.String({ description: "Table block ID" }),
|
|
123
|
+
column_start: Type.Number({ description: "Start column index (0-based)" }),
|
|
124
|
+
column_count: Type.Optional(
|
|
125
|
+
Type.Number({ description: "Number of columns to delete (default: 1)" }),
|
|
126
|
+
),
|
|
127
|
+
}),
|
|
128
|
+
Type.Object({
|
|
129
|
+
action: Type.Literal("merge_table_cells"),
|
|
130
|
+
doc_token: Type.String({ description: "Document token" }),
|
|
131
|
+
block_id: Type.String({ description: "Table block ID" }),
|
|
132
|
+
row_start: Type.Number({ description: "Start row index" }),
|
|
133
|
+
row_end: Type.Number({ description: "End row index (exclusive)" }),
|
|
134
|
+
column_start: Type.Number({ description: "Start column index" }),
|
|
135
|
+
column_end: Type.Number({ description: "End column index (exclusive)" }),
|
|
136
|
+
}),
|
|
137
|
+
// Image / file upload
|
|
138
|
+
Type.Object({
|
|
139
|
+
action: Type.Literal("upload_image"),
|
|
140
|
+
doc_token: Type.String({ description: "Document token" }),
|
|
141
|
+
url: Type.Optional(Type.String({ description: "Remote image URL (http/https)" })),
|
|
142
|
+
file_path: Type.Optional(Type.String({ description: "Local image file path" })),
|
|
143
|
+
image: Type.Optional(
|
|
144
|
+
Type.String({
|
|
145
|
+
description:
|
|
146
|
+
"Image as data URI (data:image/png;base64,...) or plain base64 string. Use instead of url/file_path for DALL-E outputs, canvas screenshots, etc.",
|
|
147
|
+
}),
|
|
148
|
+
),
|
|
149
|
+
parent_block_id: Type.Optional(
|
|
150
|
+
Type.String({ description: "Parent block ID (default: document root)" }),
|
|
151
|
+
),
|
|
152
|
+
filename: Type.Optional(Type.String({ description: "Optional filename override" })),
|
|
153
|
+
index: Type.Optional(
|
|
154
|
+
Type.Integer({
|
|
155
|
+
minimum: 0,
|
|
156
|
+
description: "Insert position (0-based index among siblings). Omit to append.",
|
|
157
|
+
}),
|
|
158
|
+
),
|
|
159
|
+
}),
|
|
160
|
+
Type.Object({
|
|
161
|
+
action: Type.Literal("upload_file"),
|
|
162
|
+
doc_token: Type.String({ description: "Document token" }),
|
|
163
|
+
url: Type.Optional(Type.String({ description: "Remote file URL (http/https)" })),
|
|
164
|
+
file_path: Type.Optional(Type.String({ description: "Local file path" })),
|
|
165
|
+
parent_block_id: Type.Optional(
|
|
166
|
+
Type.String({ description: "Parent block ID (default: document root)" }),
|
|
167
|
+
),
|
|
168
|
+
filename: Type.Optional(Type.String({ description: "Optional filename override" })),
|
|
169
|
+
}),
|
|
170
|
+
// Text color / style
|
|
171
|
+
Type.Object({
|
|
172
|
+
action: Type.Literal("color_text"),
|
|
173
|
+
doc_token: Type.String({ description: "Document token" }),
|
|
174
|
+
block_id: Type.String({ description: "Text block ID to update" }),
|
|
175
|
+
content: Type.String({
|
|
176
|
+
description:
|
|
177
|
+
'Text with color markup. Tags: [red], [green], [blue], [orange], [yellow], [purple], [grey], [bold], [bg:yellow]. Example: "Revenue [green]+15%[/green] YoY"',
|
|
178
|
+
}),
|
|
179
|
+
}),
|
|
45
180
|
]);
|
|
46
181
|
|
|
47
182
|
export type FeishuDocParams = Static<typeof FeishuDocSchema>;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Batch insertion for large Feishu documents (>1000 blocks).
|
|
3
|
+
*
|
|
4
|
+
* The Feishu Descendant API has a limit of 1000 blocks per request.
|
|
5
|
+
* This module handles splitting large documents into batches while
|
|
6
|
+
* preserving parent-child relationships between blocks.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type * as Lark from "@larksuiteoapi/node-sdk";
|
|
10
|
+
import { cleanBlocksForDescendant } from "./docx-table-ops.js";
|
|
11
|
+
|
|
12
|
+
export const BATCH_SIZE = 1000; // Feishu API limit per request
|
|
13
|
+
|
|
14
|
+
type Logger = { info?: (msg: string) => void };
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Collect all descendant blocks for a given set of first-level block IDs.
|
|
18
|
+
* Recursively traverses the block tree to gather all children.
|
|
19
|
+
*/
|
|
20
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block types
|
|
21
|
+
function collectDescendants(blocks: any[], firstLevelIds: string[]): any[] {
|
|
22
|
+
const blockMap = new Map<string, any>();
|
|
23
|
+
for (const block of blocks) {
|
|
24
|
+
blockMap.set(block.block_id, block);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const result: any[] = [];
|
|
28
|
+
const visited = new Set<string>();
|
|
29
|
+
|
|
30
|
+
function collect(blockId: string) {
|
|
31
|
+
if (visited.has(blockId)) return;
|
|
32
|
+
visited.add(blockId);
|
|
33
|
+
|
|
34
|
+
const block = blockMap.get(blockId);
|
|
35
|
+
if (!block) return;
|
|
36
|
+
|
|
37
|
+
result.push(block);
|
|
38
|
+
|
|
39
|
+
// Recursively collect children
|
|
40
|
+
const children = block.children;
|
|
41
|
+
if (Array.isArray(children)) {
|
|
42
|
+
for (const childId of children) {
|
|
43
|
+
collect(childId);
|
|
44
|
+
}
|
|
45
|
+
} else if (typeof children === "string") {
|
|
46
|
+
collect(children);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
for (const id of firstLevelIds) {
|
|
51
|
+
collect(id);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Insert a single batch of blocks using Descendant API.
|
|
59
|
+
*
|
|
60
|
+
* @param parentBlockId - Parent block to insert into (defaults to docToken)
|
|
61
|
+
* @param index - Position within parent's children (-1 = end)
|
|
62
|
+
*/
|
|
63
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block types
|
|
64
|
+
async function insertBatch(
|
|
65
|
+
client: Lark.Client,
|
|
66
|
+
docToken: string,
|
|
67
|
+
blocks: any[],
|
|
68
|
+
firstLevelBlockIds: string[],
|
|
69
|
+
parentBlockId: string = docToken,
|
|
70
|
+
index: number = -1,
|
|
71
|
+
): Promise<any[]> {
|
|
72
|
+
const descendants = cleanBlocksForDescendant(blocks);
|
|
73
|
+
|
|
74
|
+
if (descendants.length === 0) {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const res = await client.docx.documentBlockDescendant.create({
|
|
79
|
+
path: { document_id: docToken, block_id: parentBlockId },
|
|
80
|
+
data: {
|
|
81
|
+
children_id: firstLevelBlockIds,
|
|
82
|
+
descendants,
|
|
83
|
+
index,
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
if (res.code !== 0) {
|
|
88
|
+
throw new Error(`${res.msg} (code: ${res.code})`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return res.data?.children ?? [];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Insert blocks in batches for large documents (>1000 blocks).
|
|
96
|
+
*
|
|
97
|
+
* Batches are split to ensure BOTH children_id AND descendants
|
|
98
|
+
* arrays stay under the 1000 block API limit.
|
|
99
|
+
*
|
|
100
|
+
* @param client - Feishu API client
|
|
101
|
+
* @param docToken - Document ID
|
|
102
|
+
* @param blocks - All blocks from Convert API
|
|
103
|
+
* @param firstLevelBlockIds - IDs of top-level blocks to insert
|
|
104
|
+
* @param logger - Optional logger for progress updates
|
|
105
|
+
* @param parentBlockId - Parent block to insert into (defaults to docToken = document root)
|
|
106
|
+
* @param startIndex - Starting position within parent (-1 = end). For multi-batch inserts,
|
|
107
|
+
* each batch advances this by the number of first-level IDs inserted so far.
|
|
108
|
+
* @returns Inserted children blocks and any skipped block IDs
|
|
109
|
+
*/
|
|
110
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block types
|
|
111
|
+
export async function insertBlocksInBatches(
|
|
112
|
+
client: Lark.Client,
|
|
113
|
+
docToken: string,
|
|
114
|
+
blocks: any[],
|
|
115
|
+
firstLevelBlockIds: string[],
|
|
116
|
+
logger?: Logger,
|
|
117
|
+
parentBlockId: string = docToken,
|
|
118
|
+
startIndex: number = -1,
|
|
119
|
+
): Promise<{ children: any[]; skipped: string[] }> {
|
|
120
|
+
const allChildren: any[] = [];
|
|
121
|
+
|
|
122
|
+
// Build batches ensuring each batch has ≤1000 total descendants
|
|
123
|
+
const batches: { firstLevelIds: string[]; blocks: any[] }[] = [];
|
|
124
|
+
let currentBatch: { firstLevelIds: string[]; blocks: any[] } = { firstLevelIds: [], blocks: [] };
|
|
125
|
+
const usedBlockIds = new Set<string>();
|
|
126
|
+
|
|
127
|
+
for (const firstLevelId of firstLevelBlockIds) {
|
|
128
|
+
const descendants = collectDescendants(blocks, [firstLevelId]);
|
|
129
|
+
const newBlocks = descendants.filter((b) => !usedBlockIds.has(b.block_id));
|
|
130
|
+
|
|
131
|
+
// A single block whose subtree exceeds the API limit cannot be split
|
|
132
|
+
// (a table or other compound block must be inserted atomically).
|
|
133
|
+
if (newBlocks.length > BATCH_SIZE) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
`Block "${firstLevelId}" has ${newBlocks.length} descendants, which exceeds the ` +
|
|
136
|
+
`Feishu API limit of ${BATCH_SIZE} blocks per request. ` +
|
|
137
|
+
`Please split the content into smaller sections.`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// If adding this first-level block would exceed limit, start new batch
|
|
142
|
+
if (
|
|
143
|
+
currentBatch.blocks.length + newBlocks.length > BATCH_SIZE &&
|
|
144
|
+
currentBatch.blocks.length > 0
|
|
145
|
+
) {
|
|
146
|
+
batches.push(currentBatch);
|
|
147
|
+
currentBatch = { firstLevelIds: [], blocks: [] };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Add to current batch
|
|
151
|
+
currentBatch.firstLevelIds.push(firstLevelId);
|
|
152
|
+
for (const block of newBlocks) {
|
|
153
|
+
currentBatch.blocks.push(block);
|
|
154
|
+
usedBlockIds.add(block.block_id);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Don't forget the last batch
|
|
159
|
+
if (currentBatch.blocks.length > 0) {
|
|
160
|
+
batches.push(currentBatch);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Insert each batch, advancing index for position-aware inserts.
|
|
164
|
+
// When startIndex == -1 (append to end), each batch appends after the previous.
|
|
165
|
+
// When startIndex >= 0, each batch starts at startIndex + count of first-level IDs already inserted.
|
|
166
|
+
let currentIndex = startIndex;
|
|
167
|
+
for (let i = 0; i < batches.length; i++) {
|
|
168
|
+
const batch = batches[i];
|
|
169
|
+
logger?.info?.(
|
|
170
|
+
`feishu_doc: Inserting batch ${i + 1}/${batches.length} (${batch.blocks.length} blocks)...`,
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const children = await insertBatch(
|
|
174
|
+
client,
|
|
175
|
+
docToken,
|
|
176
|
+
batch.blocks,
|
|
177
|
+
batch.firstLevelIds,
|
|
178
|
+
parentBlockId,
|
|
179
|
+
currentIndex,
|
|
180
|
+
);
|
|
181
|
+
allChildren.push(...children);
|
|
182
|
+
|
|
183
|
+
// Advance index only for explicit positions; -1 always means "after last inserted"
|
|
184
|
+
if (currentIndex !== -1) {
|
|
185
|
+
currentIndex += batch.firstLevelIds.length;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return { children: allChildren, skipped: [] };
|
|
190
|
+
}
|