@m1heng-clawd/feishu 0.1.10 → 0.1.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.
Files changed (47) hide show
  1. package/README.md +280 -7
  2. package/index.ts +6 -6
  3. package/package.json +13 -3
  4. package/skills/feishu-doc/SKILL.md +64 -2
  5. package/skills/feishu-task/SKILL.md +210 -0
  6. package/src/bitable-tools/index.ts +1 -0
  7. package/src/bot.ts +182 -71
  8. package/src/channel.ts +2 -0
  9. package/src/config-schema.ts +4 -0
  10. package/src/doc-tools/actions.ts +341 -0
  11. package/src/doc-tools/common.ts +33 -0
  12. package/src/doc-tools/index.ts +2 -0
  13. package/src/doc-tools/register.ts +90 -0
  14. package/src/{doc-schema.ts → doc-tools/schemas.ts} +39 -1
  15. package/src/{docx.ts → doc-write-service.ts} +204 -351
  16. package/src/drive-tools/actions.ts +182 -0
  17. package/src/drive-tools/common.ts +18 -0
  18. package/src/drive-tools/index.ts +2 -0
  19. package/src/drive-tools/register.ts +71 -0
  20. package/src/{drive-schema.ts → drive-tools/schemas.ts} +2 -1
  21. package/src/media.ts +5 -3
  22. package/src/onboarding.ts +14 -4
  23. package/src/perm-tools/actions.ts +111 -0
  24. package/src/perm-tools/common.ts +18 -0
  25. package/src/perm-tools/index.ts +2 -0
  26. package/src/perm-tools/register.ts +65 -0
  27. package/src/policy.ts +13 -0
  28. package/src/reply-dispatcher.ts +6 -4
  29. package/src/send.ts +33 -2
  30. package/src/task-tools/actions.ts +466 -13
  31. package/src/task-tools/constants.ts +13 -0
  32. package/src/task-tools/index.ts +1 -0
  33. package/src/task-tools/register.ts +175 -13
  34. package/src/task-tools/schemas.ts +433 -4
  35. package/src/text/markdown-links.ts +104 -0
  36. package/src/types.ts +1 -0
  37. package/src/wiki-tools/actions.ts +166 -0
  38. package/src/wiki-tools/common.ts +18 -0
  39. package/src/wiki-tools/index.ts +2 -0
  40. package/src/wiki-tools/register.ts +66 -0
  41. package/src/bitable.ts +0 -1
  42. package/src/drive.ts +0 -264
  43. package/src/perm.ts +0 -165
  44. package/src/task.ts +0 -1
  45. package/src/wiki.ts +0 -223
  46. /package/src/{perm-schema.ts → perm-tools/schemas.ts} +0 -0
  47. /package/src/{wiki-schema.ts → wiki-tools/schemas.ts} +0 -0
