feishu-user-plugin 1.3.6 → 1.3.8

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 (71) hide show
  1. package/.claude-plugin/plugin.json +2 -2
  2. package/CHANGELOG.md +71 -0
  3. package/README.md +72 -41
  4. package/package.json +10 -3
  5. package/scripts/capture-feishu-protobuf.js +86 -0
  6. package/scripts/check-changelog.js +31 -0
  7. package/scripts/check-docs-sync.js +41 -0
  8. package/scripts/check-tool-count.js +40 -0
  9. package/scripts/check-version.js +40 -0
  10. package/scripts/decode-feishu-protobuf.js +115 -0
  11. package/scripts/smoke.js +224 -0
  12. package/scripts/sync-claude-md.sh +12 -0
  13. package/scripts/sync-server-json.js +71 -0
  14. package/scripts/sync-team-skills.sh +22 -0
  15. package/scripts/test-all-tools.js +158 -0
  16. package/scripts/test-wiki-attach-fallback.js +71 -0
  17. package/scripts/test-ws-events.js +84 -0
  18. package/skills/feishu-user-plugin/SKILL.md +5 -5
  19. package/skills/feishu-user-plugin/references/CLAUDE.md +248 -318
  20. package/skills/feishu-user-plugin/references/table.md +18 -9
  21. package/src/auth/cookie.js +30 -0
  22. package/src/auth/credentials.js +399 -0
  23. package/src/auth/profile-router.js +248 -0
  24. package/src/auth/uat.js +231 -0
  25. package/src/cli.js +45 -13
  26. package/src/clients/official/base.js +188 -0
  27. package/src/clients/official/bitable.js +269 -0
  28. package/src/clients/official/calendar.js +176 -0
  29. package/src/clients/official/contacts.js +54 -0
  30. package/src/clients/official/docs.js +301 -0
  31. package/src/clients/official/drive.js +77 -0
  32. package/src/clients/official/groups.js +68 -0
  33. package/src/clients/official/im.js +414 -0
  34. package/src/clients/official/index.js +30 -0
  35. package/src/clients/official/okr.js +127 -0
  36. package/src/clients/official/tasks.js +142 -0
  37. package/src/clients/official/uploads.js +260 -0
  38. package/src/clients/official/wiki.js +207 -0
  39. package/src/{client.js → clients/user.js} +25 -33
  40. package/src/config.js +13 -8
  41. package/src/events/event-buffer.js +100 -0
  42. package/src/events/index.js +5 -0
  43. package/src/events/ws-server.js +86 -0
  44. package/src/index.js +4 -1977
  45. package/src/logger.js +20 -0
  46. package/src/oauth.js +5 -1
  47. package/src/official.js +5 -1944
  48. package/src/prompts/_registry.js +69 -0
  49. package/src/prompts/index.js +54 -0
  50. package/src/server.js +305 -0
  51. package/src/setup.js +16 -1
  52. package/src/test-all.js +2 -2
  53. package/src/test-comprehensive.js +3 -3
  54. package/src/test-send.js +1 -1
  55. package/src/tools/_registry.js +31 -0
  56. package/src/tools/bitable.js +246 -0
  57. package/src/tools/calendar.js +207 -0
  58. package/src/tools/contacts.js +66 -0
  59. package/src/tools/diagnostics.js +172 -0
  60. package/src/tools/docs.js +158 -0
  61. package/src/tools/drive.js +111 -0
  62. package/src/tools/events.js +64 -0
  63. package/src/tools/groups.js +81 -0
  64. package/src/tools/im-read.js +259 -0
  65. package/src/tools/messaging-bot.js +151 -0
  66. package/src/tools/messaging-user.js +292 -0
  67. package/src/tools/okr.js +159 -0
  68. package/src/tools/profile.js +74 -0
  69. package/src/tools/tasks.js +168 -0
  70. package/src/tools/uploads.js +63 -0
  71. package/src/tools/wiki.js +191 -0
