feishu-user-plugin 1.1.1 → 1.1.3
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 +1 -1
- package/CHANGELOG.md +17 -0
- package/package.json +1 -1
- package/skills/feishu-user-plugin/SKILL.md +1 -1
- package/src/index.js +53 -21
- package/src/oauth.js +29 -1
- package/src/official.js +23 -7
- package/src/test-comprehensive.js +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "feishu-user-plugin",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.3",
|
|
4
4
|
"description": "All-in-one Feishu plugin for Claude Code — send messages as yourself, read chats, manage docs/tables/wiki. 33 tools + 9 skills, 3 auth layers.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "EthanQC"
|
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [1.1.3] - 2026-03-11
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- **Case-insensitive chat name matching**: All name resolution strategies (bot group list, im.chat.search, search_contacts) now use case-insensitive matching. "ai技术解决" now correctly matches "AI技术解决(内部)".
|
|
11
|
+
- **expires_in NaN bug**: UAT token refresh and OAuth now validate `expires_in` field, defaulting to 7200s if missing/invalid, preventing NaN corruption in config.
|
|
12
|
+
- **_populateSenderNames inefficiency**: Fixed redundant condition in cookie-based name fallback.
|
|
13
|
+
- **OAuth silent persistence failure**: Now logs warnings when token persistence to `~/.claude.json` fails, instead of silently swallowing errors.
|
|
14
|
+
- **Null safety**: Added null check in `resolveToOcId` for undefined chat_id.
|
|
15
|
+
|
|
16
|
+
## [1.1.2] - 2026-03-11
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
- **Double OAuth on first install**: `oauth.js` now writes tokens to both `.env` and `~/.claude.json` MCP config directly, so MCP restart picks them up immediately without needing a second OAuth run.
|
|
20
|
+
- **readMessagesAsUser fails with start_time but no end_time**: Auto-sets `end_time` to current timestamp when `start_time` is provided but `end_time` is not, preventing "end_time earlier than start_time" error.
|
|
21
|
+
- **read_p2p_messages rejects chat names**: Now resolves user/group names automatically via search_contacts.
|
|
22
|
+
- **External group messages show sender IDs instead of names**: `_populateSenderNames` now falls back to cookie-based user identity lookup for external tenant users.
|
|
23
|
+
|
|
7
24
|
## [1.1.1] - 2026-03-11
|
|
8
25
|
|
|
9
26
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "feishu-user-plugin",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.3",
|
|
4
4
|
"description": "All-in-one Feishu plugin for Claude Code — send messages as yourself, read chats, manage docs/tables/wiki. 33 tools + 9 skills, 3 auth layers.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: feishu-user-plugin
|
|
3
|
-
version: "1.1.
|
|
3
|
+
version: "1.1.3"
|
|
4
4
|
description: "All-in-one Feishu plugin — send messages as yourself, read group/P2P chats, manage docs/tables/wiki. Replaces and extends the official Feishu MCP."
|
|
5
5
|
allowed-tools: send_to_user, send_to_group, send_as_user, send_image_as_user, send_file_as_user, send_post_as_user, send_sticker_as_user, send_audio_as_user, search_contacts, create_p2p_chat, get_chat_info, get_user_info, get_login_status, read_p2p_messages, list_user_chats, list_chats, read_messages, reply_message, forward_message, search_docs, read_doc, create_doc, list_bitable_tables, list_bitable_fields, search_bitable_records, create_bitable_record, update_bitable_record, list_wiki_spaces, search_wiki, list_wiki_nodes, list_files, create_folder, find_user
|
|
6
6
|
user_invocable: true
|
package/src/index.js
CHANGED
|
@@ -33,20 +33,28 @@ class ChatIdMapper {
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
// Case-insensitive name matching helper
|
|
37
|
+
static _nameMatch(haystack, needle, exact = false) {
|
|
38
|
+
if (!haystack || !needle) return false;
|
|
39
|
+
const h = haystack.toLowerCase(), n = needle.toLowerCase();
|
|
40
|
+
return exact ? h === n : h.includes(n);
|
|
41
|
+
}
|
|
42
|
+
|
|
36
43
|
async findByName(name, official) {
|
|
37
44
|
await this._refresh(official);
|
|
38
|
-
// Exact match first
|
|
45
|
+
// Exact match first (case-insensitive)
|
|
39
46
|
for (const [ocId, chatName] of this.nameCache) {
|
|
40
|
-
if (chatName
|
|
47
|
+
if (ChatIdMapper._nameMatch(chatName, name, true)) return ocId;
|
|
41
48
|
}
|
|
42
|
-
// Partial match
|
|
49
|
+
// Partial match (case-insensitive)
|
|
43
50
|
for (const [ocId, chatName] of this.nameCache) {
|
|
44
|
-
if (chatName
|
|
51
|
+
if (ChatIdMapper._nameMatch(chatName, name)) return ocId;
|
|
45
52
|
}
|
|
46
53
|
return null;
|
|
47
54
|
}
|
|
48
55
|
|
|
49
56
|
async resolveToOcId(chatIdOrName, official) {
|
|
57
|
+
if (!chatIdOrName) return null;
|
|
50
58
|
if (chatIdOrName.startsWith('oc_')) return chatIdOrName;
|
|
51
59
|
// Also accept raw numeric IDs (from search_contacts)
|
|
52
60
|
if (/^\d+$/.test(chatIdOrName)) return chatIdOrName;
|
|
@@ -58,11 +66,11 @@ class ChatIdMapper {
|
|
|
58
66
|
const results = await official.chatSearch(chatIdOrName);
|
|
59
67
|
for (const chat of results) {
|
|
60
68
|
this.nameCache.set(chat.chat_id, chat.name || '');
|
|
61
|
-
if (chat.name
|
|
69
|
+
if (ChatIdMapper._nameMatch(chat.name, chatIdOrName, true)) return chat.chat_id;
|
|
62
70
|
}
|
|
63
|
-
// Partial match on search results
|
|
71
|
+
// Partial match on search results (case-insensitive)
|
|
64
72
|
for (const chat of results) {
|
|
65
|
-
if (chat.name
|
|
73
|
+
if (ChatIdMapper._nameMatch(chat.name, chatIdOrName)) return chat.chat_id;
|
|
66
74
|
}
|
|
67
75
|
} catch (e) {
|
|
68
76
|
console.error('[feishu-user-plugin] chatSearch fallback failed:', e.message);
|
|
@@ -77,13 +85,13 @@ class ChatIdMapper {
|
|
|
77
85
|
try {
|
|
78
86
|
const results = await userClient.search(chatName);
|
|
79
87
|
const groups = results.filter(r => r.type === 'group');
|
|
80
|
-
// Exact match first
|
|
88
|
+
// Exact match first (case-insensitive)
|
|
81
89
|
for (const g of groups) {
|
|
82
|
-
if (g.title
|
|
90
|
+
if (ChatIdMapper._nameMatch(g.title, chatName, true)) return String(g.id);
|
|
83
91
|
}
|
|
84
|
-
// Partial match
|
|
92
|
+
// Partial match (case-insensitive)
|
|
85
93
|
for (const g of groups) {
|
|
86
|
-
if (g.title
|
|
94
|
+
if (ChatIdMapper._nameMatch(g.title, chatName)) return String(g.id);
|
|
87
95
|
}
|
|
88
96
|
} catch (e) {
|
|
89
97
|
console.error('[feishu-user-plugin] search_contacts fallback failed:', e.message);
|
|
@@ -531,7 +539,7 @@ const TOOLS = [
|
|
|
531
539
|
// --- Server ---
|
|
532
540
|
|
|
533
541
|
const server = new Server(
|
|
534
|
-
{ name: 'feishu-user-plugin', version: '1.1.
|
|
542
|
+
{ name: 'feishu-user-plugin', version: '1.1.3' },
|
|
535
543
|
{ capabilities: { tools: {} } }
|
|
536
544
|
);
|
|
537
545
|
|
|
@@ -662,10 +670,32 @@ async function handleTool(name, args) {
|
|
|
662
670
|
|
|
663
671
|
case 'read_p2p_messages': {
|
|
664
672
|
const official = getOfficialClient();
|
|
665
|
-
|
|
673
|
+
let chatId = args.chat_id;
|
|
674
|
+
let uc = null;
|
|
675
|
+
try { uc = await getUserClient(); } catch (_) {}
|
|
676
|
+
// If chat_id is not numeric or oc_, try to resolve as user name → P2P chat
|
|
677
|
+
if (!/^\d+$/.test(chatId) && !chatId.startsWith('oc_')) {
|
|
678
|
+
if (uc) {
|
|
679
|
+
const results = await uc.search(chatId);
|
|
680
|
+
const user = results.find(r => r.type === 'user');
|
|
681
|
+
if (user) {
|
|
682
|
+
const pChatId = await uc.createChat(String(user.id));
|
|
683
|
+
if (pChatId) chatId = String(pChatId);
|
|
684
|
+
else return text(`Found user "${user.title}" but failed to create P2P chat.`);
|
|
685
|
+
} else {
|
|
686
|
+
// Maybe it's a group name
|
|
687
|
+
const group = results.find(r => r.type === 'group');
|
|
688
|
+
if (group) chatId = String(group.id);
|
|
689
|
+
else return text(`Cannot resolve "${args.chat_id}" to a chat. Use search_contacts to find the ID first.`);
|
|
690
|
+
}
|
|
691
|
+
} else {
|
|
692
|
+
return text(`"${args.chat_id}" is not a valid chat ID. Provide a numeric ID or oc_xxx format. Use search_contacts + create_p2p_chat to get the ID.`);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
return json(await official.readMessagesAsUser(chatId, {
|
|
666
696
|
pageSize: args.page_size, startTime: args.start_time, endTime: args.end_time,
|
|
667
697
|
sortType: args.sort_type,
|
|
668
|
-
}));
|
|
698
|
+
}, uc));
|
|
669
699
|
}
|
|
670
700
|
case 'list_user_chats':
|
|
671
701
|
return json(await getOfficialClient().listChatsAsUser({ pageSize: args.page_size, pageToken: args.page_token }));
|
|
@@ -677,18 +707,21 @@ async function handleTool(name, args) {
|
|
|
677
707
|
case 'read_messages': {
|
|
678
708
|
const official = getOfficialClient();
|
|
679
709
|
const msgOpts = { pageSize: args.page_size, startTime: args.start_time, endTime: args.end_time, sortType: args.sort_type };
|
|
710
|
+
// Get userClient for name resolution fallback (best-effort)
|
|
711
|
+
let uc = null;
|
|
712
|
+
try { uc = await getUserClient(); } catch (_) {}
|
|
680
713
|
const resolvedChatId = await chatIdMapper.resolveToOcId(args.chat_id, official);
|
|
681
714
|
|
|
682
715
|
// Try bot API first if we resolved an oc_ ID
|
|
683
716
|
if (resolvedChatId) {
|
|
684
717
|
try {
|
|
685
|
-
return json(await official.readMessages(resolvedChatId, msgOpts));
|
|
718
|
+
return json(await official.readMessages(resolvedChatId, msgOpts, uc));
|
|
686
719
|
} catch (botErr) {
|
|
687
720
|
// Bot API failed (e.g. bot not in group, no permission) — fall through to UAT
|
|
688
721
|
console.error(`[feishu-user-plugin] read_messages bot API failed for ${resolvedChatId}: ${botErr.message}`);
|
|
689
722
|
if (official.hasUAT) {
|
|
690
723
|
try {
|
|
691
|
-
return json(await official.readMessagesAsUser(resolvedChatId, msgOpts));
|
|
724
|
+
return json(await official.readMessagesAsUser(resolvedChatId, msgOpts, uc));
|
|
692
725
|
} catch (uatErr) {
|
|
693
726
|
console.error(`[feishu-user-plugin] read_messages UAT fallback also failed for ${resolvedChatId}: ${uatErr.message}`);
|
|
694
727
|
}
|
|
@@ -699,11 +732,10 @@ async function handleTool(name, args) {
|
|
|
699
732
|
|
|
700
733
|
// Bot couldn't resolve the chat name — try search_contacts + UAT for external groups
|
|
701
734
|
if (official.hasUAT) {
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
const contactChatId = await chatIdMapper.resolveViaContacts(args.chat_id, contactClient);
|
|
735
|
+
if (!uc) try { uc = await getUserClient(); } catch (_) {}
|
|
736
|
+
const contactChatId = await chatIdMapper.resolveViaContacts(args.chat_id, uc);
|
|
705
737
|
if (contactChatId) {
|
|
706
|
-
return json(await official.readMessagesAsUser(contactChatId, msgOpts));
|
|
738
|
+
return json(await official.readMessagesAsUser(contactChatId, msgOpts, uc));
|
|
707
739
|
}
|
|
708
740
|
}
|
|
709
741
|
|
|
@@ -772,7 +804,7 @@ async function main() {
|
|
|
772
804
|
const hasCookie = !!process.env.LARK_COOKIE;
|
|
773
805
|
const hasApp = !!(process.env.LARK_APP_ID && process.env.LARK_APP_SECRET);
|
|
774
806
|
const hasUAT = !!process.env.LARK_USER_ACCESS_TOKEN;
|
|
775
|
-
console.error(`[feishu-user-plugin] MCP Server v1.1.
|
|
807
|
+
console.error(`[feishu-user-plugin] MCP Server v1.1.3 — ${TOOLS.length} tools`);
|
|
776
808
|
console.error(`[feishu-user-plugin] Auth: Cookie=${hasCookie ? 'YES' : 'NO'} App=${hasApp ? 'YES' : 'NO'} UAT=${hasUAT ? 'YES' : 'NO'}`);
|
|
777
809
|
if (!hasCookie) console.error('[feishu-user-plugin] WARNING: LARK_COOKIE not set — user identity tools (send_to_user, etc.) will fail');
|
|
778
810
|
if (!hasApp) console.error('[feishu-user-plugin] WARNING: LARK_APP_ID/SECRET not set — official API tools (read_messages, docs, etc.) will fail');
|
package/src/oauth.js
CHANGED
|
@@ -108,7 +108,7 @@ function saveToken(tokenData) {
|
|
|
108
108
|
LARK_USER_ACCESS_TOKEN: tokenData.access_token,
|
|
109
109
|
LARK_USER_REFRESH_TOKEN: tokenData.refresh_token || '',
|
|
110
110
|
LARK_UAT_SCOPE: tokenData.scope || '',
|
|
111
|
-
LARK_UAT_EXPIRES: String(Math.floor(Date.now() / 1000 + tokenData.expires_in)),
|
|
111
|
+
LARK_UAT_EXPIRES: String(Math.floor(Date.now() / 1000 + (typeof tokenData.expires_in === 'number' && tokenData.expires_in > 0 ? tokenData.expires_in : 7200))),
|
|
112
112
|
};
|
|
113
113
|
|
|
114
114
|
for (const [key, val] of Object.entries(updates)) {
|
|
@@ -121,6 +121,34 @@ function saveToken(tokenData) {
|
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
fs.writeFileSync(envPath, envContent.trim() + '\n');
|
|
124
|
+
|
|
125
|
+
// Also persist to ~/.claude.json MCP config so MCP restart picks up tokens immediately
|
|
126
|
+
_persistToClaudeJson(updates);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function _persistToClaudeJson(updates) {
|
|
130
|
+
const claudeJsonPaths = [
|
|
131
|
+
path.join(process.env.HOME || '', '.claude.json'),
|
|
132
|
+
path.join(process.env.HOME || '', '.claude', '.claude.json'),
|
|
133
|
+
];
|
|
134
|
+
for (const cjPath of claudeJsonPaths) {
|
|
135
|
+
try {
|
|
136
|
+
const raw = fs.readFileSync(cjPath, 'utf8');
|
|
137
|
+
const config = JSON.parse(raw);
|
|
138
|
+
const servers = config.mcpServers || {};
|
|
139
|
+
for (const name of ['feishu-user-plugin', 'feishu']) {
|
|
140
|
+
if (servers[name]?.env) {
|
|
141
|
+
Object.assign(servers[name].env, updates);
|
|
142
|
+
fs.writeFileSync(cjPath, JSON.stringify(config, null, 2) + '\n');
|
|
143
|
+
console.log(`[feishu-user-plugin] OAuth tokens persisted to ${cjPath}`);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
} catch (e) {
|
|
148
|
+
console.error(`[feishu-user-plugin] Failed to persist tokens to ${cjPath}: ${e.message}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
console.error('[feishu-user-plugin] WARNING: Could not persist tokens to ~/.claude.json. Tokens saved to .env only — copy them to your MCP config manually.');
|
|
124
152
|
}
|
|
125
153
|
|
|
126
154
|
const server = http.createServer(async (req, res) => {
|
package/src/official.js
CHANGED
|
@@ -58,7 +58,8 @@ class LarkOfficialClient {
|
|
|
58
58
|
|
|
59
59
|
this._uat = tokenData.access_token;
|
|
60
60
|
this._uatRefresh = tokenData.refresh_token || this._uatRefresh;
|
|
61
|
-
|
|
61
|
+
const expiresIn = typeof tokenData.expires_in === 'number' && tokenData.expires_in > 0 ? tokenData.expires_in : 7200;
|
|
62
|
+
this._uatExpires = Math.floor(Date.now() / 1000) + expiresIn;
|
|
62
63
|
this._persistUAT();
|
|
63
64
|
console.error('[feishu-user-plugin] UAT refreshed successfully');
|
|
64
65
|
return this._uat;
|
|
@@ -156,7 +157,11 @@ class LarkOfficialClient {
|
|
|
156
157
|
return { items: data.data.items || [], pageToken: data.data.page_token, hasMore: data.data.has_more };
|
|
157
158
|
}
|
|
158
159
|
|
|
159
|
-
async readMessagesAsUser(chatId, { pageSize = 20, startTime, endTime, pageToken, sortType = 'ByCreateTimeDesc' } = {}) {
|
|
160
|
+
async readMessagesAsUser(chatId, { pageSize = 20, startTime, endTime, pageToken, sortType = 'ByCreateTimeDesc' } = {}, userClient) {
|
|
161
|
+
// Feishu API requires end_time >= start_time; auto-set end_time to now if missing
|
|
162
|
+
if (startTime && !endTime) {
|
|
163
|
+
endTime = String(Math.floor(Date.now() / 1000));
|
|
164
|
+
}
|
|
160
165
|
const params = new URLSearchParams({
|
|
161
166
|
container_id_type: 'chat', container_id: chatId, page_size: String(pageSize),
|
|
162
167
|
sort_type: sortType,
|
|
@@ -172,7 +177,7 @@ class LarkOfficialClient {
|
|
|
172
177
|
});
|
|
173
178
|
if (data.code !== 0) throw new Error(`readMessagesAsUser failed (${data.code}): ${data.msg}`);
|
|
174
179
|
const items = (data.data.items || []).map(m => this._formatMessage(m));
|
|
175
|
-
await this._populateSenderNames(items);
|
|
180
|
+
await this._populateSenderNames(items, userClient);
|
|
176
181
|
return { items, hasMore: data.data.has_more, pageToken: data.data.page_token };
|
|
177
182
|
}
|
|
178
183
|
|
|
@@ -186,14 +191,14 @@ class LarkOfficialClient {
|
|
|
186
191
|
return { items: res.data.items || [], pageToken: res.data.page_token, hasMore: res.data.has_more };
|
|
187
192
|
}
|
|
188
193
|
|
|
189
|
-
async readMessages(chatId, { pageSize = 20, startTime, endTime, pageToken, sortType = 'ByCreateTimeDesc' } = {}) {
|
|
194
|
+
async readMessages(chatId, { pageSize = 20, startTime, endTime, pageToken, sortType = 'ByCreateTimeDesc' } = {}, userClient) {
|
|
190
195
|
const params = { container_id_type: 'chat', container_id: chatId, page_size: pageSize, sort_type: sortType };
|
|
191
196
|
if (startTime) params.start_time = startTime;
|
|
192
197
|
if (endTime) params.end_time = endTime;
|
|
193
198
|
if (pageToken) params.page_token = pageToken;
|
|
194
199
|
const res = await this._safeSDKCall(() => this.client.im.message.list({ params }), 'readMessages');
|
|
195
200
|
const items = (res.data.items || []).map(m => this._formatMessage(m));
|
|
196
|
-
await this._populateSenderNames(items);
|
|
201
|
+
await this._populateSenderNames(items, userClient);
|
|
197
202
|
return { items, hasMore: res.data.has_more, pageToken: res.data.page_token };
|
|
198
203
|
}
|
|
199
204
|
|
|
@@ -429,7 +434,7 @@ class LarkOfficialClient {
|
|
|
429
434
|
return null;
|
|
430
435
|
}
|
|
431
436
|
|
|
432
|
-
async _populateSenderNames(items) {
|
|
437
|
+
async _populateSenderNames(items, userClient) {
|
|
433
438
|
// Collect unique sender IDs that aren't cached
|
|
434
439
|
const unknownIds = new Set();
|
|
435
440
|
for (const item of items) {
|
|
@@ -437,10 +442,21 @@ class LarkOfficialClient {
|
|
|
437
442
|
unknownIds.add(item.senderId);
|
|
438
443
|
}
|
|
439
444
|
}
|
|
440
|
-
// Batch resolve
|
|
445
|
+
// Batch resolve via official contact API
|
|
441
446
|
for (const id of unknownIds) {
|
|
442
447
|
await this.getUserById(id);
|
|
443
448
|
}
|
|
449
|
+
// Fallback: resolve remaining unknowns via cookie-based user identity client
|
|
450
|
+
if (userClient) {
|
|
451
|
+
for (const id of unknownIds) {
|
|
452
|
+
if (!this._userNameCache.has(id)) {
|
|
453
|
+
try {
|
|
454
|
+
const name = await userClient.getUserName(id);
|
|
455
|
+
if (name) this._userNameCache.set(id, name);
|
|
456
|
+
} catch {}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
444
460
|
// Populate senderName field
|
|
445
461
|
for (const item of items) {
|
|
446
462
|
if (item.senderId) {
|
|
@@ -274,7 +274,7 @@ async function testUAT() {
|
|
|
274
274
|
}
|
|
275
275
|
|
|
276
276
|
async function main() {
|
|
277
|
-
console.log('=== feishu-user-plugin v1.1.
|
|
277
|
+
console.log('=== feishu-user-plugin v1.1.3 — Comprehensive Test ===\n');
|
|
278
278
|
|
|
279
279
|
await testUserIdentity();
|
|
280
280
|
console.log('');
|