@@ -0,0 +1,341 @@
1
+ import { appendDoc, createAndWriteDoc, createDoc, writeDoc } from "../doc-write-service.js";
2
+ import { detectDocFormat, runDocApiCall, type DocClient } from "./common.js";
3
+ import type { FeishuDocParams } from "./schemas.js";
4
+
5
+ const BLOCK_TYPE_NAMES: Record<number, string> = {
6
+ 1: "Page",
7
+ 2: "Text",
8
+ 3: "Heading1",
9
+ 4: "Heading2",
10
+ 5: "Heading3",
11
+ 12: "Bullet",
12
+ 13: "Ordered",
13
+ 14: "Code",
14
+ 15: "Quote",
15
+ 17: "Todo",
16
+ 18: "Bitable",
17
+ 21: "Diagram",
18
+ 22: "Divider",
19
+ 23: "File",
20
+ 27: "Image",
21
+ 30: "Sheet",
22
+ 31: "Table",
23
+ 32: "TableCell",
24
+ };
25
+
26
+ const STRUCTURED_BLOCK_TYPES = new Set([14, 18, 21, 23, 27, 30, 31, 32]);
27
+
28
+ function buildCommentContent(content: string) {
29
+ return {
30
+ elements: [
31
+ {
32
+ text_run: { text: content },
33
+ type: "text_run" as const,
34
+ },
35
+ ],
36
+ };
37
+ }
38
+
39
+ function normalizePageSize(pageSize?: number) {
40
+ if (pageSize === undefined) return 50;
41
+ if (!Number.isInteger(pageSize) || pageSize < 1) {
42
+ throw new Error("page_size must be a positive integer");
43
+ }
44
+ return pageSize;
45
+ }
46
+
47
+ function omitUndefined<T extends Record<string, unknown>>(obj: T): T {
48
+ return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== undefined)) as T;
49
+ }
50
+
51
+ async function readLegacyDoc(client: DocClient, docToken: string) {
52
+ const domain = (client as any).domain ?? "https://open.feishu.cn";
53
+ const token = await client.tokenManager.getTenantAccessToken();
54
+ const response = await runDocApiCall("doc.v2.rawContent", () =>
55
+ client.httpInstance.get<{ code?: number; msg?: string; data?: { content?: string } }>(
56
+ `${domain}/open-apis/doc/v2/${docToken}/raw_content`,
57
+ {
58
+ headers: {
59
+ Authorization: `Bearer ${token}`,
60
+ },
61
+ },
62
+ ),
63
+ );
64
+
65
+ return {
66
+ content: response.data?.content,
67
+ format: "doc" as const,
68
+ hint: "Legacy document format. Only plain text content available. Title not included in this API response.",
69
+ };
70
+ }
71
+
72
+ async function readDoc(client: DocClient, docToken: string) {
73
+ const format = detectDocFormat(docToken);
74
+
75
+ if (format === "doc") {
76
+ return readLegacyDoc(client, docToken);
77
+ }
78
+
79
+ const [contentRes, infoRes, blocksRes] = await Promise.all([
80
+ runDocApiCall("docx.document.rawContent", () =>
81
+ client.docx.document.rawContent({ path: { document_id: docToken } }),
82
+ ),
83
+ runDocApiCall("docx.document.get", () =>
84
+ client.docx.document.get({ path: { document_id: docToken } }),
85
+ ),
86
+ runDocApiCall("docx.documentBlock.list", () =>
87
+ client.docx.documentBlock.list({ path: { document_id: docToken } }),
88
+ ),
89
+ ]);
90
+
91
+ const blocks = blocksRes.data?.items ?? [];
92
+ const blockCounts: Record<string, number> = {};
93
+ const structuredTypes: string[] = [];
94
+
95
+ for (const b of blocks) {
96
+ const type = b.block_type ?? 0;
97
+ const name = BLOCK_TYPE_NAMES[type] || `type_${type}`;
98
+ blockCounts[name] = (blockCounts[name] || 0) + 1;
99
+
100
+ if (STRUCTURED_BLOCK_TYPES.has(type) && !structuredTypes.includes(name)) {
101
+ structuredTypes.push(name);
102
+ }
103
+ }
104
+
105
+ let hint: string | undefined;
106
+ if (structuredTypes.length > 0) {
107
+ hint = `This document contains ${structuredTypes.join(", ")} which are NOT included in the plain text above. Use feishu_doc with action: "list_blocks" to get full content.`;
108
+ }
109
+
110
+ return {
111
+ title: infoRes.data?.document?.title,
112
+ content: contentRes.data?.content,
113
+ revision_id: infoRes.data?.document?.revision_id,
114
+ block_count: blocks.length,
115
+ block_types: blockCounts,
116
+ ...(hint && { hint }),
117
+ };
118
+ }
119
+
120
+ async function updateBlock(client: DocClient, docToken: string, blockId: string, content: string) {
121
+ await runDocApiCall("docx.documentBlock.get", () =>
122
+ client.docx.documentBlock.get({
123
+ path: { document_id: docToken, block_id: blockId },
124
+ }),
125
+ );
126
+
127
+ await runDocApiCall("docx.documentBlock.patch", () =>
128
+ client.docx.documentBlock.patch({
129
+ path: { document_id: docToken, block_id: blockId },
130
+ data: {
131
+ update_text_elements: {
132
+ elements: [{ text_run: { content } }],
133
+ },
134
+ },
135
+ }),
136
+ );
137
+
138
+ return { success: true, block_id: blockId };
139
+ }
140
+
141
+ async function deleteBlock(client: DocClient, docToken: string, blockId: string) {
142
+ const blockInfo = await runDocApiCall("docx.documentBlock.get", () =>
143
+ client.docx.documentBlock.get({
144
+ path: { document_id: docToken, block_id: blockId },
145
+ }),
146
+ );
147
+ const parentId = blockInfo.data?.block?.parent_id ?? docToken;
148
+
149
+ const children = await runDocApiCall("docx.documentBlockChildren.get", () =>
150
+ client.docx.documentBlockChildren.get({
151
+ path: { document_id: docToken, block_id: parentId },
152
+ }),
153
+ );
154
+
155
+ const items = children.data?.items ?? [];
156
+ const index = items.findIndex((item: any) => item.block_id === blockId);
157
+ if (index === -1) {
158
+ throw new Error("Block not found");
159
+ }
160
+
161
+ await runDocApiCall("docx.documentBlockChildren.batchDelete", () =>
162
+ client.docx.documentBlockChildren.batchDelete({
163
+ path: { document_id: docToken, block_id: parentId },
164
+ data: { start_index: index, end_index: index + 1 },
165
+ }),
166
+ );
167
+
168
+ return { success: true, deleted_block_id: blockId };
169
+ }
170
+
171
+ async function listBlocks(client: DocClient, docToken: string) {
172
+ const res = await runDocApiCall("docx.documentBlock.list", () =>
173
+ client.docx.documentBlock.list({
174
+ path: { document_id: docToken },
175
+ }),
176
+ );
177
+
178
+ return {
179
+ blocks: res.data?.items ?? [],
180
+ };
181
+ }
182
+
183
+ async function getBlock(client: DocClient, docToken: string, blockId: string) {
184
+ const res = await runDocApiCall("docx.documentBlock.get", () =>
185
+ client.docx.documentBlock.get({
186
+ path: { document_id: docToken, block_id: blockId },
187
+ }),
188
+ );
189
+
190
+ return {
191
+ block: res.data?.block,
192
+ };
193
+ }
194
+
195
+ async function listComments(client: DocClient, docToken: string, pageToken?: string, pageSize?: number) {
196
+ const res = await runDocApiCall("drive.fileComment.list", () =>
197
+ client.drive.fileComment.list({
198
+ path: { file_token: docToken },
199
+ params: omitUndefined({
200
+ file_type: "docx" as const,
201
+ page_token: pageToken,
202
+ page_size: normalizePageSize(pageSize),
203
+ }),
204
+ }),
205
+ );
206
+
207
+ return {
208
+ comments: Array.isArray(res.data?.items) ? res.data.items : [],
209
+ page_token: res.data?.page_token,
210
+ has_more: Boolean(res.data?.has_more),
211
+ };
212
+ }
213
+
214
+ async function createComment(client: DocClient, docToken: string, content: string) {
215
+ const res = await runDocApiCall("drive.fileComment.create", () =>
216
+ client.drive.fileComment.create({
217
+ path: { file_token: docToken },
218
+ params: {
219
+ file_type: "docx",
220
+ },
221
+ data: {
222
+ reply_list: {
223
+ replies: [
224
+ {
225
+ content: buildCommentContent(content),
226
+ },
227
+ ],
228
+ },
229
+ },
230
+ }),
231
+ );
232
+
233
+ if (!res.data?.comment_id) {
234
+ throw new Error("Comment creation failed: No comment ID returned");
235
+ }
236
+
237
+ return {
238
+ comment_id: res.data.comment_id,
239
+ comment: res.data,
240
+ };
241
+ }
242
+
243
+ async function getComment(client: DocClient, docToken: string, commentId: string) {
244
+ const res = await runDocApiCall("drive.fileComment.get", () =>
245
+ client.drive.fileComment.get({
246
+ path: { file_token: docToken, comment_id: commentId },
247
+ params: {
248
+ file_type: "docx",
249
+ },
250
+ }),
251
+ );
252
+
253
+ if (!res.data) {
254
+ throw new Error(`Comment not found: ${commentId}`);
255
+ }
256
+
257
+ return {
258
+ comment: res.data,
259
+ };
260
+ }
261
+
262
+ async function listCommentReplies(
263
+ client: DocClient,
264
+ docToken: string,
265
+ commentId: string,
266
+ pageToken?: string,
267
+ pageSize?: number,
268
+ ) {
269
+ const res = await runDocApiCall("drive.fileCommentReply.list", () =>
270
+ client.drive.fileCommentReply.list({
271
+ path: { file_token: docToken, comment_id: commentId },
272
+ params: omitUndefined({
273
+ file_type: "docx" as const,
274
+ page_token: pageToken,
275
+ page_size: normalizePageSize(pageSize),
276
+ }),
277
+ }),
278
+ );
279
+
280
+ return {
281
+ replies: Array.isArray(res.data?.items) ? res.data.items : [],
282
+ page_token: res.data?.page_token,
283
+ has_more: Boolean(res.data?.has_more),
284
+ };
285
+ }
286
+
287
+ export async function listAppScopes(client: DocClient) {
288
+ const res = await runDocApiCall("application.scope.list", () => client.application.scope.list({}));
289
+ const scopes = res.data?.scopes ?? [];
290
+ const granted = scopes.filter((s) => s.grant_status === 1);
291
+ const pending = scopes.filter((s) => s.grant_status !== 1);
292
+
293
+ return {
294
+ granted: granted.map((s) => ({ name: s.scope_name, type: s.scope_type })),
295
+ pending: pending.map((s) => ({ name: s.scope_name, type: s.scope_type })),
296
+ summary: `${granted.length} granted, ${pending.length} pending`,
297
+ };
298
+ }
299
+
300
+ export async function runDocAction(
301
+ client: DocClient,
302
+ params: FeishuDocParams,
303
+ mediaMaxBytes: number,
304
+ ) {
305
+ switch (params.action) {
306
+ case "read":
307
+ return readDoc(client, params.doc_token);
308
+ case "write":
309
+ return writeDoc(client, params.doc_token, params.content, mediaMaxBytes);
310
+ case "append":
311
+ return appendDoc(client, params.doc_token, params.content, mediaMaxBytes);
312
+ case "create":
313
+ return createDoc(client, params.title, params.folder_token);
314
+ case "create_and_write":
315
+ return createAndWriteDoc(
316
+ client,
317
+ params.title,
318
+ params.content,
319
+ mediaMaxBytes,
320
+ params.folder_token,
321
+ );
322
+ case "list_blocks":
323
+ return listBlocks(client, params.doc_token);
324
+ case "get_block":
325
+ return getBlock(client, params.doc_token, params.block_id);
326
+ case "update_block":
327
+ return updateBlock(client, params.doc_token, params.block_id, params.content);
328
+ case "delete_block":
329
+ return deleteBlock(client, params.doc_token, params.block_id);
330
+ case "list_comments":
331
+ return listComments(client, params.doc_token, params.page_token, params.page_size);
332
+ case "create_comment":
333
+ return createComment(client, params.doc_token, params.content);
334
+ case "get_comment":
335
+ return getComment(client, params.doc_token, params.comment_id);
336
+ case "list_comment_replies":
337
+ return listCommentReplies(client, params.doc_token, params.comment_id, params.page_token, params.page_size);
338
+ default:
339
+ return { error: `Unknown action: ${(params as any).action}` };
340
+ }
341
+ }
@@ -0,0 +1,33 @@
1
+ import { createFeishuClient } from "../client.js";
2
+ import {
3
+ errorResult,
4
+ json,
5
+ runFeishuApiCall,
6
+ type FeishuApiResponse,
7
+ } from "../tools-common/feishu-api.js";
8
+
9
+ export type DocClient = ReturnType<typeof createFeishuClient>;
10
+
11
+ export { json, errorResult };
12
+
13
+ export async function runDocApiCall<T extends FeishuApiResponse>(
14
+ context: string,
15
+ fn: () => Promise<T>,
16
+ ): Promise<T> {
17
+ return runFeishuApiCall(context, fn);
18
+ }
19
+
20
+ export type DocFormat = "docx" | "doc";
21
+
22
+ /**
23
+ * Detect document format from token.
24
+ * Legacy doc tokens: usually start with "doccn" and contain only alphanumeric chars.
25
+ * Docx tokens: Various formats that do not match legacy "doccn..." pattern.
26
+ */
27
+ export function detectDocFormat(token: string): DocFormat {
28
+ const normalizedToken = token.trim();
29
+ if (/^doccn[a-zA-Z0-9]+$/.test(normalizedToken)) {
30
+ return "doc";
31
+ }
32
+ return "docx";
33
+ }
@@ -0,0 +1,2 @@
1
+ export { registerFeishuDocTools } from "./register.js";
2
+ export { FeishuDocSchema, type FeishuDocParams } from "./schemas.js";
@@ -0,0 +1,90 @@
1
+ import { Type, type TSchema } from "@sinclair/typebox";
2
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
3
+ import type { ResolvedFeishuAccount } from "../types.js";
4
+ import { hasFeishuToolEnabledForAnyAccount, withFeishuToolClient } from "../tools-common/tool-exec.js";
5
+ import { listAppScopes, runDocAction } from "./actions.js";
6
+ import { errorResult, json, type DocClient } from "./common.js";
7
+ import { FeishuDocSchema, type FeishuDocParams } from "./schemas.js";
8
+
9
+ type DocToolSpec<P> = {
10
+ name: string;
11
+ label: string;
12
+ description: string;
13
+ parameters: TSchema;
14
+ requiredTool?: "doc" | "scopes";
15
+ run: (args: { client: DocClient; account: ResolvedFeishuAccount }, params: P) => Promise<unknown>;
16
+ };
17
+
18
+ function registerDocTool<P>(api: OpenClawPluginApi, spec: DocToolSpec<P>) {
19
+ api.registerTool(
20
+ {
21
+ name: spec.name,
22
+ label: spec.label,
23
+ description: spec.description,
24
+ parameters: spec.parameters,
25
+ async execute(_toolCallId, params) {
26
+ try {
27
+ return await withFeishuToolClient({
28
+ api,
29
+ toolName: spec.name,
30
+ requiredTool: spec.requiredTool,
31
+ run: async ({ client, account }) =>
32
+ json(await spec.run({ client: client as DocClient, account }, params as P)),
33
+ });
34
+ } catch (err) {
35
+ return errorResult(err);
36
+ }
37
+ },
38
+ },
39
+ { name: spec.name },
40
+ );
41
+ }
42
+
43
+ export function registerFeishuDocTools(api: OpenClawPluginApi) {
44
+ if (!api.config) {
45
+ api.logger.debug?.("feishu_doc: No config available, skipping doc tools");
46
+ return;
47
+ }
48
+
49
+ if (!hasFeishuToolEnabledForAnyAccount(api.config)) {
50
+ api.logger.debug?.("feishu_doc: No Feishu accounts configured, skipping doc tools");
51
+ return;
52
+ }
53
+
54
+ const docEnabled = hasFeishuToolEnabledForAnyAccount(api.config, "doc");
55
+ const scopesEnabled = hasFeishuToolEnabledForAnyAccount(api.config, "scopes");
56
+ const registered: string[] = [];
57
+
58
+ if (docEnabled) {
59
+ registerDocTool<FeishuDocParams>(api, {
60
+ name: "feishu_doc",
61
+ label: "Feishu Doc",
62
+ description:
63
+ 'Feishu document operations. Actions: read, write, append, create, create_and_write, list_blocks, get_block, update_block, delete_block, list_comments, create_comment, get_comment, list_comment_replies. Use "create_and_write" for atomic create + content write.',
64
+ parameters: FeishuDocSchema,
65
+ requiredTool: "doc",
66
+ run: async ({ client, account }, params) => {
67
+ const mediaMaxBytes = (account.config?.mediaMaxMb ?? 30) * 1024 * 1024;
68
+ return runDocAction(client, params, mediaMaxBytes);
69
+ },
70
+ });
71
+ registered.push("feishu_doc");
72
+ }
73
+
74
+ if (scopesEnabled) {
75
+ registerDocTool<Record<string, never>>(api, {
76
+ name: "feishu_app_scopes",
77
+ label: "Feishu App Scopes",
78
+ description:
79
+ "List current app permissions (scopes). Use to debug permission issues or check available capabilities.",
80
+ parameters: Type.Object({}),
81
+ requiredTool: "scopes",
82
+ run: async ({ client }) => listAppScopes(client),
83
+ });
84
+ registered.push("feishu_app_scopes");
85
+ }
86
+
87
+ if (registered.length > 0) {
88
+ api.logger.debug?.(`feishu_doc: Registered ${registered.join(", ")}`);
89
+ }
90
+ }
@@ -3,7 +3,10 @@ import { Type, type Static } from "@sinclair/typebox";
3
3
  export const FeishuDocSchema = Type.Union([
4
4
  Type.Object({
5
5
  action: Type.Literal("read"),
6
- doc_token: Type.String({ description: "Document token (extract from URL /docx/XXX)" }),
6
+ doc_token: Type.String({
7
+ description:
8
+ "Document token (extract from URL /docx/XXX or /docs/XXX). Supports both new (docx) and legacy (doc) formats.",
9
+ }),
7
10
  }),
8
11
  Type.Object({
9
12
  action: Type.Literal("write"),
@@ -22,6 +25,14 @@ export const FeishuDocSchema = Type.Union([
22
25
  title: Type.String({ description: "Document title" }),
23
26
  folder_token: Type.Optional(Type.String({ description: "Target folder token (optional)" })),
24
27
  }),
28
+ Type.Object({
29
+ action: Type.Literal("create_and_write"),
30
+ title: Type.String({ description: "Document title" }),
31
+ content: Type.String({
32
+ description: "Markdown content to write immediately after document creation",
33
+ }),
34
+ folder_token: Type.Optional(Type.String({ description: "Target folder token (optional)" })),
35
+ }),
25
36
  Type.Object({
26
37
  action: Type.Literal("list_blocks"),
27
38
  doc_token: Type.String({ description: "Document token" }),
@@ -42,6 +53,33 @@ export const FeishuDocSchema = Type.Union([
42
53
  doc_token: Type.String({ description: "Document token" }),
43
54
  block_id: Type.String({ description: "Block ID" }),
44
55
  }),
56
+ Type.Object({
57
+ action: Type.Literal("list_comments"),
58
+ doc_token: Type.String({ description: "Document token" }),
59
+ page_token: Type.Optional(Type.String({ description: "Page token for pagination" })),
60
+ page_size: Type.Optional(
61
+ Type.Integer({ minimum: 1, description: "Page size, default 50 (positive integer)" }),
62
+ ),
63
+ }),
64
+ Type.Object({
65
+ action: Type.Literal("create_comment"),
66
+ doc_token: Type.String({ description: "Document token" }),
67
+ content: Type.String({ description: "Comment content" }),
68
+ }),
69
+ Type.Object({
70
+ action: Type.Literal("get_comment"),
71
+ doc_token: Type.String({ description: "Document token" }),
72
+ comment_id: Type.String({ description: "Comment ID" }),
73
+ }),
74
+ Type.Object({
75
+ action: Type.Literal("list_comment_replies"),
76
+ doc_token: Type.String({ description: "Document token" }),
77
+ comment_id: Type.String({ description: "Comment ID" }),
78
+ page_token: Type.Optional(Type.String({ description: "Page token for pagination" })),
79
+ page_size: Type.Optional(
80
+ Type.Integer({ minimum: 1, description: "Page size, default 50 (positive integer)" }),
81
+ ),
82
+ }),
45
83
  ]);
46
84
 
47
85
  export type FeishuDocParams = Static<typeof FeishuDocSchema>;