feishu-user-plugin 1.3.10 → 1.3.12
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/.cursor-plugin/plugin.json +27 -0
- package/.mcpb/manifest.json +91 -0
- package/CHANGELOG.md +118 -0
- package/PRIVACY.md +105 -0
- package/README.en.md +130 -413
- package/README.md +88 -258
- package/package.json +5 -3
- package/scripts/build-mcpb.js +119 -0
- package/scripts/check-description-drift.js +73 -0
- package/scripts/check-docs-sync.js +7 -16
- package/scripts/check-mcp-registry-version.js +43 -0
- package/scripts/check-mcpb-version.js +33 -0
- package/scripts/check-scopes.js +99 -0
- package/scripts/check-tool-count.js +4 -3
- package/scripts/check-version.js +5 -0
- package/scripts/sync-claude-md.sh +3 -4
- package/scripts/sync-team-skills.sh +72 -57
- package/scripts/verify-app-name.js +64 -0
- package/skills/feishu-user-plugin/SKILL.md +3 -3
- package/skills/feishu-user-plugin/references/search.md +3 -3
- package/src/auth/credentials-monitor.js +185 -0
- package/src/auth/credentials.js +49 -0
- package/src/auth/identity-state.js +204 -0
- package/src/auth/lark-desktop.js +135 -0
- package/src/auth/uat.js +49 -35
- package/src/cli.js +87 -0
- package/src/clients/official/base.js +145 -14
- package/src/clients/official/calendar.js +3 -1
- package/src/clients/official/im.js +76 -2
- package/src/clients/official/okr.js +2 -1
- package/src/error-codes.js +40 -0
- package/src/events/lockfile.js +40 -4
- package/src/events/owner.js +11 -2
- package/src/index.js +1 -1
- package/src/logger.js +11 -5
- package/src/oauth.js +46 -10
- package/src/server.js +102 -37
- package/src/setup.js +44 -0
- package/src/test-all.js +40 -0
- package/src/test-cli-tool.js +87 -0
- package/src/test-credentials-monitor.js +124 -0
- package/src/test-display-label.js +88 -0
- package/src/test-error-codes.js +85 -0
- package/src/test-identity-state.js +172 -0
- package/src/test-lark-desktop.js +300 -0
- package/src/test-lockfile-pid.js +90 -0
- package/src/test-lru-cache.js +145 -0
- package/src/test-negative-cache.js +85 -0
- package/src/test-populate-sender-names.js +98 -0
- package/src/test-search-messages.js +101 -0
- package/src/test-send-shape.js +115 -0
- package/src/test-via-user.js +94 -0
- package/src/test-with-uat-retry.js +135 -0
- package/src/tools/_registry.js +24 -1
- package/src/tools/calendar.js +5 -5
- package/src/tools/im-read.js +52 -4
- package/src/tools/messaging-user.js +1 -1
- package/src/utils.js +83 -0
- package/scripts/generate-og-image.js +0 -39
- package/skills/feishu-user-plugin/references/CLAUDE.md +0 -523
package/src/tools/im-read.js
CHANGED
|
@@ -107,7 +107,7 @@ const schemas = [
|
|
|
107
107
|
},
|
|
108
108
|
{
|
|
109
109
|
name: 'read_p2p_messages',
|
|
110
|
-
description: '[User UAT] Read P2P (direct message) chat history using user_access_token. Works for chats the bot cannot access. Returns newest messages first by default. Auto-expands merge_forward messages into their child messages by default — disable with expand_merge_forward=false. Requires OAuth setup.',
|
|
110
|
+
description: '[User UAT] Read P2P (direct message) chat history using user_access_token. Works for chats the bot cannot access. Returns newest messages first by default. Auto-expands merge_forward messages into their child messages by default — disable with expand_merge_forward=false. Requires OAuth setup.\n\n**Sender semantics (v1.3.12)**: each message has a `displayLabel` (e.g. `周宇`, `[Bot] Claude聊天助手`, `[匿名]`, `[系统]`, `[已撤回] 怪兽`) — prefer it over raw `senderId` when narrating who-said-what. Also surfaced: `senderType` (user|app|anonymous), `senderIdType` (open_id|union_id|user_id), `senderTenantKey`, `isExternal` (cross-tenant), `isRecalled`, `isThreadReply` (parent_id present).',
|
|
111
111
|
inputSchema: {
|
|
112
112
|
type: 'object',
|
|
113
113
|
properties: {
|
|
@@ -145,7 +145,7 @@ const schemas = [
|
|
|
145
145
|
},
|
|
146
146
|
{
|
|
147
147
|
name: 'read_messages',
|
|
148
|
-
description: '[Official API + UAT fallback] Read message history from any group. Accepts oc_xxx ID, numeric ID, or chat name (auto-searched). Auto-falls back to UAT for external groups the bot cannot access. Returns newest messages first by default, with sender names resolved. Auto-expands merge_forward messages into their child messages (with original sender / time / content preserved) by default — disable with expand_merge_forward=false. Text messages have URLs extracted into `urls`; Feishu doc links are additionally surfaced as `feishuDocs` so agents can feed them straight into read_doc / get_doc_blocks.',
|
|
148
|
+
description: '[Official API + UAT fallback] Read message history from any group. Accepts oc_xxx ID, numeric ID, or chat name (auto-searched). Auto-falls back to UAT for external groups the bot cannot access. Returns newest messages first by default, with sender names resolved. Auto-expands merge_forward messages into their child messages (with original sender / time / content preserved) by default — disable with expand_merge_forward=false. Text messages have URLs extracted into `urls`; Feishu doc links are additionally surfaced as `feishuDocs` so agents can feed them straight into read_doc / get_doc_blocks.\n\n**Sender semantics (v1.3.12)**: each message has a `displayLabel` (e.g. `周宇`, `[Bot] Claude聊天助手`, `[匿名]`, `[系统]`, `[已撤回] 怪兽`) — prefer it over raw `senderId` when narrating who-said-what. Also surfaced: `senderType` (user|app|anonymous), `senderIdType` (open_id|union_id|user_id), `senderTenantKey`, `isExternal` (cross-tenant), `isRecalled`, `isThreadReply` (parent_id present). **merge_forward children** carry `originChatId` (the chat the conversation came from, NOT the chat you queried) and best-effort `forwardedFromChatName` — do NOT treat children as native messages of the current group.',
|
|
149
149
|
inputSchema: {
|
|
150
150
|
type: 'object',
|
|
151
151
|
properties: {
|
|
@@ -155,10 +155,29 @@ const schemas = [
|
|
|
155
155
|
end_time: { type: 'string', description: 'End timestamp in seconds (optional)' },
|
|
156
156
|
sort_type: { type: 'string', enum: ['ByCreateTimeDesc', 'ByCreateTimeAsc'], description: 'Sort order (default: ByCreateTimeDesc = newest first)' },
|
|
157
157
|
expand_merge_forward: { type: 'boolean', description: 'Auto-expand merge_forward placeholders into their child messages (default true). Children carry parentMessageId; use that id (not the child id) with download_message_resource (kind=image or file).' },
|
|
158
|
+
via_user: { type: 'boolean', description: 'v1.3.12 — explicit identity override. `true` skips the bot path and reads directly via UAT (use when the chat is yours / external and you know bot has no access). `false` skips UAT fallback and surfaces the bot error instead of cross-identity hop (use when you specifically want the bot view). Omit for default auto-fallback (bot first, UAT on failure).' },
|
|
158
159
|
},
|
|
159
160
|
required: ['chat_id'],
|
|
160
161
|
},
|
|
161
162
|
},
|
|
163
|
+
{
|
|
164
|
+
name: 'search_messages',
|
|
165
|
+
description: '[User UAT, v1.3.12] Search the user\'s IM history by keyword. Wraps Feishu `POST /open-apis/search/v2/message`. Requires UAT with the `search:message` scope (re-run `npx feishu-user-plugin oauth` after v1.3.12 SCOPES update). Feishu does NOT expose a bot-path search; if you only have app credentials this tool will error.\n\nReturns `{items, pageToken, hasMore}` where each item is a `{message_id, chat_id, ...}` pointer — call `read_messages(chat_id)` or `read_p2p_messages(chat_id)` to fetch the full message bodies if needed. The pointer-only return keeps the response token-light when searching across many chats.\n\nFilter knobs (all optional):\n- `chat_ids`: only search inside these chats (oc_xxx)\n- `from_ids`: messages sent by these users (ou_xxx / union_id)\n- `at_user_ids`: messages that @-mention these users\n- `message_types`: e.g. `["text", "post"]`\n- `from_types`: e.g. `["user", "anonymous"]`',
|
|
166
|
+
inputSchema: {
|
|
167
|
+
type: 'object',
|
|
168
|
+
properties: {
|
|
169
|
+
query: { type: 'string', description: 'Search keyword. Plain text; Feishu handles tokenization.' },
|
|
170
|
+
page_size: { type: 'number', description: 'Items per page (default 20, max 100)' },
|
|
171
|
+
page_token: { type: 'string', description: 'Pagination cursor from a previous page' },
|
|
172
|
+
chat_ids: { type: 'array', items: { type: 'string' }, description: 'Restrict to these oc_xxx chats' },
|
|
173
|
+
from_ids: { type: 'array', items: { type: 'string' }, description: 'Restrict to messages from these user ids (ou_xxx / union_id)' },
|
|
174
|
+
at_user_ids: { type: 'array', items: { type: 'string' }, description: 'Restrict to messages that @-mention these user ids' },
|
|
175
|
+
message_types: { type: 'array', items: { type: 'string' }, description: 'Filter by message types (e.g. ["text","post","image","file","interactive"])' },
|
|
176
|
+
from_types: { type: 'array', items: { type: 'string' }, description: 'Filter by sender types (e.g. ["user","anonymous"])' },
|
|
177
|
+
},
|
|
178
|
+
required: ['query'],
|
|
179
|
+
},
|
|
180
|
+
},
|
|
162
181
|
];
|
|
163
182
|
|
|
164
183
|
const handlers = {
|
|
@@ -231,6 +250,15 @@ const handlers = {
|
|
|
231
250
|
sortType: args.sort_type,
|
|
232
251
|
expandMergeForward: args.expand_merge_forward !== false,
|
|
233
252
|
};
|
|
253
|
+
// v1.3.12: via_user opt-in routing override. true=skip bot (UAT only),
|
|
254
|
+
// false=skip UAT (bot only / no fallback), undefined=default auto-fallback.
|
|
255
|
+
// Set `via: 'user'` explicitly so readMessagesWithFallback labels the
|
|
256
|
+
// response data.via = 'user' (distinguishing intentional UAT route from
|
|
257
|
+
// the auto-fallback case where 'bot' is the default label).
|
|
258
|
+
const routingOpts = {};
|
|
259
|
+
if (args.via_user === true) { routingOpts.skipBot = true; routingOpts.via = 'user'; }
|
|
260
|
+
else if (args.via_user === false) routingOpts.skipUat = true;
|
|
261
|
+
|
|
234
262
|
// Get userClient for name resolution fallback (best-effort)
|
|
235
263
|
let uc = null;
|
|
236
264
|
try { uc = await ctx.getUserClient(); } catch (_) {}
|
|
@@ -238,12 +266,17 @@ const handlers = {
|
|
|
238
266
|
// Path A — chat_id that resolves inside bot's / official search scope.
|
|
239
267
|
const resolvedChatId = await chatIdMapper.resolveToOcId(args.chat_id, official);
|
|
240
268
|
if (resolvedChatId) {
|
|
241
|
-
return json(await official.readMessagesWithFallback(resolvedChatId, msgOpts, uc));
|
|
269
|
+
return json(await official.readMessagesWithFallback(resolvedChatId, msgOpts, uc, routingOpts));
|
|
242
270
|
}
|
|
243
271
|
|
|
244
272
|
// Path B — external group discovered only via cookie search_contacts.
|
|
245
273
|
// When we got here the bot definitely can't see it, so skip bot entirely
|
|
246
|
-
// and go straight to UAT with a `contacts` via label.
|
|
274
|
+
// and go straight to UAT with a `contacts` via label. If user explicitly
|
|
275
|
+
// set via_user=false (bot-only), short-circuit with a clear error rather
|
|
276
|
+
// than silently routing through UAT anyway.
|
|
277
|
+
if (args.via_user === false) {
|
|
278
|
+
return text(`Cannot find "${args.chat_id}" via bot, and via_user=false explicitly opts out of UAT fallback. Either omit via_user or set via_user=true.`);
|
|
279
|
+
}
|
|
247
280
|
if (official.hasUAT) {
|
|
248
281
|
if (!uc) try { uc = await ctx.getUserClient(); } catch (_) {}
|
|
249
282
|
const contactChatId = await chatIdMapper.resolveViaContacts(args.chat_id, uc);
|
|
@@ -254,6 +287,21 @@ const handlers = {
|
|
|
254
287
|
|
|
255
288
|
return text(`Cannot resolve "${args.chat_id}" to a chat ID.\nSearched: bot's group list, im.chat.search API, and user contacts (search_contacts).\nTry: provide the oc_xxx or numeric chat ID directly.`);
|
|
256
289
|
},
|
|
290
|
+
|
|
291
|
+
async search_messages(args, ctx) {
|
|
292
|
+
const official = ctx.getOfficialClient();
|
|
293
|
+
const result = await official.searchMessages({
|
|
294
|
+
query: args.query,
|
|
295
|
+
pageSize: args.page_size,
|
|
296
|
+
pageToken: args.page_token,
|
|
297
|
+
chatIds: args.chat_ids,
|
|
298
|
+
fromIds: args.from_ids,
|
|
299
|
+
atUserIds: args.at_user_ids,
|
|
300
|
+
messageTypes: args.message_types,
|
|
301
|
+
fromTypes: args.from_types,
|
|
302
|
+
});
|
|
303
|
+
return json(result);
|
|
304
|
+
},
|
|
257
305
|
};
|
|
258
306
|
|
|
259
307
|
module.exports = { schemas, handlers };
|
|
@@ -294,7 +294,7 @@ const handlers = {
|
|
|
294
294
|
},
|
|
295
295
|
async send_card_as_user(args, ctx) {
|
|
296
296
|
const r = await ctx.getOfficialClient().sendMessageAsBot(args.chat_id, 'interactive', args.card);
|
|
297
|
-
return
|
|
297
|
+
return sendResult(r, { desc: 'Card sent via bot (cookie channel rejects interactive)', viaUser: false });
|
|
298
298
|
},
|
|
299
299
|
};
|
|
300
300
|
|
package/src/utils.js
CHANGED
|
@@ -43,10 +43,93 @@ function fetchWithTimeout(url, init = {}) {
|
|
|
43
43
|
return fetch(url, { ...rest, signal: rest.signal || controller.signal }).finally(() => clearTimeout(timer));
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// LRU cache with TTL. Replaces unbounded `new Map()` in base.js for
|
|
47
|
+
// _userNameCache / _appNameCache (v1.3.12). Insertion order in a JS Map gives
|
|
48
|
+
// us LRU for free — re-insertion (delete + set) moves a key to "newest".
|
|
49
|
+
class LRUCache {
|
|
50
|
+
constructor({ max = 500, ttlMs = 600_000 } = {}) {
|
|
51
|
+
if (!Number.isFinite(max) || max <= 0) throw new Error('LRUCache: max must be positive');
|
|
52
|
+
if (!Number.isFinite(ttlMs) || ttlMs <= 0) throw new Error('LRUCache: ttlMs must be positive');
|
|
53
|
+
this._max = max;
|
|
54
|
+
this._ttlMs = ttlMs;
|
|
55
|
+
this._map = new Map(); // key → { value, expiresAt }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
_isExpired(entry) {
|
|
59
|
+
return entry.expiresAt <= Date.now();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
get(key) {
|
|
63
|
+
const entry = this._map.get(key);
|
|
64
|
+
if (!entry) return undefined;
|
|
65
|
+
if (this._isExpired(entry)) {
|
|
66
|
+
this._map.delete(key);
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
// Bump recency: re-insert to move to tail.
|
|
70
|
+
this._map.delete(key);
|
|
71
|
+
this._map.set(key, entry);
|
|
72
|
+
return entry.value;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
has(key) {
|
|
76
|
+
const entry = this._map.get(key);
|
|
77
|
+
if (!entry) return false;
|
|
78
|
+
if (this._isExpired(entry)) {
|
|
79
|
+
this._map.delete(key);
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
set(key, value) {
|
|
86
|
+
// If the key exists, delete first so re-insert puts it at the tail.
|
|
87
|
+
if (this._map.has(key)) this._map.delete(key);
|
|
88
|
+
this._map.set(key, { value, expiresAt: Date.now() + this._ttlMs });
|
|
89
|
+
while (this._map.size > this._max) {
|
|
90
|
+
const oldest = this._map.keys().next().value;
|
|
91
|
+
this._map.delete(oldest);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
delete(key) { return this._map.delete(key); }
|
|
96
|
+
|
|
97
|
+
clear() { this._map.clear(); }
|
|
98
|
+
|
|
99
|
+
get size() { return this._map.size; }
|
|
100
|
+
|
|
101
|
+
// Map-compatible iteration. Skips expired entries so callers don't observe
|
|
102
|
+
// stale data via spread / for-of. Order follows the underlying Map's
|
|
103
|
+
// insertion order (= LRU recency order, oldest first).
|
|
104
|
+
*entries() {
|
|
105
|
+
for (const [key, entry] of this._map) {
|
|
106
|
+
if (this._isExpired(entry)) continue;
|
|
107
|
+
yield [key, entry.value];
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
*keys() {
|
|
112
|
+
for (const [key, entry] of this._map) {
|
|
113
|
+
if (this._isExpired(entry)) continue;
|
|
114
|
+
yield key;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
*values() {
|
|
119
|
+
for (const [, entry] of this._map) {
|
|
120
|
+
if (this._isExpired(entry)) continue;
|
|
121
|
+
yield entry.value;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
[Symbol.iterator]() { return this.entries(); }
|
|
126
|
+
}
|
|
127
|
+
|
|
46
128
|
module.exports = {
|
|
47
129
|
generateRequestId,
|
|
48
130
|
generateCid,
|
|
49
131
|
parseCookie,
|
|
50
132
|
formatCookie,
|
|
51
133
|
fetchWithTimeout,
|
|
134
|
+
LRUCache,
|
|
52
135
|
};
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Render docs/og.svg → docs/og.png at 1200x630.
|
|
3
|
-
//
|
|
4
|
-
// Idempotent. Run `node scripts/generate-og-image.js` after editing
|
|
5
|
-
// docs/og.svg. Commit both the SVG (source) and the PNG (asset used
|
|
6
|
-
// by social-media unfurls and `<meta property="og:image">`).
|
|
7
|
-
//
|
|
8
|
-
// Why PNG: Twitter / WeChat / 飞书 unfurls don't render SVG `og:image`.
|
|
9
|
-
|
|
10
|
-
'use strict';
|
|
11
|
-
|
|
12
|
-
const fs = require('fs');
|
|
13
|
-
const path = require('path');
|
|
14
|
-
const { Resvg } = require('@resvg/resvg-js');
|
|
15
|
-
|
|
16
|
-
const svgPath = path.join(__dirname, '..', 'docs', 'og.svg');
|
|
17
|
-
const pngPath = path.join(__dirname, '..', 'docs', 'og.png');
|
|
18
|
-
|
|
19
|
-
const svg = fs.readFileSync(svgPath, 'utf8');
|
|
20
|
-
|
|
21
|
-
const resvg = new Resvg(svg, {
|
|
22
|
-
fitTo: { mode: 'width', value: 1200 },
|
|
23
|
-
// Try to use system fonts so Chinese characters render. resvg-js loads
|
|
24
|
-
// fonts from `font.fontDirs` and `font.defaultFontFamily`. On macOS
|
|
25
|
-
// /System/Library/Fonts has PingFang SC; on Linux CI, jekyll-seo-tag
|
|
26
|
-
// doesn't run this script anyway — only humans do, locally.
|
|
27
|
-
font: {
|
|
28
|
-
fontDirs: ['/System/Library/Fonts', '/Library/Fonts', '/usr/share/fonts'],
|
|
29
|
-
loadSystemFonts: true,
|
|
30
|
-
defaultFontFamily: 'PingFang SC',
|
|
31
|
-
},
|
|
32
|
-
background: '#0d1117',
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
const pngBuffer = resvg.render().asPng();
|
|
36
|
-
fs.writeFileSync(pngPath, pngBuffer);
|
|
37
|
-
|
|
38
|
-
const sizeKb = (pngBuffer.length / 1024).toFixed(1);
|
|
39
|
-
console.log(`OK: wrote ${pngPath} (${pngBuffer.length} bytes / ${sizeKb} KiB)`);
|