@bobotu/feishu-fork 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +922 -0
- package/index.ts +65 -0
- package/openclaw.plugin.json +10 -0
- package/package.json +72 -0
- package/skills/feishu-doc/SKILL.md +161 -0
- package/skills/feishu-doc/references/block-types.md +102 -0
- package/skills/feishu-drive/SKILL.md +96 -0
- package/skills/feishu-perm/SKILL.md +90 -0
- package/skills/feishu-task/SKILL.md +210 -0
- package/skills/feishu-wiki/SKILL.md +96 -0
- package/src/accounts.ts +140 -0
- package/src/bitable-tools/actions.ts +199 -0
- package/src/bitable-tools/common.ts +90 -0
- package/src/bitable-tools/index.ts +1 -0
- package/src/bitable-tools/meta.ts +80 -0
- package/src/bitable-tools/register.ts +195 -0
- package/src/bitable-tools/schemas.ts +221 -0
- package/src/bot.ts +1125 -0
- package/src/channel.ts +334 -0
- package/src/client.ts +114 -0
- package/src/config-schema.ts +237 -0
- package/src/dedup.ts +54 -0
- package/src/directory.ts +165 -0
- package/src/doc-tools/actions.ts +341 -0
- package/src/doc-tools/common.ts +33 -0
- package/src/doc-tools/index.ts +2 -0
- package/src/doc-tools/register.ts +90 -0
- package/src/doc-tools/schemas.ts +85 -0
- package/src/doc-write-service.ts +711 -0
- package/src/drive-tools/actions.ts +182 -0
- package/src/drive-tools/common.ts +18 -0
- package/src/drive-tools/index.ts +2 -0
- package/src/drive-tools/register.ts +71 -0
- package/src/drive-tools/schemas.ts +67 -0
- package/src/dynamic-agent.ts +135 -0
- package/src/external-keys.ts +19 -0
- package/src/media.ts +510 -0
- package/src/mention.ts +121 -0
- package/src/monitor.ts +323 -0
- package/src/onboarding.ts +449 -0
- package/src/outbound.ts +40 -0
- package/src/perm-tools/actions.ts +111 -0
- package/src/perm-tools/common.ts +18 -0
- package/src/perm-tools/index.ts +2 -0
- package/src/perm-tools/register.ts +65 -0
- package/src/perm-tools/schemas.ts +52 -0
- package/src/policy.ts +117 -0
- package/src/probe.ts +147 -0
- package/src/reactions.ts +160 -0
- package/src/reply-dispatcher.ts +240 -0
- package/src/runtime.ts +14 -0
- package/src/send.ts +391 -0
- package/src/streaming-card.ts +211 -0
- package/src/targets.ts +58 -0
- package/src/task-tools/actions.ts +590 -0
- package/src/task-tools/common.ts +18 -0
- package/src/task-tools/constants.ts +13 -0
- package/src/task-tools/index.ts +1 -0
- package/src/task-tools/register.ts +263 -0
- package/src/task-tools/schemas.ts +567 -0
- package/src/text/markdown-links.ts +104 -0
- package/src/tools-common/feishu-api.ts +184 -0
- package/src/tools-common/tool-context.ts +23 -0
- package/src/tools-common/tool-exec.ts +73 -0
- package/src/tools-config.ts +22 -0
- package/src/types.ts +79 -0
- package/src/typing.ts +75 -0
- package/src/wiki-tools/actions.ts +166 -0
- package/src/wiki-tools/common.ts +18 -0
- package/src/wiki-tools/index.ts +2 -0
- package/src/wiki-tools/register.ts +66 -0
- package/src/wiki-tools/schemas.ts +55 -0
package/src/dedup.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createDedupeCache, createPersistentDedupe } from "openclaw/plugin-sdk";
|
|
4
|
+
|
|
5
|
+
// Persistent TTL: 24 hours — survives restarts & WebSocket reconnects.
|
|
6
|
+
const DEDUP_TTL_MS = 24 * 60 * 60 * 1000;
|
|
7
|
+
const MEMORY_MAX_SIZE = 1_000;
|
|
8
|
+
const FILE_MAX_ENTRIES = 10_000;
|
|
9
|
+
|
|
10
|
+
const memoryDedupe = createDedupeCache({ ttlMs: DEDUP_TTL_MS, maxSize: MEMORY_MAX_SIZE });
|
|
11
|
+
|
|
12
|
+
function resolveStateDirFromEnv(env: NodeJS.ProcessEnv = process.env): string {
|
|
13
|
+
const stateOverride = env.OPENCLAW_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim();
|
|
14
|
+
if (stateOverride) {
|
|
15
|
+
return stateOverride;
|
|
16
|
+
}
|
|
17
|
+
if (env.VITEST || env.NODE_ENV === "test") {
|
|
18
|
+
return path.join(os.tmpdir(), ["openclaw-vitest", String(process.pid)].join("-"));
|
|
19
|
+
}
|
|
20
|
+
return path.join(os.homedir(), ".openclaw");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function resolveNamespaceFilePath(namespace: string): string {
|
|
24
|
+
const safe = namespace.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
25
|
+
return path.join(resolveStateDirFromEnv(), "feishu", "dedup", `${safe}.json`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const persistentDedupe = createPersistentDedupe({
|
|
29
|
+
ttlMs: DEDUP_TTL_MS,
|
|
30
|
+
memoryMaxSize: MEMORY_MAX_SIZE,
|
|
31
|
+
fileMaxEntries: FILE_MAX_ENTRIES,
|
|
32
|
+
resolveFilePath: resolveNamespaceFilePath,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Synchronous dedup — memory only.
|
|
37
|
+
* Kept for backward compatibility; prefer {@link tryRecordMessagePersistent}.
|
|
38
|
+
*/
|
|
39
|
+
export function tryRecordMessage(messageId: string): boolean {
|
|
40
|
+
return !memoryDedupe.check(messageId);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function tryRecordMessagePersistent(
|
|
44
|
+
messageId: string,
|
|
45
|
+
namespace = "global",
|
|
46
|
+
log?: (...args: unknown[]) => void,
|
|
47
|
+
): Promise<boolean> {
|
|
48
|
+
return persistentDedupe.checkAndRecord(messageId, {
|
|
49
|
+
namespace,
|
|
50
|
+
onDiskError: (error) => {
|
|
51
|
+
log?.(`feishu-dedup: disk error, falling back to memory: ${String(error)}`);
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
}
|
package/src/directory.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
import { createFeishuClient } from "./client.js";
|
|
3
|
+
import { resolveFeishuAccount } from "./accounts.js";
|
|
4
|
+
import { normalizeFeishuTarget } from "./targets.js";
|
|
5
|
+
|
|
6
|
+
export type FeishuDirectoryPeer = {
|
|
7
|
+
kind: "user";
|
|
8
|
+
id: string;
|
|
9
|
+
name?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type FeishuDirectoryGroup = {
|
|
13
|
+
kind: "group";
|
|
14
|
+
id: string;
|
|
15
|
+
name?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export async function listFeishuDirectoryPeers(params: {
|
|
19
|
+
cfg: ClawdbotConfig;
|
|
20
|
+
query?: string;
|
|
21
|
+
limit?: number;
|
|
22
|
+
accountId?: string;
|
|
23
|
+
}): Promise<FeishuDirectoryPeer[]> {
|
|
24
|
+
const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
|
|
25
|
+
const feishuCfg = account.config;
|
|
26
|
+
const q = params.query?.trim().toLowerCase() || "";
|
|
27
|
+
const ids = new Set<string>();
|
|
28
|
+
|
|
29
|
+
for (const entry of feishuCfg?.allowFrom ?? []) {
|
|
30
|
+
const trimmed = String(entry).trim();
|
|
31
|
+
if (trimmed && trimmed !== "*") ids.add(trimmed);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
for (const userId of Object.keys(feishuCfg?.dms ?? {})) {
|
|
35
|
+
const trimmed = userId.trim();
|
|
36
|
+
if (trimmed) ids.add(trimmed);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return Array.from(ids)
|
|
40
|
+
.map((raw) => raw.trim())
|
|
41
|
+
.filter(Boolean)
|
|
42
|
+
.map((raw) => normalizeFeishuTarget(raw) ?? raw)
|
|
43
|
+
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
|
44
|
+
.slice(0, params.limit && params.limit > 0 ? params.limit : undefined)
|
|
45
|
+
.map((id) => ({ kind: "user" as const, id }));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function listFeishuDirectoryGroups(params: {
|
|
49
|
+
cfg: ClawdbotConfig;
|
|
50
|
+
query?: string;
|
|
51
|
+
limit?: number;
|
|
52
|
+
accountId?: string;
|
|
53
|
+
}): Promise<FeishuDirectoryGroup[]> {
|
|
54
|
+
const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
|
|
55
|
+
const feishuCfg = account.config;
|
|
56
|
+
const q = params.query?.trim().toLowerCase() || "";
|
|
57
|
+
const ids = new Set<string>();
|
|
58
|
+
|
|
59
|
+
for (const groupId of Object.keys(feishuCfg?.groups ?? {})) {
|
|
60
|
+
const trimmed = groupId.trim();
|
|
61
|
+
if (trimmed && trimmed !== "*") ids.add(trimmed);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
for (const entry of feishuCfg?.groupAllowFrom ?? []) {
|
|
65
|
+
const trimmed = String(entry).trim();
|
|
66
|
+
if (trimmed && trimmed !== "*") ids.add(trimmed);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return Array.from(ids)
|
|
70
|
+
.map((raw) => raw.trim())
|
|
71
|
+
.filter(Boolean)
|
|
72
|
+
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
|
73
|
+
.slice(0, params.limit && params.limit > 0 ? params.limit : undefined)
|
|
74
|
+
.map((id) => ({ kind: "group" as const, id }));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function listFeishuDirectoryPeersLive(params: {
|
|
78
|
+
cfg: ClawdbotConfig;
|
|
79
|
+
query?: string;
|
|
80
|
+
limit?: number;
|
|
81
|
+
accountId?: string;
|
|
82
|
+
}): Promise<FeishuDirectoryPeer[]> {
|
|
83
|
+
const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
|
|
84
|
+
if (!account.configured) {
|
|
85
|
+
return listFeishuDirectoryPeers(params);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const client = createFeishuClient(account);
|
|
90
|
+
const peers: FeishuDirectoryPeer[] = [];
|
|
91
|
+
const limit = params.limit ?? 50;
|
|
92
|
+
|
|
93
|
+
const response = await client.contact.user.list({
|
|
94
|
+
params: {
|
|
95
|
+
page_size: Math.min(limit, 50),
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
if (response.code === 0 && response.data?.items) {
|
|
100
|
+
for (const user of response.data.items) {
|
|
101
|
+
if (user.open_id) {
|
|
102
|
+
const q = params.query?.trim().toLowerCase() || "";
|
|
103
|
+
const name = user.name || "";
|
|
104
|
+
if (!q || user.open_id.toLowerCase().includes(q) || name.toLowerCase().includes(q)) {
|
|
105
|
+
peers.push({
|
|
106
|
+
kind: "user",
|
|
107
|
+
id: user.open_id,
|
|
108
|
+
name: name || undefined,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (peers.length >= limit) break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return peers;
|
|
117
|
+
} catch {
|
|
118
|
+
return listFeishuDirectoryPeers(params);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function listFeishuDirectoryGroupsLive(params: {
|
|
123
|
+
cfg: ClawdbotConfig;
|
|
124
|
+
query?: string;
|
|
125
|
+
limit?: number;
|
|
126
|
+
accountId?: string;
|
|
127
|
+
}): Promise<FeishuDirectoryGroup[]> {
|
|
128
|
+
const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
|
|
129
|
+
if (!account.configured) {
|
|
130
|
+
return listFeishuDirectoryGroups(params);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const client = createFeishuClient(account);
|
|
135
|
+
const groups: FeishuDirectoryGroup[] = [];
|
|
136
|
+
const limit = params.limit ?? 50;
|
|
137
|
+
|
|
138
|
+
const response = await client.im.chat.list({
|
|
139
|
+
params: {
|
|
140
|
+
page_size: Math.min(limit, 100),
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
if (response.code === 0 && response.data?.items) {
|
|
145
|
+
for (const chat of response.data.items) {
|
|
146
|
+
if (chat.chat_id) {
|
|
147
|
+
const q = params.query?.trim().toLowerCase() || "";
|
|
148
|
+
const name = chat.name || "";
|
|
149
|
+
if (!q || chat.chat_id.toLowerCase().includes(q) || name.toLowerCase().includes(q)) {
|
|
150
|
+
groups.push({
|
|
151
|
+
kind: "group",
|
|
152
|
+
id: chat.chat_id,
|
|
153
|
+
name: name || undefined,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (groups.length >= limit) break;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return groups;
|
|
162
|
+
} catch {
|
|
163
|
+
return listFeishuDirectoryGroups(params);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -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,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
|
+
}
|