@@ -0,0 +1,188 @@
1
+ const lark = require('@larksuiteoapi/node-sdk');
2
+ const { fetchWithTimeout } = require('../../utils');
3
+ const { stderrLogger } = require('../../logger');
4
+ const uatLifecycle = require('../../auth/uat');
5
+
6
+ class LarkOfficialClient {
7
+ constructor(appId, appSecret) {
8
+ this.appId = appId;
9
+ this.appSecret = appSecret;
10
+ this.client = new lark.Client({ appId, appSecret, disableTokenCache: false, logger: stderrLogger, loggerLevel: lark.LoggerLevel.warn });
11
+ this._uat = null;
12
+ this._uatRefresh = null;
13
+ this._uatExpires = 0;
14
+ this._userNameCache = new Map(); // open_id → display name
15
+ }
16
+
17
+ // --- UAT (User Access Token) Management ---
18
+
19
+ loadUAT() {
20
+ const token = process.env.LARK_USER_ACCESS_TOKEN;
21
+ const refresh = process.env.LARK_USER_REFRESH_TOKEN;
22
+ const expires = parseInt(process.env.LARK_UAT_EXPIRES || '0');
23
+ if (token) {
24
+ this._uat = token;
25
+ this._uatRefresh = refresh || null;
26
+ this._uatExpires = expires || this._decodeTokenExpiry(token);
27
+ }
28
+ }
29
+
30
+ get hasUAT() {
31
+ return !!this._uat;
32
+ }
33
+
34
+ // Fetches (and caches) an app_access_token directly via the internal endpoint.
35
+ // Avoids relying on SDK-internal token-manager APIs that may change across versions.
36
+ async _getAppToken() {
37
+ const now = Math.floor(Date.now() / 1000);
38
+ if (this._appToken && this._appTokenExpires > now + 60) return this._appToken;
39
+ const res = await fetchWithTimeout('https://open.feishu.cn/open-apis/auth/v3/app_access_token/internal', {
40
+ method: 'POST',
41
+ headers: { 'content-type': 'application/json' },
42
+ body: JSON.stringify({ app_id: this.appId, app_secret: this.appSecret }),
43
+ timeoutMs: 10000,
44
+ });
45
+ const data = await res.json();
46
+ if (data.code !== 0 || !data.app_access_token) {
47
+ throw new Error(`app_access_token failed: ${data.code}: ${data.msg || 'unknown'}`);
48
+ }
49
+ this._appToken = data.app_access_token;
50
+ this._appTokenExpires = now + (typeof data.expire === 'number' ? data.expire : 7200);
51
+ return this._appToken;
52
+ }
53
+
54
+ // Probe APP_ID/SECRET validity by requesting a tenant access token.
55
+ // Catches the common "user's Claude filled in a wrong/stale APP_ID" failure mode
56
+ // (observed in production: 周宇's machine ran with an APP_ID nobody recognized,
57
+ // causing all Official API calls to 401 with cryptic messages that looked like
58
+ // MCP "掉线" to the user). Returns { valid, appId, appName?, error? }.
59
+ async verifyApp() {
60
+ try {
61
+ const token = await this._getAppToken();
62
+ // Try to fetch app display name (best-effort; requires application scope)
63
+ let appName = null;
64
+ try {
65
+ const infoRes = await fetchWithTimeout(`https://open.feishu.cn/open-apis/application/v6/applications/${this.appId}?lang=zh_cn`, {
66
+ headers: { 'Authorization': `Bearer ${token}` },
67
+ timeoutMs: 10000,
68
+ });
69
+ const info = await infoRes.json();
70
+ if (info.code === 0) appName = info.data?.app?.app_name || null;
71
+ } catch (_) { /* name is best-effort; valid creds still matter most */ }
72
+ return { valid: true, appId: this.appId, appName };
73
+ } catch (e) {
74
+ return { valid: false, appId: this.appId, error: e.message };
75
+ }
76
+ }
77
+
78
+ // UAT lifecycle methods are extracted to src/auth/uat.js (v1.3.8 D.1).
79
+ // State (this._uat / this._uatRefresh / this._uatExpires) still lives here;
80
+ // function bodies live in auth/uat.js. These methods are 1-line delegates.
81
+ _decodeTokenExpiry(token) { return uatLifecycle.decodeTokenExpiry(token); }
82
+ async _getValidUAT() { return uatLifecycle.getValidUAT(this); }
83
+ _adoptPersistedUATIfNewer() { return uatLifecycle.adoptPersistedUATIfNewer(this); }
84
+ async _refreshUAT() { return uatLifecycle.refreshUAT(this); }
85
+ _persistUAT() { return uatLifecycle.persistUAT(this); }
86
+ async _withUAT(fn) { return uatLifecycle.withUAT(this, fn); }
87
+ async _uatREST(method, path, opts) { return uatLifecycle.uatREST(this, method, path, opts); }
88
+ async _asUserOrApp(opts) { return uatLifecycle.asUserOrApp(this, opts); }
89
+
90
+ // --- Safe SDK Call (extracts real Feishu error from AxiosError) ---
91
+
92
+ async _safeSDKCall(fn, label = 'API') {
93
+ try {
94
+ const res = await fn();
95
+ // SDK returns abbreviated responses for multipart uploads (code/msg undefined)
96
+ // Only treat as error if code is explicitly non-zero
97
+ if (res.code !== undefined && res.code !== 0) throw new Error(`${label} failed (${res.code}): ${res.msg}`);
98
+ return res;
99
+ } catch (err) {
100
+ // Lark SDK uses axios; extract actual Feishu error from response body
101
+ if (err.response?.data) {
102
+ const d = err.response.data;
103
+ const code = d.code ?? d.error ?? 'unknown';
104
+ const msg = d.msg ?? d.error_description ?? d.message ?? JSON.stringify(d);
105
+ throw new Error(`${label} failed (HTTP ${err.response.status}, code=${code}): ${msg}`);
106
+ }
107
+ throw err;
108
+ }
109
+ }
110
+
111
+ async _populateSenderNames(items, userClient) {
112
+ // Collect unique sender IDs that aren't cached
113
+ const unknownIds = new Set();
114
+ for (const item of items) {
115
+ if (item.senderId && !this._userNameCache.has(item.senderId)) {
116
+ unknownIds.add(item.senderId);
117
+ }
118
+ }
119
+ // Parallel resolve via official contact API (instead of sequential N calls)
120
+ if (unknownIds.size > 0) {
121
+ await Promise.allSettled([...unknownIds].map(id => this.getUserById(id)));
122
+ }
123
+ // Fallback: resolve remaining unknowns via cookie-based user identity client
124
+ if (userClient) {
125
+ for (const id of unknownIds) {
126
+ if (!this._userNameCache.has(id)) {
127
+ try {
128
+ const name = await userClient.getUserName(id);
129
+ if (name) this._userNameCache.set(id, name);
130
+ } catch {}
131
+ }
132
+ }
133
+ }
134
+ // Populate senderName field
135
+ for (const item of items) {
136
+ if (item.senderId) {
137
+ item.senderName = this._userNameCache.get(item.senderId) || null;
138
+ }
139
+ }
140
+ }
141
+
142
+ // --- Helpers ---
143
+
144
+ _formatMessage(m) {
145
+ if (!m) return null;
146
+ let body = m.body?.content || '';
147
+ try { body = JSON.parse(body); } catch {}
148
+ const out = {
149
+ messageId: m.message_id,
150
+ chatId: m.chat_id,
151
+ senderId: m.sender?.id,
152
+ senderType: m.sender?.sender_type,
153
+ msgType: m.msg_type,
154
+ content: body,
155
+ createTime: this._normalizeTimestamp(m.create_time),
156
+ updateTime: this._normalizeTimestamp(m.update_time),
157
+ };
158
+ if (Array.isArray(m.mentions) && m.mentions.length > 0) out.mentions = m.mentions;
159
+ if (m.upper_message_id) out.upperMessageId = m.upper_message_id;
160
+ if (m.root_id) out.rootId = m.root_id;
161
+ if (m.parent_id) out.parentId = m.parent_id;
162
+ // Extract URL-like strings from text bodies so agents can call WebFetch /
163
+ // read_doc / get_doc_blocks without having to regex the body themselves.
164
+ if (out.msgType === 'text' && typeof body?.text === 'string') {
165
+ const urls = body.text.match(/https?:\/\/[^\s一-鿿]+/g);
166
+ if (urls && urls.length > 0) {
167
+ out.urls = Array.from(new Set(urls));
168
+ const feishuDocs = out.urls.filter(u =>
169
+ /feishu\.cn\/(?:docx|wiki|base|sheets|docs|mindnotes)\//i.test(u));
170
+ if (feishuDocs.length > 0) out.feishuDocs = feishuDocs;
171
+ }
172
+ }
173
+ return out;
174
+ }
175
+
176
+ _normalizeTimestamp(ts) {
177
+ if (!ts) return null;
178
+ const n = parseInt(ts);
179
+ // Feishu returns millisecond strings; normalize to seconds
180
+ return String(n > 1e12 ? Math.floor(n / 1000) : n);
181
+ }
182
+
183
+ }
184
+
185
+ // base.js exports only the bare class. clients/official/index.js composes the
186
+ // domain mixins onto its prototype — callers should always import from there,
187
+ // never directly from base.js.
188
+ module.exports = { LarkOfficialClient };
@@ -0,0 +1,269 @@
1
+ // src/clients/official/bitable.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.attachToWiki (mixed in via wiki.js), etc. — all
6
+ // defined in base.js or mixed in via other domain modules.
7
+
8
+ module.exports = {
9
+ // --- Bitable ---
10
+
11
+ async createBitable(name, folderId, { wikiSpaceId, wikiParentNodeToken } = {}) {
12
+ const data = {};
13
+ if (name) data.name = name;
14
+ if (folderId) data.folder_token = folderId;
15
+ const res = await this._asUserOrApp({
16
+ uatPath: `/open-apis/bitable/v1/apps`,
17
+ method: 'POST',
18
+ body: data,
19
+ sdkFn: () => this.client.bitable.app.create({ data }),
20
+ label: 'createBitable',
21
+ });
22
+ const appToken = res.data.app?.app_token;
23
+ const out = { appToken, name: res.data.app?.name, url: res.data.app?.url, viaUser: !!res._viaUser, fallbackWarning: res._fallbackWarning || null };
24
+ if (appToken && wikiSpaceId) {
25
+ try {
26
+ const node = await this.attachToWiki(wikiSpaceId, 'bitable', appToken, wikiParentNodeToken);
27
+ if (node?.node_token) out.wikiNodeToken = node.node_token;
28
+ else if (node?.task_id) out.wikiAttachTaskId = node.task_id;
29
+ } catch (e) {
30
+ out.wikiAttachError = e.message;
31
+ }
32
+ }
33
+ return out;
34
+ },
35
+
36
+ async listBitableTables(appToken) {
37
+ const res = await this._asUserOrApp({
38
+ uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables`,
39
+ sdkFn: () => this.client.bitable.appTable.list({ path: { app_token: appToken } }),
40
+ label: 'listTables',
41
+ });
42
+ return { items: res.data.items || [] };
43
+ },
44
+
45
+ async createBitableTable(appToken, name, fields) {
46
+ const data = { table: { name } };
47
+ if (fields && fields.length > 0) data.table.default_view_name = name;
48
+ if (fields && fields.length > 0) data.table.fields = fields;
49
+ const res = await this._asUserOrApp({
50
+ uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables`,
51
+ method: 'POST',
52
+ body: data,
53
+ sdkFn: () => this.client.bitable.appTable.create({ path: { app_token: appToken }, data }),
54
+ label: 'createTable',
55
+ });
56
+ return { tableId: res.data.table_id, fallbackWarning: res._fallbackWarning || null };
57
+ },
58
+
59
+ async listBitableFields(appToken, tableId) {
60
+ const res = await this._asUserOrApp({
61
+ uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/fields`,
62
+ sdkFn: () => this.client.bitable.appTableField.list({ path: { app_token: appToken, table_id: tableId } }),
63
+ label: 'listFields',
64
+ });
65
+ return { items: res.data.items || [] };
66
+ },
67
+
68
+ async createBitableField(appToken, tableId, fieldConfig) {
69
+ const res = await this._asUserOrApp({
70
+ uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/fields`,
71
+ method: 'POST',
72
+ body: fieldConfig,
73
+ sdkFn: () => this.client.bitable.appTableField.create({ path: { app_token: appToken, table_id: tableId }, data: fieldConfig }),
74
+ label: 'createField',
75
+ });
76
+ return { field: res.data.field, fallbackWarning: res._fallbackWarning || null };
77
+ },
78
+
79
+ async updateBitableField(appToken, tableId, fieldId, fieldConfig) {
80
+ const res = await this._asUserOrApp({
81
+ uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/fields/${fieldId}`,
82
+ method: 'PUT',
83
+ body: fieldConfig,
84
+ sdkFn: () => this.client.bitable.appTableField.update({ path: { app_token: appToken, table_id: tableId, field_id: fieldId }, data: fieldConfig }),
85
+ label: 'updateField',
86
+ });
87
+ return { field: res.data.field };
88
+ },
89
+
90
+ async deleteBitableField(appToken, tableId, fieldId) {
91
+ const res = await this._asUserOrApp({
92
+ uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/fields/${fieldId}`,
93
+ method: 'DELETE',
94
+ sdkFn: () => this.client.bitable.appTableField.delete({ path: { app_token: appToken, table_id: tableId, field_id: fieldId } }),
95
+ label: 'deleteField',
96
+ });
97
+ return { fieldId: res.data.field_id, deleted: res.data.deleted };
98
+ },
99
+
100
+ async searchBitableRecords(appToken, tableId, { filter, sort, pageSize = 20, pageToken } = {}) {
101
+ const data = {};
102
+ if (filter) data.filter = filter;
103
+ if (sort) data.sort = sort;
104
+ const query = {};
105
+ if (pageSize) query.page_size = String(pageSize);
106
+ if (pageToken) query.page_token = pageToken;
107
+ const res = await this._asUserOrApp({
108
+ uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/records/search`,
109
+ method: 'POST',
110
+ body: data,
111
+ query,
112
+ sdkFn: () => this.client.bitable.appTableRecord.search({
113
+ path: { app_token: appToken, table_id: tableId },
114
+ params: { page_size: pageSize, ...(pageToken ? { page_token: pageToken } : {}) },
115
+ data,
116
+ }),
117
+ label: 'searchRecords',
118
+ });
119
+ return { items: res.data.items || [], total: res.data.total, hasMore: res.data.has_more };
120
+ },
121
+
122
+ async createBitableRecord(appToken, tableId, fields) {
123
+ const res = await this._asUserOrApp({
124
+ uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/records`,
125
+ method: 'POST',
126
+ body: { fields },
127
+ sdkFn: () => this.client.bitable.appTableRecord.create({ path: { app_token: appToken, table_id: tableId }, data: { fields } }),
128
+ label: 'createRecord',
129
+ });
130
+ return { recordId: res.data.record?.record_id, fallbackWarning: res._fallbackWarning || null };
131
+ },
132
+
133
+ async updateBitableRecord(appToken, tableId, recordId, fields) {
134
+ const res = await this._asUserOrApp({
135
+ uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/records/${recordId}`,
136
+ method: 'PUT',
137
+ body: { fields },
138
+ sdkFn: () => this.client.bitable.appTableRecord.update({ path: { app_token: appToken, table_id: tableId, record_id: recordId }, data: { fields } }),
139
+ label: 'updateRecord',
140
+ });
141
+ return { recordId: res.data.record?.record_id };
142
+ },
143
+
144
+ async deleteBitableRecord(appToken, tableId, recordId) {
145
+ const res = await this._asUserOrApp({
146
+ uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/records/${recordId}`,
147
+ method: 'DELETE',
148
+ sdkFn: () => this.client.bitable.appTableRecord.delete({ path: { app_token: appToken, table_id: tableId, record_id: recordId } }),
149
+ label: 'deleteRecord',
150
+ });
151
+ return { deleted: res.data.deleted };
152
+ },
153
+
154
+ async batchCreateBitableRecords(appToken, tableId, records) {
155
+ const res = await this._asUserOrApp({
156
+ uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/records/batch_create`,
157
+ method: 'POST',
158
+ body: { records },
159
+ sdkFn: () => this.client.bitable.appTableRecord.batchCreate({ path: { app_token: appToken, table_id: tableId }, data: { records } }),
160
+ label: 'batchCreateRecords',
161
+ });
162
+ return { records: res.data.records || [], fallbackWarning: res._fallbackWarning || null };
163
+ },
164
+
165
+ async batchUpdateBitableRecords(appToken, tableId, records) {
166
+ const res = await this._asUserOrApp({
167
+ uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/records/batch_update`,
168
+ method: 'POST',
169
+ body: { records },
170
+ sdkFn: () => this.client.bitable.appTableRecord.batchUpdate({ path: { app_token: appToken, table_id: tableId }, data: { records } }),
171
+ label: 'batchUpdateRecords',
172
+ });
173
+ return { records: res.data.records || [] };
174
+ },
175
+
176
+ async batchDeleteBitableRecords(appToken, tableId, recordIds) {
177
+ const res = await this._asUserOrApp({
178
+ uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/records/batch_delete`,
179
+ method: 'POST',
180
+ body: { records: recordIds },
181
+ sdkFn: () => this.client.bitable.appTableRecord.batchDelete({ path: { app_token: appToken, table_id: tableId }, data: { records: recordIds } }),
182
+ label: 'batchDeleteRecords',
183
+ });
184
+ return { records: res.data.records || [] };
185
+ },
186
+
187
+ async listBitableViews(appToken, tableId) {
188
+ const res = await this._asUserOrApp({
189
+ uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/views`,
190
+ query: { page_size: '50' },
191
+ sdkFn: () => this.client.bitable.appTableView.list({ path: { app_token: appToken, table_id: tableId }, params: { page_size: 50 } }),
192
+ label: 'listViews',
193
+ });
194
+ return { items: res.data.items || [] };
195
+ },
196
+
197
+ async getBitableRecord(appToken, tableId, recordId) {
198
+ const res = await this._asUserOrApp({
199
+ uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/records/${recordId}`,
200
+ sdkFn: () => this.client.bitable.appTableRecord.get({ path: { app_token: appToken, table_id: tableId, record_id: recordId } }),
201
+ label: 'getRecord',
202
+ });
203
+ return { record: res.data.record };
204
+ },
205
+
206
+ async deleteBitableTable(appToken, tableId) {
207
+ await this._asUserOrApp({
208
+ uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}`,
209
+ method: 'DELETE',
210
+ sdkFn: () => this.client.bitable.appTable.delete({ path: { app_token: appToken, table_id: tableId } }),
211
+ label: 'deleteTable',
212
+ });
213
+ return { deleted: true };
214
+ },
215
+
216
+ async getBitableMeta(appToken) {
217
+ const res = await this._asUserOrApp({
218
+ uatPath: `/open-apis/bitable/v1/apps/${appToken}`,
219
+ sdkFn: () => this.client.bitable.app.get({ path: { app_token: appToken } }),
220
+ label: 'getBitableMeta',
221
+ });
222
+ return { app: res.data.app };
223
+ },
224
+
225
+ async updateBitableTable(appToken, tableId, name) {
226
+ const res = await this._asUserOrApp({
227
+ uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}`,
228
+ method: 'PATCH',
229
+ body: { name },
230
+ sdkFn: () => this.client.bitable.appTable.patch({ path: { app_token: appToken, table_id: tableId }, data: { name } }),
231
+ label: 'updateTable',
232
+ });
233
+ return { name: res.data.name };
234
+ },
235
+
236
+ async createBitableView(appToken, tableId, viewName, viewType = 'grid') {
237
+ const res = await this._asUserOrApp({
238
+ uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/views`,
239
+ method: 'POST',
240
+ body: { view_name: viewName, view_type: viewType },
241
+ sdkFn: () => this.client.bitable.appTableView.create({ path: { app_token: appToken, table_id: tableId }, data: { view_name: viewName, view_type: viewType } }),
242
+ label: 'createView',
243
+ });
244
+ return { view: res.data.view, fallbackWarning: res._fallbackWarning || null };
245
+ },
246
+
247
+ async deleteBitableView(appToken, tableId, viewId) {
248
+ await this._asUserOrApp({
249
+ uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/views/${viewId}`,
250
+ method: 'DELETE',
251
+ sdkFn: () => this.client.bitable.appTableView.delete({ path: { app_token: appToken, table_id: tableId, view_id: viewId } }),
252
+ label: 'deleteView',
253
+ });
254
+ return { deleted: true };
255
+ },
256
+
257
+ async copyBitable(appToken, name, folderId) {
258
+ const data = { name };
259
+ if (folderId) data.folder_token = folderId;
260
+ const res = await this._asUserOrApp({
261
+ uatPath: `/open-apis/bitable/v1/apps/${appToken}/copy`,
262
+ method: 'POST',
263
+ body: data,
264
+ sdkFn: () => this.client.bitable.app.copy({ path: { app_token: appToken }, data }),
265
+ label: 'copyBitable',
266
+ });
267
+ return { app: res.data.app, fallbackWarning: res._fallbackWarning || null };
268
+ },
269
+ };
@@ -0,0 +1,176 @@
1
+ // src/clients/official/calendar.js
2
+ // Mixed into LarkOfficialClient.prototype by ./index.js. UAT-first for all
3
+ // methods (calendar resources are user-owned by default).
4
+
5
+ module.exports = {
6
+ // --- Calendar read (v1.3.4) ---
7
+
8
+ async listCalendars({ pageSize = 50, pageToken, syncToken } = {}) {
9
+ // Feishu's calendar/v4/calendars endpoint rejects page_size < 50 with
10
+ // `99992402 field validation failed` ("the min value is 50"). The docs don't
11
+ // flag this — smoke-tested against the real API. Clamp to be safe.
12
+ const ps = Math.max(50, Number(pageSize) || 50);
13
+ const params = { page_size: String(ps) };
14
+ if (pageToken) params.page_token = pageToken;
15
+ if (syncToken) params.sync_token = syncToken;
16
+ const res = await this._asUserOrApp({
17
+ uatPath: `/open-apis/calendar/v4/calendars`,
18
+ query: params,
19
+ sdkFn: () => this.client.calendar.calendar.list({ params: { page_size: ps, ...(pageToken ? { page_token: pageToken } : {}), ...(syncToken ? { sync_token: syncToken } : {}) } }),
20
+ label: 'listCalendars',
21
+ });
22
+ return {
23
+ items: res.data.calendar_list || [],
24
+ pageToken: res.data.page_token,
25
+ syncToken: res.data.sync_token,
26
+ hasMore: res.data.has_more,
27
+ };
28
+ },
29
+
30
+ async listCalendarEvents(calendarId, { startTime, endTime, pageSize = 50, pageToken, syncToken } = {}) {
31
+ if (!calendarId) throw new Error('listCalendarEvents: calendarId is required');
32
+ const params = { page_size: String(pageSize) };
33
+ if (startTime) params.start_time = String(startTime);
34
+ if (endTime) params.end_time = String(endTime);
35
+ if (pageToken) params.page_token = pageToken;
36
+ if (syncToken) params.sync_token = syncToken;
37
+ const res = await this._asUserOrApp({
38
+ uatPath: `/open-apis/calendar/v4/calendars/${encodeURIComponent(calendarId)}/events`,
39
+ query: params,
40
+ sdkFn: () => this.client.calendar.calendarEvent.list({
41
+ path: { calendar_id: calendarId },
42
+ params: {
43
+ page_size: pageSize,
44
+ ...(startTime ? { start_time: String(startTime) } : {}),
45
+ ...(endTime ? { end_time: String(endTime) } : {}),
46
+ ...(pageToken ? { page_token: pageToken } : {}),
47
+ ...(syncToken ? { sync_token: syncToken } : {}),
48
+ },
49
+ }),
50
+ label: 'listCalendarEvents',
51
+ });
52
+ return {
53
+ items: res.data.items || [],
54
+ pageToken: res.data.page_token,
55
+ syncToken: res.data.sync_token,
56
+ hasMore: res.data.has_more,
57
+ };
58
+ },
59
+
60
+ async getCalendarEvent(calendarId, eventId) {
61
+ if (!calendarId || !eventId) throw new Error('getCalendarEvent: calendarId and eventId are required');
62
+ const res = await this._asUserOrApp({
63
+ uatPath: `/open-apis/calendar/v4/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`,
64
+ sdkFn: () => this.client.calendar.calendarEvent.get({ path: { calendar_id: calendarId, event_id: eventId } }),
65
+ label: 'getCalendarEvent',
66
+ });
67
+ return { event: res.data.event };
68
+ },
69
+
70
+ // --- Calendar write (v1.3.7) ---
71
+ // Requires `calendar:calendar.event:write` scope on app + UAT.
72
+
73
+ async createCalendarEvent(calendarId, eventData) {
74
+ if (!calendarId) throw new Error('createCalendarEvent: calendarId is required');
75
+ if (!eventData?.start_time || !eventData?.end_time) {
76
+ throw new Error('createCalendarEvent: start_time and end_time are required (each: {timestamp: "<unix-seconds>", timezone?: "Asia/Shanghai"} or {date: "YYYY-MM-DD"})');
77
+ }
78
+ const res = await this._asUserOrApp({
79
+ uatPath: `/open-apis/calendar/v4/calendars/${encodeURIComponent(calendarId)}/events`,
80
+ method: 'POST',
81
+ body: eventData,
82
+ sdkFn: () => this.client.calendar.calendarEvent.create({
83
+ path: { calendar_id: calendarId },
84
+ data: eventData,
85
+ }),
86
+ label: 'createCalendarEvent',
87
+ });
88
+ const out = { event: res.data.event, viaUser: !!res._viaUser };
89
+ if (res._fallbackWarning) out.fallbackWarning = res._fallbackWarning;
90
+ return out;
91
+ },
92
+
93
+ async updateCalendarEvent(calendarId, eventId, updates) {
94
+ if (!calendarId || !eventId) throw new Error('updateCalendarEvent: calendarId and eventId are required');
95
+ if (!updates || typeof updates !== 'object' || Object.keys(updates).length === 0) {
96
+ throw new Error('updateCalendarEvent: updates object is required (e.g. {summary, description, start_time, end_time, attendee_ability, ...})');
97
+ }
98
+ const res = await this._asUserOrApp({
99
+ uatPath: `/open-apis/calendar/v4/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`,
100
+ method: 'PATCH',
101
+ body: updates,
102
+ sdkFn: () => this.client.calendar.calendarEvent.patch({
103
+ path: { calendar_id: calendarId, event_id: eventId },
104
+ data: updates,
105
+ }),
106
+ label: 'updateCalendarEvent',
107
+ });
108
+ const out = { event: res.data.event, viaUser: !!res._viaUser };
109
+ if (res._fallbackWarning) out.fallbackWarning = res._fallbackWarning;
110
+ return out;
111
+ },
112
+
113
+ async deleteCalendarEvent(calendarId, eventId, { needNotification, meetingChatId } = {}) {
114
+ if (!calendarId || !eventId) throw new Error('deleteCalendarEvent: calendarId and eventId are required');
115
+ const query = {};
116
+ if (needNotification !== undefined) query.need_notification = String(needNotification);
117
+ if (meetingChatId) query.meeting_chat_id = meetingChatId;
118
+ const res = await this._asUserOrApp({
119
+ uatPath: `/open-apis/calendar/v4/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`,
120
+ method: 'DELETE',
121
+ query,
122
+ sdkFn: () => this.client.calendar.calendarEvent.delete({
123
+ path: { calendar_id: calendarId, event_id: eventId },
124
+ params: meetingChatId ? { meeting_chat_id: meetingChatId } : {},
125
+ }),
126
+ label: 'deleteCalendarEvent',
127
+ });
128
+ const out = { deleted: true, viaUser: !!res._viaUser };
129
+ if (res._fallbackWarning) out.fallbackWarning = res._fallbackWarning;
130
+ return out;
131
+ },
132
+
133
+ async respondCalendarEvent(calendarId, eventId, rsvpStatus) {
134
+ if (!calendarId || !eventId) throw new Error('respondCalendarEvent: calendarId and eventId are required');
135
+ if (!['accept', 'decline', 'tentative'].includes(rsvpStatus)) {
136
+ throw new Error('respondCalendarEvent: rsvp_status must be one of accept|decline|tentative');
137
+ }
138
+ const body = { rsvp_status: rsvpStatus };
139
+ const res = await this._asUserOrApp({
140
+ uatPath: `/open-apis/calendar/v4/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}/reply`,
141
+ method: 'PATCH',
142
+ body,
143
+ sdkFn: () => this.client.calendar.calendarEvent.reply({
144
+ path: { calendar_id: calendarId, event_id: eventId },
145
+ data: body,
146
+ }),
147
+ label: 'respondCalendarEvent',
148
+ });
149
+ const out = { rsvp: rsvpStatus, viaUser: !!res._viaUser };
150
+ if (res._fallbackWarning) out.fallbackWarning = res._fallbackWarning;
151
+ return out;
152
+ },
153
+
154
+ async getFreebusy({ timeMin, timeMax, userIds = [], roomIds = [], includeExternalCalendar, onlyBusy } = {}) {
155
+ if (!timeMin || !timeMax) throw new Error('getFreebusy: time_min and time_max (RFC3339 strings) are required');
156
+ if (!Array.isArray(userIds) || userIds.length === 0) {
157
+ throw new Error('getFreebusy: user_ids array is required (use list_profiles / get_login_status to get your own open_id)');
158
+ }
159
+ const body = {
160
+ time_min: timeMin,
161
+ time_max: timeMax,
162
+ user_ids: userIds,
163
+ };
164
+ if (roomIds && roomIds.length) body.room_ids = roomIds;
165
+ if (includeExternalCalendar !== undefined) body.include_external_calendar = !!includeExternalCalendar;
166
+ if (onlyBusy !== undefined) body.only_busy = !!onlyBusy;
167
+ const res = await this._asUserOrApp({
168
+ uatPath: `/open-apis/calendar/v4/freebusy/batch_get`,
169
+ method: 'POST',
170
+ body,
171
+ sdkFn: () => this.client.calendar.freebusy.batch({ data: body }),
172
+ label: 'getFreebusy',
173
+ });
174
+ return { freebusyLists: res.data.freebusy_lists || [], viaUser: !!res._viaUser };
175
+ },
176
+ };
@@ -0,0 +1,54 @@
1
+ // src/clients/official/contacts.js
2
+ // Mixed into LarkOfficialClient.prototype by ./index.js. Methods receive `this`
3
+ // bound to the LarkOfficialClient instance, so they can use this.client,
4
+ // this._safeSDKCall, this._asUserOrApp, this._uatREST, etc.
5
+
6
+ module.exports = {
7
+ // --- User Name Resolution ---
8
+ //
9
+ // UAT-first (v1.3.7 C1.15 fix) so that calling get_user_info with the
10
+ // current user's own open_id resolves correctly. The bot path goes via the
11
+ // app-level contact API which can read tenant employees but does not have a
12
+ // notion of "the calling user" — so when the bot couldn't see a user (because
13
+ // contact scope wasn't granted, or the user happened to be the bot's owner)
14
+ // the previous code fell into a `null` and we surfaced "may be from external
15
+ // tenant", which was misleading. UAT can always see the current user.
16
+ async getUserById(userId, userIdType = 'open_id') {
17
+ if (this._userNameCache.has(userId)) return this._userNameCache.get(userId);
18
+
19
+ // 1. UAT path — works for the current user (self) and any colleague the
20
+ // UAT owner has access to.
21
+ if (this.hasUAT) {
22
+ try {
23
+ const data = await this._uatREST(
24
+ 'GET',
25
+ `/open-apis/contact/v3/users/${encodeURIComponent(userId)}`,
26
+ { query: { user_id_type: userIdType } },
27
+ );
28
+ if (data && data.code === 0 && data.data?.user?.name) {
29
+ this._userNameCache.set(userId, data.data.user.name);
30
+ return data.data.user.name;
31
+ }
32
+ } catch (e) {
33
+ console.error(`[feishu-user-plugin] getUserById(${userId}) as user failed: ${e.message}`);
34
+ }
35
+ }
36
+
37
+ // 2. Bot fallback — needs `contact:user.base:readonly` scope on the app.
38
+ try {
39
+ const res = await this.client.contact.user.get({
40
+ path: { user_id: userId },
41
+ params: { user_id_type: userIdType },
42
+ });
43
+ if (res.code === 0 && res.data?.user?.name) {
44
+ this._userNameCache.set(userId, res.data.user.name);
45
+ return res.data.user.name;
46
+ }
47
+ } catch (e) {
48
+ // Surface to the diagnostics log so users can see whether the failure
49
+ // was a missing contact scope vs an actual external user.
50
+ console.error(`[feishu-user-plugin] getUserById(${userId}) as bot failed: ${e.message}`);
51
+ }
52
+ return null;
53
+ },
54
+ };