feishu-user-plugin 1.3.6 → 1.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/.claude-plugin/plugin.json +2 -2
- package/CHANGELOG.md +22 -0
- package/README.md +55 -40
- package/package.json +10 -3
- package/scripts/check-tool-count.js +15 -0
- package/scripts/check-version.js +40 -0
- package/scripts/smoke.js +224 -0
- package/scripts/sync-claude-md.sh +12 -0
- package/scripts/sync-team-skills.sh +22 -0
- package/scripts/test-all-tools.js +158 -0
- package/skills/feishu-user-plugin/SKILL.md +5 -5
- package/skills/feishu-user-plugin/references/CLAUDE.md +138 -99
- package/skills/feishu-user-plugin/references/table.md +18 -9
- package/src/auth/credentials.js +350 -0
- package/src/cli.js +42 -13
- package/src/clients/official/base.js +424 -0
- package/src/clients/official/bitable.js +269 -0
- package/src/clients/official/calendar.js +176 -0
- package/src/clients/official/contacts.js +54 -0
- package/src/clients/official/docs.js +301 -0
- package/src/clients/official/drive.js +77 -0
- package/src/clients/official/groups.js +68 -0
- package/src/clients/official/im.js +414 -0
- package/src/clients/official/index.js +30 -0
- package/src/clients/official/okr.js +127 -0
- package/src/clients/official/tasks.js +142 -0
- package/src/clients/official/uploads.js +260 -0
- package/src/clients/official/wiki.js +207 -0
- package/src/{client.js → clients/user.js} +23 -17
- package/src/index.js +4 -1977
- package/src/logger.js +20 -0
- package/src/oauth.js +5 -1
- package/src/official.js +5 -1944
- package/src/prompts/_registry.js +69 -0
- package/src/prompts/index.js +54 -0
- package/src/server.js +242 -0
- package/src/test-all.js +2 -2
- package/src/test-comprehensive.js +3 -3
- package/src/test-send.js +1 -1
- package/src/tools/_registry.js +30 -0
- package/src/tools/bitable.js +246 -0
- package/src/tools/calendar.js +207 -0
- package/src/tools/contacts.js +66 -0
- package/src/tools/diagnostics.js +172 -0
- package/src/tools/docs.js +158 -0
- package/src/tools/drive.js +111 -0
- package/src/tools/groups.js +81 -0
- package/src/tools/im-read.js +259 -0
- package/src/tools/messaging-bot.js +151 -0
- package/src/tools/messaging-user.js +292 -0
- package/src/tools/okr.js +159 -0
- package/src/tools/profile.js +43 -0
- package/src/tools/tasks.js +168 -0
- package/src/tools/uploads.js +63 -0
- package/src/tools/wiki.js +191 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// src/clients/official/tasks.js — Feishu Tasks v2 API.
|
|
2
|
+
//
|
|
3
|
+
// Feishu's Task v2 API. Reference:
|
|
4
|
+
// https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/task-v2/task/overview
|
|
5
|
+
//
|
|
6
|
+
// All methods are UAT-first since tasks are user-owned by default. Requires
|
|
7
|
+
// `task:task` scope on the OAuth.
|
|
8
|
+
//
|
|
9
|
+
// Note on identifiers: v2 uses `task_guid` (not numeric task_id like v1). All
|
|
10
|
+
// our methods accept and return guid strings.
|
|
11
|
+
//
|
|
12
|
+
// Note on completion: there is no separate `complete()` endpoint in v2 —
|
|
13
|
+
// completion is a `patch` setting `completed_at` to a unix-millis string
|
|
14
|
+
// ("0" to uncomplete) plus update_fields=['completed_at'].
|
|
15
|
+
|
|
16
|
+
module.exports = {
|
|
17
|
+
async listTasks({ completed, type, pageSize, pageToken } = {}) {
|
|
18
|
+
const params = {};
|
|
19
|
+
if (completed !== undefined) params.completed = String(!!completed);
|
|
20
|
+
if (type) params.type = type;
|
|
21
|
+
if (pageSize) params.page_size = String(pageSize);
|
|
22
|
+
if (pageToken) params.page_token = pageToken;
|
|
23
|
+
const res = await this._asUserOrApp({
|
|
24
|
+
uatPath: `/open-apis/task/v2/tasks`,
|
|
25
|
+
method: 'GET',
|
|
26
|
+
query: params,
|
|
27
|
+
sdkFn: () => this.client.task.v2.task.list({
|
|
28
|
+
params: {
|
|
29
|
+
...(completed !== undefined ? { completed: !!completed } : {}),
|
|
30
|
+
...(type ? { type } : {}),
|
|
31
|
+
...(pageSize ? { page_size: pageSize } : {}),
|
|
32
|
+
...(pageToken ? { page_token: pageToken } : {}),
|
|
33
|
+
},
|
|
34
|
+
}),
|
|
35
|
+
label: 'listTasks',
|
|
36
|
+
});
|
|
37
|
+
return {
|
|
38
|
+
items: res.data.items || [],
|
|
39
|
+
pageToken: res.data.page_token,
|
|
40
|
+
hasMore: res.data.has_more,
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
async getTask(taskGuid) {
|
|
45
|
+
if (!taskGuid) throw new Error('getTask: task_guid is required');
|
|
46
|
+
const res = await this._asUserOrApp({
|
|
47
|
+
uatPath: `/open-apis/task/v2/tasks/${encodeURIComponent(taskGuid)}`,
|
|
48
|
+
method: 'GET',
|
|
49
|
+
sdkFn: () => this.client.task.v2.task.get({ path: { task_guid: taskGuid } }),
|
|
50
|
+
label: 'getTask',
|
|
51
|
+
});
|
|
52
|
+
return { task: res.data.task };
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
async createTask(taskData) {
|
|
56
|
+
if (!taskData?.summary) throw new Error('createTask: summary is required');
|
|
57
|
+
const res = await this._asUserOrApp({
|
|
58
|
+
uatPath: `/open-apis/task/v2/tasks`,
|
|
59
|
+
method: 'POST',
|
|
60
|
+
body: taskData,
|
|
61
|
+
sdkFn: () => this.client.task.v2.task.create({ data: taskData }),
|
|
62
|
+
label: 'createTask',
|
|
63
|
+
});
|
|
64
|
+
const out = { task: res.data.task, viaUser: !!res._viaUser };
|
|
65
|
+
if (res._fallbackWarning) out.fallbackWarning = res._fallbackWarning;
|
|
66
|
+
return out;
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
async updateTask(taskGuid, taskUpdates, updateFields) {
|
|
70
|
+
if (!taskGuid) throw new Error('updateTask: task_guid is required');
|
|
71
|
+
if (!Array.isArray(updateFields) || updateFields.length === 0) {
|
|
72
|
+
throw new Error('updateTask: update_fields array is required (e.g. ["summary","due","completed_at"]) — Feishu only patches the listed fields.');
|
|
73
|
+
}
|
|
74
|
+
const body = { task: taskUpdates || {}, update_fields: updateFields };
|
|
75
|
+
const res = await this._asUserOrApp({
|
|
76
|
+
uatPath: `/open-apis/task/v2/tasks/${encodeURIComponent(taskGuid)}`,
|
|
77
|
+
method: 'PATCH',
|
|
78
|
+
body,
|
|
79
|
+
sdkFn: () => this.client.task.v2.task.patch({
|
|
80
|
+
path: { task_guid: taskGuid },
|
|
81
|
+
data: body,
|
|
82
|
+
}),
|
|
83
|
+
label: 'updateTask',
|
|
84
|
+
});
|
|
85
|
+
const out = { task: res.data.task, viaUser: !!res._viaUser };
|
|
86
|
+
if (res._fallbackWarning) out.fallbackWarning = res._fallbackWarning;
|
|
87
|
+
return out;
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
// completed=true marks done now; completed=false un-completes (sets completed_at to "0")
|
|
91
|
+
async completeTask(taskGuid, completed = true) {
|
|
92
|
+
if (!taskGuid) throw new Error('completeTask: task_guid is required');
|
|
93
|
+
const completedAt = completed ? String(Date.now()) : '0';
|
|
94
|
+
return this.updateTask(taskGuid, { completed_at: completedAt }, ['completed_at']);
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
async deleteTask(taskGuid) {
|
|
98
|
+
if (!taskGuid) throw new Error('deleteTask: task_guid is required');
|
|
99
|
+
const res = await this._asUserOrApp({
|
|
100
|
+
uatPath: `/open-apis/task/v2/tasks/${encodeURIComponent(taskGuid)}`,
|
|
101
|
+
method: 'DELETE',
|
|
102
|
+
sdkFn: () => this.client.task.v2.task.delete({ path: { task_guid: taskGuid } }),
|
|
103
|
+
label: 'deleteTask',
|
|
104
|
+
});
|
|
105
|
+
return { deleted: true, viaUser: !!res._viaUser };
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
// members: array of {id, type?, role, name?}. role is typically "assignee" or "follower".
|
|
109
|
+
async addTaskMembers(taskGuid, members) {
|
|
110
|
+
if (!taskGuid) throw new Error('addTaskMembers: task_guid is required');
|
|
111
|
+
if (!Array.isArray(members) || members.length === 0) throw new Error('addTaskMembers: members array required ({id, role}, role=assignee|follower)');
|
|
112
|
+
const body = { members };
|
|
113
|
+
const res = await this._asUserOrApp({
|
|
114
|
+
uatPath: `/open-apis/task/v2/tasks/${encodeURIComponent(taskGuid)}/add_members`,
|
|
115
|
+
method: 'POST',
|
|
116
|
+
body,
|
|
117
|
+
sdkFn: () => this.client.task.v2.task.addMembers({
|
|
118
|
+
path: { task_guid: taskGuid },
|
|
119
|
+
data: body,
|
|
120
|
+
}),
|
|
121
|
+
label: 'addTaskMembers',
|
|
122
|
+
});
|
|
123
|
+
return { task: res.data.task, viaUser: !!res._viaUser };
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
async removeTaskMembers(taskGuid, members) {
|
|
127
|
+
if (!taskGuid) throw new Error('removeTaskMembers: task_guid is required');
|
|
128
|
+
if (!Array.isArray(members) || members.length === 0) throw new Error('removeTaskMembers: members array required');
|
|
129
|
+
const body = { members };
|
|
130
|
+
const res = await this._asUserOrApp({
|
|
131
|
+
uatPath: `/open-apis/task/v2/tasks/${encodeURIComponent(taskGuid)}/remove_members`,
|
|
132
|
+
method: 'POST',
|
|
133
|
+
body,
|
|
134
|
+
sdkFn: () => this.client.task.v2.task.removeMembers({
|
|
135
|
+
path: { task_guid: taskGuid },
|
|
136
|
+
data: body,
|
|
137
|
+
}),
|
|
138
|
+
label: 'removeTaskMembers',
|
|
139
|
+
});
|
|
140
|
+
return { task: res.data.task, viaUser: !!res._viaUser };
|
|
141
|
+
},
|
|
142
|
+
};
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// src/clients/official/uploads.js
|
|
2
|
+
// Mixed into LarkOfficialClient.prototype by ./index.js (or temporarily by
|
|
3
|
+
// ./base.js during phase A.4–A.11). Methods receive `this` bound to the
|
|
4
|
+
// LarkOfficialClient instance, so they can use this.client, this._safeSDKCall,
|
|
5
|
+
// this._asUserOrApp, this._uatREST, etc. — all defined in base.js.
|
|
6
|
+
|
|
7
|
+
const { fetchWithTimeout } = require('../../utils');
|
|
8
|
+
|
|
9
|
+
module.exports = {
|
|
10
|
+
// --- Upload ---
|
|
11
|
+
|
|
12
|
+
async uploadImage(imagePath, imageType = 'message') {
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const res = await this._safeSDKCall(
|
|
15
|
+
() => this.client.im.image.create({
|
|
16
|
+
data: { image_type: imageType, image: fs.createReadStream(imagePath) },
|
|
17
|
+
}),
|
|
18
|
+
'uploadImage'
|
|
19
|
+
);
|
|
20
|
+
// SDK multipart responses may have data at top level or nested under .data
|
|
21
|
+
const imageKey = res.data?.image_key || res.image_key;
|
|
22
|
+
if (!imageKey) throw new Error(`uploadImage: unexpected response structure: ${JSON.stringify(res).slice(0, 500)}`);
|
|
23
|
+
return { imageKey };
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
async uploadFile(filePath, fileType = 'stream', fileName) {
|
|
27
|
+
const fs = require('fs');
|
|
28
|
+
const path = require('path');
|
|
29
|
+
if (!fileName) fileName = path.basename(filePath);
|
|
30
|
+
const res = await this._safeSDKCall(
|
|
31
|
+
() => this.client.im.file.create({
|
|
32
|
+
data: {
|
|
33
|
+
file_type: fileType,
|
|
34
|
+
file_name: fileName,
|
|
35
|
+
file: fs.createReadStream(filePath),
|
|
36
|
+
},
|
|
37
|
+
}),
|
|
38
|
+
'uploadFile'
|
|
39
|
+
);
|
|
40
|
+
// SDK multipart responses may have data at top level or nested under .data
|
|
41
|
+
const fileKey = res.data?.file_key || res.file_key;
|
|
42
|
+
if (!fileKey) throw new Error(`uploadFile: unexpected response structure: ${JSON.stringify(res).slice(0, 500)}`);
|
|
43
|
+
return { fileKey };
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
// --- Docx Image Read (v1.3.4) ---
|
|
47
|
+
|
|
48
|
+
// Download a media asset (image, file, etc.) referenced from inside a Feishu
|
|
49
|
+
// docx block. The model actually gets the pixels via MCP image content in the
|
|
50
|
+
// handler layer; here we just return base64 + metadata.
|
|
51
|
+
//
|
|
52
|
+
// Feishu's drive/v1/medias/{token}/download requires a query `extra` with
|
|
53
|
+
// a JSON-encoded doc_token when the media lives inside a doc (to pass
|
|
54
|
+
// tenant-scoped auth). Passing extra is harmless for generic drive files.
|
|
55
|
+
async downloadDocImage(imageToken, docToken, docType = 'docx') {
|
|
56
|
+
if (!imageToken) throw new Error('downloadDocImage: imageToken is required');
|
|
57
|
+
// Feishu's drive media download uses `extra` as a JSON-string query param to
|
|
58
|
+
// identify the enclosing doc context. Most observed forms carry both
|
|
59
|
+
// `doc_type` and `doc_token`; omitting docType falls back to 'docx' which
|
|
60
|
+
// is the by-far most common case. Omitting extra entirely is safe for
|
|
61
|
+
// standalone drive-media tokens that don't live inside a doc.
|
|
62
|
+
const extra = docToken
|
|
63
|
+
? `?extra=${encodeURIComponent(JSON.stringify({ doc_type: docType, doc_token: docToken }))}`
|
|
64
|
+
: '';
|
|
65
|
+
const path = `/open-apis/drive/v1/medias/${encodeURIComponent(imageToken)}/download${extra}`;
|
|
66
|
+
const url = 'https://open.feishu.cn' + path;
|
|
67
|
+
|
|
68
|
+
// Attempt 1 — user identity (most reliable for user-owned docs).
|
|
69
|
+
if (this.hasUAT) {
|
|
70
|
+
try {
|
|
71
|
+
const uat = await this._getValidUAT();
|
|
72
|
+
const res = await fetchWithTimeout(url, { headers: { 'Authorization': `Bearer ${uat}` }, timeoutMs: 60000 });
|
|
73
|
+
if (res.ok && !res.headers.get('content-type')?.includes('application/json')) {
|
|
74
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
75
|
+
return {
|
|
76
|
+
base64: buf.toString('base64'),
|
|
77
|
+
mimeType: res.headers.get('content-type') || 'application/octet-stream',
|
|
78
|
+
bytes: buf.length,
|
|
79
|
+
viaUser: true,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
const errJson = await res.json().catch(() => null);
|
|
83
|
+
console.error(`[feishu-user-plugin] downloadDocImage as user failed: ${errJson?.code}: ${errJson?.msg || res.statusText}, retrying as app`);
|
|
84
|
+
} catch (e) {
|
|
85
|
+
console.error(`[feishu-user-plugin] downloadDocImage as user threw (${e.message}), retrying as app`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Attempt 2 — app identity. Requires the app to have drive access to the doc.
|
|
90
|
+
const token = await this._getAppToken();
|
|
91
|
+
const res = await fetchWithTimeout(url, { headers: { 'Authorization': `Bearer ${token}` }, timeoutMs: 60000 });
|
|
92
|
+
if (!res.ok || res.headers.get('content-type')?.includes('application/json')) {
|
|
93
|
+
const errJson = await res.json().catch(() => null);
|
|
94
|
+
throw new Error(`downloadDocImage failed: ${errJson?.code}: ${errJson?.msg || res.statusText}. Note: app identity requires drive access to the document; configure UAT for user-owned docs.`);
|
|
95
|
+
}
|
|
96
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
97
|
+
return {
|
|
98
|
+
base64: buf.toString('base64'),
|
|
99
|
+
mimeType: res.headers.get('content-type') || 'application/octet-stream',
|
|
100
|
+
bytes: buf.length,
|
|
101
|
+
viaUser: false,
|
|
102
|
+
};
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
// --- Docx Image Write (v1.3.4) ---
|
|
106
|
+
|
|
107
|
+
// Upload binary media to Feishu's drive layer so it can be attached to a
|
|
108
|
+
// docx block, sheet cell, bitable attachment field, etc. Returns the
|
|
109
|
+
// media's file_token, which is what the host block's replace_*.token
|
|
110
|
+
// (or bitable attachment field value) expects.
|
|
111
|
+
//
|
|
112
|
+
// parentType ∈ {
|
|
113
|
+
// docx_image, docx_file,
|
|
114
|
+
// sheet_image, sheet_file,
|
|
115
|
+
// bitable_image, bitable_file,
|
|
116
|
+
// doc_image, doc_file, // legacy doc (pre-docx)
|
|
117
|
+
// ccm_import_open, // import-task host
|
|
118
|
+
// vc_virtual_background // VC bg, grayscale-only
|
|
119
|
+
// }
|
|
120
|
+
// parentNode = the block_id (docx) / spreadsheet_token (sheet) / app_token
|
|
121
|
+
// (bitable) / doc_token (legacy) — depends on parentType.
|
|
122
|
+
async uploadMedia(filePath, parentNode, parentType = 'docx_image') {
|
|
123
|
+
const fs = require('fs');
|
|
124
|
+
const path = require('path');
|
|
125
|
+
if (!filePath) throw new Error('uploadMedia: filePath is required');
|
|
126
|
+
if (!parentNode) throw new Error('uploadMedia: parentNode is required');
|
|
127
|
+
const ALLOWED = new Set([
|
|
128
|
+
'docx_image', 'docx_file',
|
|
129
|
+
'sheet_image', 'sheet_file',
|
|
130
|
+
'bitable_image', 'bitable_file',
|
|
131
|
+
'doc_image', 'doc_file',
|
|
132
|
+
'ccm_import_open', 'vc_virtual_background',
|
|
133
|
+
]);
|
|
134
|
+
if (!ALLOWED.has(parentType)) {
|
|
135
|
+
throw new Error(`uploadMedia: unsupported parent_type "${parentType}". Allowed: ${[...ALLOWED].join(', ')}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const stat = fs.statSync(filePath);
|
|
139
|
+
const fileName = path.basename(filePath);
|
|
140
|
+
const buf = fs.readFileSync(filePath);
|
|
141
|
+
|
|
142
|
+
// Best-effort content-type from extension. Feishu doesn't require it but
|
|
143
|
+
// some CDNs behind the API key off it; the Blob default is text/plain
|
|
144
|
+
// which would look wrong for binary attachments.
|
|
145
|
+
const ext = path.extname(fileName).toLowerCase();
|
|
146
|
+
const mimeMap = {
|
|
147
|
+
// image
|
|
148
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
149
|
+
'.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
|
|
150
|
+
'.bmp': 'image/bmp', '.tiff': 'image/tiff', '.ico': 'image/x-icon',
|
|
151
|
+
// doc / archive
|
|
152
|
+
'.pdf': 'application/pdf', '.zip': 'application/zip',
|
|
153
|
+
'.doc': 'application/msword',
|
|
154
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
155
|
+
'.xls': 'application/vnd.ms-excel',
|
|
156
|
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
157
|
+
'.ppt': 'application/vnd.ms-powerpoint',
|
|
158
|
+
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
159
|
+
'.txt': 'text/plain', '.md': 'text/markdown', '.csv': 'text/csv', '.json': 'application/json',
|
|
160
|
+
};
|
|
161
|
+
const contentType = mimeMap[ext] || 'application/octet-stream';
|
|
162
|
+
|
|
163
|
+
const doUpload = async (bearer) => {
|
|
164
|
+
const form = new FormData();
|
|
165
|
+
form.append('file_name', fileName);
|
|
166
|
+
form.append('parent_type', parentType);
|
|
167
|
+
form.append('parent_node', parentNode);
|
|
168
|
+
form.append('size', String(stat.size));
|
|
169
|
+
form.append('file', new Blob([buf], { type: contentType }), fileName);
|
|
170
|
+
const res = await fetchWithTimeout('https://open.feishu.cn/open-apis/drive/v1/medias/upload_all', {
|
|
171
|
+
method: 'POST',
|
|
172
|
+
headers: { 'Authorization': `Bearer ${bearer}` },
|
|
173
|
+
body: form,
|
|
174
|
+
timeoutMs: 120000,
|
|
175
|
+
});
|
|
176
|
+
return res.json();
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// User identity first — host resources are usually user-owned.
|
|
180
|
+
if (this.hasUAT) {
|
|
181
|
+
try {
|
|
182
|
+
const data = await this._withUAT(doUpload);
|
|
183
|
+
if (data.code === 0 && data.data?.file_token) {
|
|
184
|
+
return { fileToken: data.data.file_token, viaUser: true };
|
|
185
|
+
}
|
|
186
|
+
console.error(`[feishu-user-plugin] uploadMedia (${parentType}) as user failed (${data.code}: ${data.msg}), retrying as app`);
|
|
187
|
+
} catch (e) {
|
|
188
|
+
console.error(`[feishu-user-plugin] uploadMedia (${parentType}) as user threw (${e.message}), retrying as app`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const appToken = await this._getAppToken();
|
|
192
|
+
const data = await doUpload(appToken);
|
|
193
|
+
if (data.code !== 0 || !data.data?.file_token) {
|
|
194
|
+
throw new Error(`uploadMedia (${parentType}) failed: ${data.code}: ${data.msg || 'no file_token returned'}`);
|
|
195
|
+
}
|
|
196
|
+
return { fileToken: data.data.file_token, viaUser: false };
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
// Backwards-compat alias — old name from v1.3.4.
|
|
200
|
+
async uploadDocMedia(filePath, parentNode, parentType = 'docx_image') {
|
|
201
|
+
return this.uploadMedia(filePath, parentNode, parentType);
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
// Upload a file to a drive folder (NOT for embedding in a doc — that's
|
|
205
|
+
// uploadMedia). Uses drive/v1/files/upload_all with parent_type=explorer.
|
|
206
|
+
// Returns { fileToken, viaUser } where fileToken is the cloud-doc file id.
|
|
207
|
+
async uploadDriveFile(filePath, folderToken) {
|
|
208
|
+
const fs = require('fs');
|
|
209
|
+
const path = require('path');
|
|
210
|
+
if (!filePath) throw new Error('uploadDriveFile: filePath is required');
|
|
211
|
+
if (!folderToken) throw new Error('uploadDriveFile: folderToken is required (use the destination folder token; for "my space" root call list_files first to get it)');
|
|
212
|
+
|
|
213
|
+
const stat = fs.statSync(filePath);
|
|
214
|
+
const fileName = path.basename(filePath);
|
|
215
|
+
const buf = fs.readFileSync(filePath);
|
|
216
|
+
const ext = path.extname(fileName).toLowerCase();
|
|
217
|
+
const mimeMap = {
|
|
218
|
+
'.pdf': 'application/pdf', '.zip': 'application/zip',
|
|
219
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
220
|
+
'.txt': 'text/plain', '.md': 'text/markdown', '.csv': 'text/csv', '.json': 'application/json',
|
|
221
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
222
|
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
223
|
+
};
|
|
224
|
+
const contentType = mimeMap[ext] || 'application/octet-stream';
|
|
225
|
+
|
|
226
|
+
const doUpload = async (bearer) => {
|
|
227
|
+
const form = new FormData();
|
|
228
|
+
form.append('file_name', fileName);
|
|
229
|
+
form.append('parent_type', 'explorer');
|
|
230
|
+
form.append('parent_node', folderToken);
|
|
231
|
+
form.append('size', String(stat.size));
|
|
232
|
+
form.append('file', new Blob([buf], { type: contentType }), fileName);
|
|
233
|
+
const res = await fetchWithTimeout('https://open.feishu.cn/open-apis/drive/v1/files/upload_all', {
|
|
234
|
+
method: 'POST',
|
|
235
|
+
headers: { 'Authorization': `Bearer ${bearer}` },
|
|
236
|
+
body: form,
|
|
237
|
+
timeoutMs: 120000,
|
|
238
|
+
});
|
|
239
|
+
return res.json();
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
if (this.hasUAT) {
|
|
243
|
+
try {
|
|
244
|
+
const data = await this._withUAT(doUpload);
|
|
245
|
+
if (data.code === 0 && data.data?.file_token) {
|
|
246
|
+
return { fileToken: data.data.file_token, viaUser: true };
|
|
247
|
+
}
|
|
248
|
+
console.error(`[feishu-user-plugin] uploadDriveFile as user failed (${data.code}: ${data.msg}), retrying as app`);
|
|
249
|
+
} catch (e) {
|
|
250
|
+
console.error(`[feishu-user-plugin] uploadDriveFile as user threw (${e.message}), retrying as app`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const appToken = await this._getAppToken();
|
|
254
|
+
const data = await doUpload(appToken);
|
|
255
|
+
if (data.code !== 0 || !data.data?.file_token) {
|
|
256
|
+
throw new Error(`uploadDriveFile failed: ${data.code}: ${data.msg || 'no file_token returned'}`);
|
|
257
|
+
}
|
|
258
|
+
return { fileToken: data.data.file_token, viaUser: false };
|
|
259
|
+
},
|
|
260
|
+
};
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// src/clients/official/wiki.js
|
|
2
|
+
// Mixed into LarkOfficialClient.prototype by ./index.js (or temporarily by
|
|
3
|
+
// ./base.js during phase A.4–A.11). Methods receive `this` bound to the
|
|
4
|
+
// LarkOfficialClient instance, so they can use this.client, this._safeSDKCall,
|
|
5
|
+
// this._asUserOrApp, this._uatREST, etc. — all defined in base.js.
|
|
6
|
+
|
|
7
|
+
module.exports = {
|
|
8
|
+
// --- Wiki ---
|
|
9
|
+
|
|
10
|
+
async listWikiSpaces() {
|
|
11
|
+
// Try UAT first — most users access only their own / team Wiki spaces
|
|
12
|
+
// which the bot may not have been invited to. Falling back to app keeps
|
|
13
|
+
// the bot-shared-spaces case working too.
|
|
14
|
+
const res = await this._asUserOrApp({
|
|
15
|
+
uatPath: '/open-apis/wiki/v2/spaces?page_size=50',
|
|
16
|
+
method: 'GET',
|
|
17
|
+
sdkFn: () => this.client.wiki.space.list({ params: { page_size: 50 } }),
|
|
18
|
+
label: 'listSpaces',
|
|
19
|
+
});
|
|
20
|
+
const items = res.data.items || [];
|
|
21
|
+
const out = { items, viaUser: !!res._viaUser };
|
|
22
|
+
if (res._fallbackWarning) out.fallbackWarning = res._fallbackWarning;
|
|
23
|
+
// Empty + bot path means scope is missing; surface a clear hint instead
|
|
24
|
+
// of silently returning nothing.
|
|
25
|
+
if (items.length === 0 && !res._viaUser) {
|
|
26
|
+
out.scopeHint = 'No spaces returned via app — the bot likely lacks `wiki:wiki:readonly` scope, or has not been invited to any Wiki space. Run `npx feishu-user-plugin oauth` and ensure the wiki scope is granted; or invite the bot to the target Wiki space.';
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
async searchWiki(query) {
|
|
32
|
+
const res = await this._safeSDKCall(
|
|
33
|
+
() => this.client.request({ method: 'POST', url: '/open-apis/suite/docs-api/search/object', data: { search_key: query, count: 20, offset: 0, owner_ids: [], chat_ids: [], docs_types: ['wiki'] } }),
|
|
34
|
+
'searchWiki'
|
|
35
|
+
);
|
|
36
|
+
return { items: res.data.docs_entities || [] };
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
// Resolves a wiki node token to its underlying object (docx / sheet / bitable / ...).
|
|
40
|
+
// `spaceId` argument is kept for backward compatibility but isn't used — the Feishu
|
|
41
|
+
// endpoint `wiki.v2.getNode` takes only the token.
|
|
42
|
+
//
|
|
43
|
+
// Accepts both wiki node tokens (wikcnXXX from list_wiki_nodes) and underlying
|
|
44
|
+
// obj_tokens (docxXXX / bascnXXX from search_wiki). For obj_tokens the wiki
|
|
45
|
+
// endpoint returns 95300x errors; the handler in tools/wiki.js detects this
|
|
46
|
+
// and returns a synthesized node-shaped result so callers don't have to know
|
|
47
|
+
// which ID space they're holding.
|
|
48
|
+
async getWikiNode(nodeToken, _spaceId) {
|
|
49
|
+
const res = await this._safeSDKCall(() => this.client.wiki.space.getNode({ params: { token: nodeToken } }), 'getNode');
|
|
50
|
+
return res.data.node;
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
async listWikiNodes(spaceId, { parentNodeToken, pageToken } = {}) {
|
|
54
|
+
// UAT-first (v1.3.7): bot identity hits 131006 "wiki space permission
|
|
55
|
+
// denied" for spaces it wasn't explicitly invited to, even when the user
|
|
56
|
+
// has access. listWikiSpaces is already UAT-first; this matches.
|
|
57
|
+
const queryParams = { page_size: '50' };
|
|
58
|
+
if (parentNodeToken) queryParams.parent_node_token = parentNodeToken;
|
|
59
|
+
if (pageToken) queryParams.page_token = pageToken;
|
|
60
|
+
const sdkParams = { page_size: 50 };
|
|
61
|
+
if (parentNodeToken) sdkParams.parent_node_token = parentNodeToken;
|
|
62
|
+
if (pageToken) sdkParams.page_token = pageToken;
|
|
63
|
+
const res = await this._asUserOrApp({
|
|
64
|
+
uatPath: `/open-apis/wiki/v2/spaces/${encodeURIComponent(spaceId)}/nodes`,
|
|
65
|
+
query: queryParams,
|
|
66
|
+
sdkFn: () => this.client.wiki.spaceNode.list({ path: { space_id: spaceId }, params: sdkParams }),
|
|
67
|
+
label: 'listWikiNodes',
|
|
68
|
+
});
|
|
69
|
+
return { items: res.data.items || [], hasMore: res.data.has_more, viaUser: !!res._viaUser };
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
// --- Wiki write (v1.3.7) ---
|
|
73
|
+
|
|
74
|
+
// Create a new node inside a Wiki space. obj_type picks the underlying
|
|
75
|
+
// resource (docx / sheet / bitable / mindnote / file / slides). For
|
|
76
|
+
// node_type='shortcut' the caller must also pass origin_node_token to
|
|
77
|
+
// point at an existing node.
|
|
78
|
+
async createWikiNode(spaceId, { obj_type, node_type = 'origin', parent_node_token, origin_node_token, title } = {}) {
|
|
79
|
+
if (!spaceId) throw new Error('createWikiNode: spaceId is required');
|
|
80
|
+
if (!obj_type) throw new Error('createWikiNode: obj_type is required (doc/sheet/bitable/mindnote/file/docx/slides)');
|
|
81
|
+
if (node_type === 'shortcut' && !origin_node_token) {
|
|
82
|
+
throw new Error('createWikiNode: origin_node_token is required when node_type=shortcut');
|
|
83
|
+
}
|
|
84
|
+
const data = { obj_type, node_type };
|
|
85
|
+
if (parent_node_token) data.parent_node_token = parent_node_token;
|
|
86
|
+
if (origin_node_token) data.origin_node_token = origin_node_token;
|
|
87
|
+
if (title) data.title = title;
|
|
88
|
+
const res = await this._asUserOrApp({
|
|
89
|
+
uatPath: `/open-apis/wiki/v2/spaces/${encodeURIComponent(spaceId)}/nodes`,
|
|
90
|
+
method: 'POST',
|
|
91
|
+
body: data,
|
|
92
|
+
sdkFn: () => this.client.wiki.spaceNode.create({ path: { space_id: spaceId }, data }),
|
|
93
|
+
label: 'createWikiNode',
|
|
94
|
+
});
|
|
95
|
+
return { node: res.data.node, viaUser: !!res._viaUser, fallbackWarning: res._fallbackWarning || null };
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
// Rename a wiki node. Feishu's SDK exposes this as updateTitle (the
|
|
99
|
+
// underlying API is /open-apis/wiki/v2/spaces/{space_id}/nodes/{token}/update_title).
|
|
100
|
+
async updateWikiNodeTitle(spaceId, nodeToken, title) {
|
|
101
|
+
if (!spaceId) throw new Error('updateWikiNodeTitle: spaceId is required');
|
|
102
|
+
if (!nodeToken) throw new Error('updateWikiNodeTitle: nodeToken is required');
|
|
103
|
+
if (!title) throw new Error('updateWikiNodeTitle: title is required');
|
|
104
|
+
const data = { title };
|
|
105
|
+
const res = await this._asUserOrApp({
|
|
106
|
+
uatPath: `/open-apis/wiki/v2/spaces/${encodeURIComponent(spaceId)}/nodes/${encodeURIComponent(nodeToken)}/update_title`,
|
|
107
|
+
method: 'POST',
|
|
108
|
+
body: data,
|
|
109
|
+
sdkFn: () => this.client.wiki.spaceNode.updateTitle({ path: { space_id: spaceId, node_token: nodeToken }, data }),
|
|
110
|
+
label: 'updateWikiNodeTitle',
|
|
111
|
+
});
|
|
112
|
+
return { ok: res.code === 0, viaUser: !!res._viaUser, fallbackWarning: res._fallbackWarning || null };
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
// Move a wiki node to a different parent or different space.
|
|
116
|
+
async moveWikiNode(spaceId, nodeToken, { target_parent_token, target_space_id } = {}) {
|
|
117
|
+
if (!spaceId) throw new Error('moveWikiNode: spaceId is required');
|
|
118
|
+
if (!nodeToken) throw new Error('moveWikiNode: nodeToken is required');
|
|
119
|
+
if (!target_parent_token && !target_space_id) {
|
|
120
|
+
throw new Error('moveWikiNode: at least one of target_parent_token or target_space_id is required');
|
|
121
|
+
}
|
|
122
|
+
const data = {};
|
|
123
|
+
if (target_parent_token) data.target_parent_token = target_parent_token;
|
|
124
|
+
if (target_space_id) data.target_space_id = target_space_id;
|
|
125
|
+
const res = await this._asUserOrApp({
|
|
126
|
+
uatPath: `/open-apis/wiki/v2/spaces/${encodeURIComponent(spaceId)}/nodes/${encodeURIComponent(nodeToken)}/move`,
|
|
127
|
+
method: 'POST',
|
|
128
|
+
body: data,
|
|
129
|
+
sdkFn: () => this.client.wiki.spaceNode.move({ path: { space_id: spaceId, node_token: nodeToken }, data }),
|
|
130
|
+
label: 'moveWikiNode',
|
|
131
|
+
});
|
|
132
|
+
return { node: res.data.node, viaUser: !!res._viaUser, fallbackWarning: res._fallbackWarning || null };
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
// Delete a wiki node. The Feishu SDK does not expose this endpoint, but the
|
|
136
|
+
// open API documents `DELETE /open-apis/wiki/v2/spaces/{space_id}/nodes/{token}`
|
|
137
|
+
// (added 2025-Q1 per the API console; not yet typed in @larksuiteoapi/node-sdk).
|
|
138
|
+
// We fall back to UAT REST and the bot's `client.request` raw helper, since
|
|
139
|
+
// there's no SDK method to call.
|
|
140
|
+
//
|
|
141
|
+
// CAVEAT: this only removes the wiki node. The underlying drive resource
|
|
142
|
+
// (docx / sheet / bitable / file) is NOT deleted — Feishu's design treats
|
|
143
|
+
// wiki nodes as pointers. To delete the actual resource, follow up with
|
|
144
|
+
// manage_drive_file(action=delete, type=<obj_type>, file_token=<obj_token>).
|
|
145
|
+
async deleteWikiNode(spaceId, nodeToken) {
|
|
146
|
+
if (!spaceId) throw new Error('deleteWikiNode: spaceId is required');
|
|
147
|
+
if (!nodeToken) throw new Error('deleteWikiNode: nodeToken is required');
|
|
148
|
+
const path = `/open-apis/wiki/v2/spaces/${encodeURIComponent(spaceId)}/nodes/${encodeURIComponent(nodeToken)}`;
|
|
149
|
+
const res = await this._asUserOrApp({
|
|
150
|
+
uatPath: path,
|
|
151
|
+
method: 'DELETE',
|
|
152
|
+
sdkFn: () => this.client.request({ method: 'DELETE', url: path }),
|
|
153
|
+
label: 'deleteWikiNode',
|
|
154
|
+
});
|
|
155
|
+
return { deleted: res.code === 0, viaUser: !!res._viaUser, fallbackWarning: res._fallbackWarning || null };
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
// Copy a wiki node — deep copies the underlying resource into the target
|
|
159
|
+
// location. target_parent_token / target_space_id select the destination;
|
|
160
|
+
// omitting target_space_id keeps it within the source space.
|
|
161
|
+
async copyWikiNode(spaceId, nodeToken, { target_parent_token, target_space_id, title } = {}) {
|
|
162
|
+
if (!spaceId) throw new Error('copyWikiNode: spaceId is required');
|
|
163
|
+
if (!nodeToken) throw new Error('copyWikiNode: nodeToken is required');
|
|
164
|
+
const data = {};
|
|
165
|
+
if (target_parent_token) data.target_parent_token = target_parent_token;
|
|
166
|
+
if (target_space_id) data.target_space_id = target_space_id;
|
|
167
|
+
if (title) data.title = title;
|
|
168
|
+
const res = await this._asUserOrApp({
|
|
169
|
+
uatPath: `/open-apis/wiki/v2/spaces/${encodeURIComponent(spaceId)}/nodes/${encodeURIComponent(nodeToken)}/copy`,
|
|
170
|
+
method: 'POST',
|
|
171
|
+
body: data,
|
|
172
|
+
sdkFn: () => this.client.wiki.spaceNode.copy({ path: { space_id: spaceId, node_token: nodeToken }, data }),
|
|
173
|
+
label: 'copyWikiNode',
|
|
174
|
+
});
|
|
175
|
+
return { node: res.data.node, viaUser: !!res._viaUser, fallbackWarning: res._fallbackWarning || null };
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
// --- Wiki attach (v1.3.4) ---
|
|
179
|
+
|
|
180
|
+
// Move an existing drive resource (docx / bitable / sheet / ...) into a Wiki
|
|
181
|
+
// space as an 'origin' node. Used by createDoc / createBitable when their
|
|
182
|
+
// wikiSpaceId option is set.
|
|
183
|
+
//
|
|
184
|
+
// Uses wiki/v2/spaces/{space_id}/nodes/move_docs_to_wiki — the documented path
|
|
185
|
+
// for migrating an existing drive doc into wiki. Note: this endpoint is async;
|
|
186
|
+
// if the move completes immediately (typical for newly-created docs) we get
|
|
187
|
+
// back a wiki_token and surface it as node_token. If it's queued we return
|
|
188
|
+
// { task_id } so the caller can see the async state — we don't currently poll.
|
|
189
|
+
async attachToWiki(spaceId, objType, objToken, parentNodeToken) {
|
|
190
|
+
if (!spaceId) throw new Error('attachToWiki: spaceId is required');
|
|
191
|
+
if (!objType) throw new Error('attachToWiki: objType is required');
|
|
192
|
+
if (!objToken) throw new Error('attachToWiki: objToken is required');
|
|
193
|
+
const body = { obj_type: objType, obj_token: objToken, apply: true };
|
|
194
|
+
if (parentNodeToken) body.parent_wiki_token = parentNodeToken;
|
|
195
|
+
const res = await this._asUserOrApp({
|
|
196
|
+
uatPath: `/open-apis/wiki/v2/spaces/${encodeURIComponent(spaceId)}/nodes/move_docs_to_wiki`,
|
|
197
|
+
method: 'POST',
|
|
198
|
+
body,
|
|
199
|
+
sdkFn: () => this.client.wiki.spaceNode.moveDocsToWiki({ path: { space_id: spaceId }, data: body }),
|
|
200
|
+
label: 'attachToWiki',
|
|
201
|
+
});
|
|
202
|
+
const data = res.data || {};
|
|
203
|
+
if (data.wiki_token) return { node_token: data.wiki_token, applied: !!data.applied };
|
|
204
|
+
if (data.task_id) return { task_id: data.task_id, applied: false };
|
|
205
|
+
return data;
|
|
206
|
+
},
|
|
207
|
+
};
|