feishu-user-plugin 1.3.2 → 1.3.4
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 +37 -0
- package/README.md +19 -4
- package/package.json +2 -2
- package/skills/feishu-user-plugin/SKILL.md +3 -3
- package/skills/feishu-user-plugin/references/CLAUDE.md +114 -5
- package/src/client.js +4 -4
- package/src/doc-blocks.js +70 -0
- package/src/error-codes.js +78 -0
- package/src/index.js +318 -68
- package/src/oauth.js +6 -1
- package/src/official.js +584 -15
- package/src/resolver.js +151 -0
- package/src/utils.js +13 -0
package/src/resolver.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Unified Feishu ID resolver — accepts three input forms and produces
|
|
2
|
+
// a concrete { obj_type, obj_token } pair that downstream docx / bitable
|
|
3
|
+
// tools can consume.
|
|
4
|
+
//
|
|
5
|
+
// Accepted inputs:
|
|
6
|
+
// • Native token (e.g. "doccnXXX", "bascnXXX", "docxAAA")
|
|
7
|
+
// • Wiki node token (e.g. "wikcnAAA", "wikmXXX", "wikxxxXXX")
|
|
8
|
+
// • Full Feishu URL (e.g. "https://xxx.feishu.cn/docx/DocXXX?...")
|
|
9
|
+
//
|
|
10
|
+
// Exposed functions:
|
|
11
|
+
// parseFeishuInput(input) — pure, no I/O. Maps input → { kind, token, raw }.
|
|
12
|
+
// resolveToObj(input, official) — async. For wiki kind, calls official.getWikiNode
|
|
13
|
+
// to unwrap to the underlying obj_token + obj_type.
|
|
14
|
+
// Results cached for 10 min.
|
|
15
|
+
|
|
16
|
+
// Host allows the `<sub>.feishu.cn` / `<sub>.larksuite.com` pattern with an
|
|
17
|
+
// optional port (some corporate proxies surface :443 explicitly; rare but we
|
|
18
|
+
// cost nothing by allowing it).
|
|
19
|
+
const URL_RE = /^https?:\/\/[^/]*(feishu\.cn|larksuite\.com)(?::\d+)?\/(docx|wiki|base|sheets|file|docs)\/([A-Za-z0-9_-]+)/;
|
|
20
|
+
const WIKI_BARE_RE = /^(wik[a-z]{1,4})([A-Za-z0-9_-]{6,})$/; // wikcn / wikm / wikn / wiki prefixes
|
|
21
|
+
|
|
22
|
+
// Feishu URL segment → obj_type mapping. 'docs' is the legacy doc type (pre-docx).
|
|
23
|
+
const URL_KIND_MAP = {
|
|
24
|
+
docx: 'docx',
|
|
25
|
+
docs: 'doc',
|
|
26
|
+
wiki: 'wiki',
|
|
27
|
+
base: 'bitable',
|
|
28
|
+
sheets: 'sheet',
|
|
29
|
+
file: 'file',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Parse a Feishu input string into its components. Pure / no I/O.
|
|
34
|
+
* @param {string} input
|
|
35
|
+
* @returns {{kind: string, token: string, raw: string}}
|
|
36
|
+
* kind: 'docx' | 'doc' | 'wiki' | 'bitable' | 'sheet' | 'file' | 'raw'
|
|
37
|
+
* token: the extracted token (or the original string if kind='raw')
|
|
38
|
+
* raw: the original input (for diagnostics)
|
|
39
|
+
*/
|
|
40
|
+
function parseFeishuInput(input) {
|
|
41
|
+
if (input === null || input === undefined) {
|
|
42
|
+
throw new Error('parseFeishuInput: input is required');
|
|
43
|
+
}
|
|
44
|
+
const s = String(input).trim();
|
|
45
|
+
if (!s) throw new Error('parseFeishuInput: input is empty');
|
|
46
|
+
|
|
47
|
+
// URL form
|
|
48
|
+
const m = s.match(URL_RE);
|
|
49
|
+
if (m) {
|
|
50
|
+
const segment = m[2];
|
|
51
|
+
const token = m[3];
|
|
52
|
+
const kind = URL_KIND_MAP[segment] || 'raw';
|
|
53
|
+
return { kind, token, raw: s };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Bare wiki node token (starts with wik-prefix like wikcn/wikm/wikn and has body)
|
|
57
|
+
if (WIKI_BARE_RE.test(s)) {
|
|
58
|
+
return { kind: 'wiki', token: s, raw: s };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Everything else is a raw native token — downstream will trust it as-is.
|
|
62
|
+
return { kind: 'raw', token: s, raw: s };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// --- LRU cache for wiki-node → obj resolution ---
|
|
66
|
+
// Wiki node tokens are long-lived; the obj_token and obj_type behind them
|
|
67
|
+
// essentially never change, so a 10-minute TTL prevents refetching the
|
|
68
|
+
// same node 20 times in one chain of tool calls but still lets the rare
|
|
69
|
+
// re-parented-node case catch up on its own.
|
|
70
|
+
|
|
71
|
+
const CACHE_MAX = 200;
|
|
72
|
+
const CACHE_TTL_MS = 10 * 60 * 1000;
|
|
73
|
+
const _cache = new Map(); // token → { value, expiresAt }
|
|
74
|
+
|
|
75
|
+
function _cacheGet(token) {
|
|
76
|
+
const e = _cache.get(token);
|
|
77
|
+
if (!e) return null;
|
|
78
|
+
if (Date.now() > e.expiresAt) {
|
|
79
|
+
_cache.delete(token);
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
// Refresh LRU position
|
|
83
|
+
_cache.delete(token);
|
|
84
|
+
_cache.set(token, e);
|
|
85
|
+
return e.value;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function _cacheSet(token, value) {
|
|
89
|
+
if (_cache.has(token)) _cache.delete(token);
|
|
90
|
+
_cache.set(token, { value, expiresAt: Date.now() + CACHE_TTL_MS });
|
|
91
|
+
// Evict oldest if over cap
|
|
92
|
+
while (_cache.size > CACHE_MAX) {
|
|
93
|
+
const firstKey = _cache.keys().next().value;
|
|
94
|
+
_cache.delete(firstKey);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Resolve a user-provided input into a concrete { obj_type, obj_token, space_id? }.
|
|
100
|
+
* @param {string} input
|
|
101
|
+
* @param {object} official LarkOfficialClient instance (for wiki getNode lookup)
|
|
102
|
+
* @returns {Promise<{obj_type: string, obj_token: string, space_id?: string, via: string}>}
|
|
103
|
+
* via: 'url-direct' | 'wiki-lookup' | 'raw-passthrough'
|
|
104
|
+
*/
|
|
105
|
+
async function resolveToObj(input, official) {
|
|
106
|
+
const parsed = parseFeishuInput(input);
|
|
107
|
+
|
|
108
|
+
// Wiki node — must call getWikiNode to unwrap.
|
|
109
|
+
if (parsed.kind === 'wiki') {
|
|
110
|
+
const cached = _cacheGet(parsed.token);
|
|
111
|
+
if (cached) return { ...cached, via: 'wiki-lookup-cached' };
|
|
112
|
+
if (!official || typeof official.getWikiNode !== 'function') {
|
|
113
|
+
throw new Error('resolveToObj: wiki input requires an official client, none provided');
|
|
114
|
+
}
|
|
115
|
+
const node = await official.getWikiNode(parsed.token);
|
|
116
|
+
if (!node || !node.obj_token || !node.obj_type) {
|
|
117
|
+
throw new Error(`resolveToObj: wiki node ${parsed.token} missing obj_token/obj_type in response: ${JSON.stringify(node)}`);
|
|
118
|
+
}
|
|
119
|
+
const value = {
|
|
120
|
+
obj_type: node.obj_type, // e.g. 'docx' | 'sheet' | 'bitable' | 'mindnote' | 'slide' | 'file'
|
|
121
|
+
obj_token: node.obj_token,
|
|
122
|
+
space_id: node.space_id,
|
|
123
|
+
};
|
|
124
|
+
_cacheSet(parsed.token, value);
|
|
125
|
+
return { ...value, via: 'wiki-lookup' };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Native URL with a direct doc/bitable/etc segment — just use the extracted token.
|
|
129
|
+
if (parsed.kind !== 'raw') {
|
|
130
|
+
return { obj_type: parsed.kind, obj_token: parsed.token, via: 'url-direct' };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Raw — caller knows what they're doing, pass through without claiming a type.
|
|
134
|
+
return { obj_type: 'raw', obj_token: parsed.token, via: 'raw-passthrough' };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Convenience shortcut: resolve and return just the obj_token.
|
|
139
|
+
* Used in handler prologues where we only need the native token and trust the tool
|
|
140
|
+
* to know which surface it's hitting.
|
|
141
|
+
*/
|
|
142
|
+
async function resolveToken(input, official) {
|
|
143
|
+
const r = await resolveToObj(input, official);
|
|
144
|
+
return r.obj_token;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
module.exports = {
|
|
148
|
+
parseFeishuInput,
|
|
149
|
+
resolveToObj,
|
|
150
|
+
resolveToken,
|
|
151
|
+
};
|
package/src/utils.js
CHANGED
|
@@ -31,9 +31,22 @@ function formatCookie(cookieObj) {
|
|
|
31
31
|
.join('; ');
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// Wraps global fetch with an AbortController-based timeout. A stalled network
|
|
35
|
+
// connection to feishu.cn can otherwise block an MCP tool handler indefinitely,
|
|
36
|
+
// causing the client to time out and (in some clients) tear down the stdio
|
|
37
|
+
// transport — observed as "MCP 中途掉线" by v1.3.2 users.
|
|
38
|
+
// Default 30s; pass `timeoutMs` in init to override per-call.
|
|
39
|
+
function fetchWithTimeout(url, init = {}) {
|
|
40
|
+
const { timeoutMs = 30000, ...rest } = init;
|
|
41
|
+
const controller = new AbortController();
|
|
42
|
+
const timer = setTimeout(() => controller.abort(new Error(`fetch timeout after ${timeoutMs}ms: ${url}`)), timeoutMs);
|
|
43
|
+
return fetch(url, { ...rest, signal: rest.signal || controller.signal }).finally(() => clearTimeout(timer));
|
|
44
|
+
}
|
|
45
|
+
|
|
34
46
|
module.exports = {
|
|
35
47
|
generateRequestId,
|
|
36
48
|
generateCid,
|
|
37
49
|
parseCookie,
|
|
38
50
|
formatCookie,
|
|
51
|
+
fetchWithTimeout,
|
|
39
52
|
};
|