feishu-user-plugin 1.3.11 → 1.3.13
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 +2 -2
- package/.mcpb/manifest.json +3 -3
- package/CHANGELOG.md +159 -8
- package/README.en.md +130 -413
- package/README.md +69 -259
- package/package.json +2 -2
- package/scripts/check-description-drift.js +73 -0
- package/scripts/check-docs-sync.js +7 -16
- package/scripts/check-scopes.js +99 -0
- package/scripts/check-tool-count.js +4 -3
- package/scripts/sync-claude-md.sh +3 -4
- 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/identity-state.js +209 -0
- package/src/auth/uat.js +49 -35
- package/src/cli.js +87 -0
- package/src/clients/official/base.js +170 -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 +65 -14
- package/src/server.js +76 -37
- package/src/test-all.js +41 -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 +177 -0
- package/src/test-lark-desktop.js +1 -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/skills/feishu-user-plugin/references/CLAUDE.md +0 -524
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// src/test-populate-sender-names.js — verify _populateSenderNames now reads
|
|
2
|
+
// Promise.allSettled results instead of discarding them.
|
|
3
|
+
//
|
|
4
|
+
// Pre-v1.3.12 bug: base.js used `await Promise.allSettled([...]map(getUserById))`
|
|
5
|
+
// without reading result.status — every failed contact lookup vanished into
|
|
6
|
+
// the void. We track failedIds and log a single stderr line per call so
|
|
7
|
+
// long-running diagnosis can grep for "[populate_sender_names]".
|
|
8
|
+
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const assert = require('node:assert/strict');
|
|
12
|
+
const { LarkOfficialClient } = require('./clients/official');
|
|
13
|
+
|
|
14
|
+
function captureStderr(fn) {
|
|
15
|
+
const original = console.error;
|
|
16
|
+
const lines = [];
|
|
17
|
+
console.error = (...args) => lines.push(args.join(' '));
|
|
18
|
+
return Promise.resolve(fn()).finally(() => { console.error = original; });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function run() {
|
|
22
|
+
const c = new LarkOfficialClient('cli_test', 'fake_secret');
|
|
23
|
+
|
|
24
|
+
// Stub network probes — _resolveSelfTenantKey hits Feishu via fetchWithTimeout,
|
|
25
|
+
// we don't want that in a unit test.
|
|
26
|
+
c._resolveSelfTenantKey = async () => 'tenant_self';
|
|
27
|
+
c._selfTenantKey = 'tenant_self';
|
|
28
|
+
|
|
29
|
+
// --- Case 1: all lookups succeed → no stderr complaint ---
|
|
30
|
+
c.getUserById = async (id) => {
|
|
31
|
+
c._userNameCache.set(id, `user_${id.slice(-2)}`);
|
|
32
|
+
};
|
|
33
|
+
c.getAppName = async () => null;
|
|
34
|
+
|
|
35
|
+
let stderrLines;
|
|
36
|
+
stderrLines = [];
|
|
37
|
+
await captureStderr(async () => {
|
|
38
|
+
const items = [
|
|
39
|
+
{ senderId: 'ou_aa', senderType: 'user' },
|
|
40
|
+
{ senderId: 'ou_bb', senderType: 'user' },
|
|
41
|
+
];
|
|
42
|
+
await c._populateSenderNames(items, null);
|
|
43
|
+
assert.equal(items[0].senderName, 'user_aa');
|
|
44
|
+
assert.equal(items[1].senderName, 'user_bb');
|
|
45
|
+
}).then(() => null).catch(e => stderrLines.push(`THREW ${e.message}`));
|
|
46
|
+
|
|
47
|
+
// --- Case 2: one lookup throws → failedIds logged once ---
|
|
48
|
+
c._userNameCache.clear();
|
|
49
|
+
c.getUserById = async (id) => {
|
|
50
|
+
if (id === 'ou_bad') throw new Error('contact api 70009');
|
|
51
|
+
c._userNameCache.set(id, `user_${id.slice(-2)}`);
|
|
52
|
+
};
|
|
53
|
+
const captured = [];
|
|
54
|
+
const originalErr = console.error;
|
|
55
|
+
console.error = (...args) => captured.push(args.join(' '));
|
|
56
|
+
try {
|
|
57
|
+
const items = [
|
|
58
|
+
{ senderId: 'ou_aa', senderType: 'user' },
|
|
59
|
+
{ senderId: 'ou_bad', senderType: 'user' },
|
|
60
|
+
];
|
|
61
|
+
await c._populateSenderNames(items, null);
|
|
62
|
+
assert.equal(items[0].senderName, 'user_aa');
|
|
63
|
+
assert.equal(items[1].senderName, null, 'failed lookup leaves senderName null');
|
|
64
|
+
assert.equal(items[1].displayLabel, '(ou_bad)', 'displayLabel uses raw id fallback');
|
|
65
|
+
} finally {
|
|
66
|
+
console.error = originalErr;
|
|
67
|
+
}
|
|
68
|
+
const failedLog = captured.find(l => l.includes('[feishu-user-plugin]') && l.includes('sender name lookup'));
|
|
69
|
+
assert.ok(failedLog, 'should log a failed-lookup line to stderr');
|
|
70
|
+
assert.ok(failedLog.includes('ou_bad'), 'failed log should name the failing open_id');
|
|
71
|
+
|
|
72
|
+
// --- Case 3: app name lookup fails → logged with kind=app ---
|
|
73
|
+
c._appNameCache.clear();
|
|
74
|
+
c.getUserById = async (id) => { c._userNameCache.set(id, `u_${id.slice(-2)}`); };
|
|
75
|
+
c.getAppName = async (id) => {
|
|
76
|
+
if (id === 'cli_bad') throw new Error('99991672');
|
|
77
|
+
return null;
|
|
78
|
+
};
|
|
79
|
+
const appCaptured = [];
|
|
80
|
+
console.error = (...args) => appCaptured.push(args.join(' '));
|
|
81
|
+
try {
|
|
82
|
+
const items = [
|
|
83
|
+
{ senderId: 'cli_bad', senderType: 'app' },
|
|
84
|
+
];
|
|
85
|
+
await c._populateSenderNames(items, null);
|
|
86
|
+
assert.equal(items[0].displayLabel, '[Bot] (cli_bad)');
|
|
87
|
+
} finally {
|
|
88
|
+
console.error = originalErr;
|
|
89
|
+
}
|
|
90
|
+
const appLog = appCaptured.find(l => l.includes('app name lookup') || (l.includes('sender name lookup') && l.includes('cli_bad')));
|
|
91
|
+
assert.ok(appLog, 'should log a failed app name lookup');
|
|
92
|
+
assert.ok(appLog.includes('cli_bad'));
|
|
93
|
+
|
|
94
|
+
console.log('populate-sender-names.js: PASS');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (require.main === module) run().catch(e => { console.error(e); process.exit(1); });
|
|
98
|
+
module.exports = { run };
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// src/test-search-messages.js — fixture test for v1.3.12 search_messages.
|
|
2
|
+
//
|
|
3
|
+
// search_messages wraps POST /open-apis/search/v2/message (UAT-only).
|
|
4
|
+
// Live calls require the `search:message` scope; this test mocks
|
|
5
|
+
// _uatREST directly to verify request shape + response handling +
|
|
6
|
+
// error classification without a real Feishu round-trip.
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
const assert = require('node:assert/strict');
|
|
11
|
+
const { LarkOfficialClient } = require('./clients/official');
|
|
12
|
+
|
|
13
|
+
async function run() {
|
|
14
|
+
const c = new LarkOfficialClient('cli_test', 'fake_secret');
|
|
15
|
+
// Make hasUAT truthy so the early-throw doesn't fire.
|
|
16
|
+
c._uat = 'fake_uat';
|
|
17
|
+
c._uatRefresh = 'fake_refresh';
|
|
18
|
+
c._uatExpires = Math.floor(Date.now() / 1000) + 3600;
|
|
19
|
+
|
|
20
|
+
// --- 1. happy path: returns items + pageToken + hasMore ---
|
|
21
|
+
c._uatREST = async (method, path, opts) => {
|
|
22
|
+
assert.equal(method, 'POST');
|
|
23
|
+
assert.equal(path, '/open-apis/search/v2/message');
|
|
24
|
+
assert.equal(opts.body.query, 'hello');
|
|
25
|
+
return {
|
|
26
|
+
code: 0,
|
|
27
|
+
data: {
|
|
28
|
+
items: [
|
|
29
|
+
{ message_id: 'om_a', chat_id: 'oc_x' },
|
|
30
|
+
{ message_id: 'om_b', chat_id: 'oc_y' },
|
|
31
|
+
],
|
|
32
|
+
page_token: 'next_xyz',
|
|
33
|
+
has_more: true,
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
let result = await c.searchMessages({ query: 'hello', pageSize: 10 });
|
|
38
|
+
assert.equal(result.items.length, 2);
|
|
39
|
+
assert.equal(result.pageToken, 'next_xyz');
|
|
40
|
+
assert.equal(result.hasMore, true);
|
|
41
|
+
|
|
42
|
+
// --- 2. filter knobs propagate into body ---
|
|
43
|
+
let captured;
|
|
44
|
+
c._uatREST = async (method, path, opts) => {
|
|
45
|
+
captured = opts.body;
|
|
46
|
+
return { code: 0, data: { items: [], page_token: null, has_more: false } };
|
|
47
|
+
};
|
|
48
|
+
await c.searchMessages({
|
|
49
|
+
query: 'q',
|
|
50
|
+
chatIds: ['oc_a', 'oc_b'],
|
|
51
|
+
fromIds: ['ou_x'],
|
|
52
|
+
atUserIds: ['ou_at'],
|
|
53
|
+
messageTypes: ['text', 'post'],
|
|
54
|
+
fromTypes: ['user'],
|
|
55
|
+
});
|
|
56
|
+
assert.deepEqual(captured.chat_ids, ['oc_a', 'oc_b']);
|
|
57
|
+
assert.deepEqual(captured.from_ids, ['ou_x']);
|
|
58
|
+
assert.deepEqual(captured.at_chatter_ids, ['ou_at']);
|
|
59
|
+
assert.deepEqual(captured.message_type_list, ['text', 'post']);
|
|
60
|
+
assert.deepEqual(captured.from_types, ['user']);
|
|
61
|
+
|
|
62
|
+
// --- 3. 99991679 → throws with scope guidance ---
|
|
63
|
+
c._uatREST = async () => ({ code: 99991679, msg: 'Unauthorized. required: search:message' });
|
|
64
|
+
let threw;
|
|
65
|
+
try { await c.searchMessages({ query: 'x' }); }
|
|
66
|
+
catch (e) { threw = e; }
|
|
67
|
+
assert.ok(threw);
|
|
68
|
+
assert.ok(threw.message.includes('search:message'));
|
|
69
|
+
assert.ok(threw.message.includes('npx feishu-user-plugin oauth'));
|
|
70
|
+
|
|
71
|
+
// --- 4. other non-zero code → wrapped throw ---
|
|
72
|
+
c._uatREST = async () => ({ code: 42101, msg: 'rate limited' });
|
|
73
|
+
threw = undefined;
|
|
74
|
+
try { await c.searchMessages({ query: 'x' }); }
|
|
75
|
+
catch (e) { threw = e; }
|
|
76
|
+
assert.ok(threw);
|
|
77
|
+
assert.ok(threw.message.includes('42101'));
|
|
78
|
+
|
|
79
|
+
// --- 5. missing query → input error before any API call ---
|
|
80
|
+
c._uatREST = async () => { throw new Error('should not be called'); };
|
|
81
|
+
threw = undefined;
|
|
82
|
+
try { await c.searchMessages({}); }
|
|
83
|
+
catch (e) { threw = e; }
|
|
84
|
+
assert.ok(threw);
|
|
85
|
+
assert.ok(threw.message.includes('query'));
|
|
86
|
+
|
|
87
|
+
// --- 6. no UAT → throws with oauth pointer ---
|
|
88
|
+
const c2 = new LarkOfficialClient('cli_test', 'fake_secret');
|
|
89
|
+
// hasUAT will be false (no _uat).
|
|
90
|
+
threw = undefined;
|
|
91
|
+
try { await c2.searchMessages({ query: 'x' }); }
|
|
92
|
+
catch (e) { threw = e; }
|
|
93
|
+
assert.ok(threw);
|
|
94
|
+
assert.ok(threw.message.includes('UAT'));
|
|
95
|
+
assert.ok(threw.message.includes('npx feishu-user-plugin oauth'));
|
|
96
|
+
|
|
97
|
+
console.log('search-messages.js: PASS');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (require.main === module) run().catch(e => { console.error(e); process.exit(1); });
|
|
101
|
+
module.exports = { run };
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// src/test-send-shape.js — verify all 8 send_*_as_user tool handlers
|
|
2
|
+
// return the v1.3.12 unified shape: {ok, viaUser, fallbackWarning?, messageId?}
|
|
3
|
+
//
|
|
4
|
+
// Pre-v1.3.12 the user-identity send tools returned plain text
|
|
5
|
+
// ("Text sent as user to oc_xxx"); send_card_as_user already returned
|
|
6
|
+
// a bot-path messageId text. The new shape is JSON inside an MCP text
|
|
7
|
+
// content block so the LLM can read ok / viaUser / messageId structurally
|
|
8
|
+
// without regex.
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const assert = require('node:assert/strict');
|
|
13
|
+
const { handlers: msgHandlers } = require('./tools/messaging-user');
|
|
14
|
+
|
|
15
|
+
// Parse a tool MCP response { content: [{type:'text', text: '...'}] } where
|
|
16
|
+
// the text is JSON. Returns the parsed object, or null if the text isn't JSON
|
|
17
|
+
// (e.g. disambiguation message from send_to_user with multiple matches).
|
|
18
|
+
function parseJsonResponse(resp) {
|
|
19
|
+
const t = resp?.content?.[0]?.text;
|
|
20
|
+
if (typeof t !== 'string') return null;
|
|
21
|
+
try { return JSON.parse(t); } catch (_) { return null; }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Stub MCP ctx with controllable per-call behavior.
|
|
25
|
+
function fakeCtx({ sendImpl, botSendImpl } = {}) {
|
|
26
|
+
const userClient = {
|
|
27
|
+
sendMessage: async (chat, text /* , opts */) => sendImpl({ chat, text, kind: 'text' }),
|
|
28
|
+
sendImage: async (chat, key /* , opts */) => sendImpl({ chat, key, kind: 'image' }),
|
|
29
|
+
sendFile: async (chat, key, name /* , opts */) => sendImpl({ chat, key, name, kind: 'file' }),
|
|
30
|
+
sendPost: async (chat, title, paragraphs /* , opts */) => sendImpl({ chat, title, paragraphs, kind: 'post' }),
|
|
31
|
+
search: async () => [], // tests below avoid send_to_user / send_to_group multi-match
|
|
32
|
+
createChat: async () => 'oc_fake',
|
|
33
|
+
};
|
|
34
|
+
return {
|
|
35
|
+
getUserClient: async () => userClient,
|
|
36
|
+
getOfficialClient: () => ({
|
|
37
|
+
sendMessageAsBot: async (chat, msgType, payload) => botSendImpl({ chat, msgType, payload }),
|
|
38
|
+
}),
|
|
39
|
+
resolveDocId: async (x) => x,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function run() {
|
|
44
|
+
// --- 1. send_as_user → ok / viaUser=true / status passed through ---
|
|
45
|
+
{
|
|
46
|
+
const ctx = fakeCtx({
|
|
47
|
+
sendImpl: async () => ({ success: true, status: 0 }),
|
|
48
|
+
});
|
|
49
|
+
const resp = await msgHandlers.send_as_user({ chat_id: '7234567890123', text: 'hi' }, ctx);
|
|
50
|
+
const parsed = parseJsonResponse(resp);
|
|
51
|
+
assert.ok(parsed, 'send_as_user should return JSON-parseable shape');
|
|
52
|
+
assert.equal(parsed.ok, true, 'ok flag present and true on success');
|
|
53
|
+
assert.equal(parsed.viaUser, true, 'cookie/user path → viaUser=true');
|
|
54
|
+
assert.equal(parsed.fallbackWarning, undefined);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// --- 2. send_as_user failure → ok=false ---
|
|
58
|
+
{
|
|
59
|
+
const ctx = fakeCtx({
|
|
60
|
+
sendImpl: async () => ({ success: false, status: 70003 }),
|
|
61
|
+
});
|
|
62
|
+
const resp = await msgHandlers.send_as_user({ chat_id: '7234567890123', text: 'hi' }, ctx);
|
|
63
|
+
const parsed = parseJsonResponse(resp);
|
|
64
|
+
assert.ok(parsed);
|
|
65
|
+
assert.equal(parsed.ok, false, 'failed send → ok=false');
|
|
66
|
+
assert.equal(parsed.viaUser, true);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// --- 3. send_image_as_user, send_file_as_user, send_post_as_user — same shape ---
|
|
70
|
+
for (const [name, args] of [
|
|
71
|
+
['send_image_as_user', { chat_id: '7234567890123', image_key: 'img_xxx' }],
|
|
72
|
+
['send_file_as_user', { chat_id: '7234567890123', file_key: 'file_xxx', file_name: 'a.pdf' }],
|
|
73
|
+
['send_post_as_user', { chat_id: '7234567890123', title: 'T', paragraphs: [] }],
|
|
74
|
+
]) {
|
|
75
|
+
const ctx = fakeCtx({
|
|
76
|
+
sendImpl: async () => ({ success: true, status: 0 }),
|
|
77
|
+
});
|
|
78
|
+
const resp = await msgHandlers[name](args, ctx);
|
|
79
|
+
const parsed = parseJsonResponse(resp);
|
|
80
|
+
assert.ok(parsed, `${name} should return JSON shape`);
|
|
81
|
+
assert.equal(parsed.ok, true, `${name} ok=true on success`);
|
|
82
|
+
assert.equal(parsed.viaUser, true, `${name} viaUser=true`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// --- 4. send_card_as_user → viaUser=false + messageId ---
|
|
86
|
+
{
|
|
87
|
+
const ctx = fakeCtx({
|
|
88
|
+
botSendImpl: async () => ({ messageId: 'om_card_123' }),
|
|
89
|
+
});
|
|
90
|
+
const resp = await msgHandlers.send_card_as_user({ chat_id: '7234567890123', card: {} }, ctx);
|
|
91
|
+
const parsed = parseJsonResponse(resp);
|
|
92
|
+
assert.ok(parsed, 'send_card_as_user JSON');
|
|
93
|
+
assert.equal(parsed.ok, true);
|
|
94
|
+
assert.equal(parsed.viaUser, false, 'card path goes via bot → viaUser=false');
|
|
95
|
+
assert.equal(parsed.messageId, 'om_card_123');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// --- 5. unified shape: no extra fields beyond {ok, viaUser, description?, fallbackWarning?, messageId?, status?} ---
|
|
99
|
+
{
|
|
100
|
+
const ctx = fakeCtx({
|
|
101
|
+
sendImpl: async () => ({ success: true, status: 0 }),
|
|
102
|
+
});
|
|
103
|
+
const resp = await msgHandlers.send_as_user({ chat_id: '7234567890123', text: 'hi' }, ctx);
|
|
104
|
+
const parsed = parseJsonResponse(resp);
|
|
105
|
+
const allowed = new Set(['ok', 'viaUser', 'description', 'fallbackWarning', 'messageId', 'status']);
|
|
106
|
+
for (const k of Object.keys(parsed)) {
|
|
107
|
+
assert.ok(allowed.has(k), `send_as_user returned unexpected key '${k}' — unified shape allows only ${[...allowed].join(', ')}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
console.log('send-shape.js: PASS');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (require.main === module) run().catch(e => { console.error(e); process.exit(1); });
|
|
115
|
+
module.exports = { run };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// src/test-via-user.js — verify the v1.3.12 `via_user` param on
|
|
2
|
+
// read_messages controls bot/UAT routing.
|
|
3
|
+
//
|
|
4
|
+
// Pre-v1.3.12 read_messages auto-failed bot → UAT (skipBot true only when
|
|
5
|
+
// chat resolved via cookie search_contacts). No way for the LLM/user to
|
|
6
|
+
// say "I know this is mine, go UAT first" or "I want only the bot view".
|
|
7
|
+
//
|
|
8
|
+
// New: read_messages accepts `via_user: boolean`.
|
|
9
|
+
// - via_user=true → skip bot, go straight to UAT (alias of skipBot)
|
|
10
|
+
// - via_user=false → bot-only, NEVER fall back to UAT (skipUat new)
|
|
11
|
+
// - undefined → existing auto-fallback (default)
|
|
12
|
+
//
|
|
13
|
+
// Test stubs readMessagesWithFallback directly to count which paths fire.
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const assert = require('node:assert/strict');
|
|
18
|
+
const { handlers } = require('./tools/im-read');
|
|
19
|
+
|
|
20
|
+
function fakeCtx({ fallbackImpl, asUserImpl }) {
|
|
21
|
+
let lastOpts;
|
|
22
|
+
const official = {
|
|
23
|
+
hasUAT: true,
|
|
24
|
+
readMessagesWithFallback: async (chatId, msgOpts, uc, opts) => {
|
|
25
|
+
lastOpts = opts;
|
|
26
|
+
return fallbackImpl({ chatId, msgOpts, opts });
|
|
27
|
+
},
|
|
28
|
+
readMessagesAsUser: async (chatId, msgOpts, uc) => {
|
|
29
|
+
lastOpts = { via: 'user' };
|
|
30
|
+
return asUserImpl ? asUserImpl({ chatId, msgOpts }) : { items: [], via: 'user' };
|
|
31
|
+
},
|
|
32
|
+
getChatInfo: async () => ({ name: 'fake group' }),
|
|
33
|
+
};
|
|
34
|
+
return {
|
|
35
|
+
getOfficialClient: () => official,
|
|
36
|
+
getUserClient: async () => ({
|
|
37
|
+
search: async () => [],
|
|
38
|
+
}),
|
|
39
|
+
_getLastOpts: () => lastOpts,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function run() {
|
|
44
|
+
// --- 1. via_user=true → skipBot ---
|
|
45
|
+
{
|
|
46
|
+
const ctx = fakeCtx({
|
|
47
|
+
fallbackImpl: ({ opts }) => ({ items: [], opts }),
|
|
48
|
+
});
|
|
49
|
+
const resp = await handlers.read_messages({ chat_id: 'oc_x', via_user: true }, ctx);
|
|
50
|
+
const opts = ctx._getLastOpts();
|
|
51
|
+
assert.ok(opts);
|
|
52
|
+
assert.equal(opts.skipBot, true, 'via_user=true → skipBot=true');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// --- 2. via_user=false → skipUat ---
|
|
56
|
+
{
|
|
57
|
+
const ctx = fakeCtx({
|
|
58
|
+
fallbackImpl: ({ opts }) => ({ items: [], opts }),
|
|
59
|
+
});
|
|
60
|
+
const resp = await handlers.read_messages({ chat_id: 'oc_x', via_user: false }, ctx);
|
|
61
|
+
const opts = ctx._getLastOpts();
|
|
62
|
+
assert.ok(opts);
|
|
63
|
+
assert.equal(opts.skipUat, true, 'via_user=false → skipUat=true');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// --- 3. via_user undefined → default auto-fallback (no skip flags) ---
|
|
67
|
+
{
|
|
68
|
+
const ctx = fakeCtx({
|
|
69
|
+
fallbackImpl: ({ opts }) => ({ items: [], opts }),
|
|
70
|
+
});
|
|
71
|
+
const resp = await handlers.read_messages({ chat_id: 'oc_x' }, ctx);
|
|
72
|
+
const opts = ctx._getLastOpts();
|
|
73
|
+
// Either opts is undefined or both skip flags absent — both mean auto-fallback.
|
|
74
|
+
assert.ok(!opts || !opts.skipBot, 'no via_user → no skipBot (auto)');
|
|
75
|
+
assert.ok(!opts || !opts.skipUat, 'no via_user → no skipUat (auto)');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// --- 4. read_messages schema has via_user param ---
|
|
79
|
+
{
|
|
80
|
+
const { schemas } = require('./tools/im-read');
|
|
81
|
+
const readMessages = schemas.find(s => s.name === 'read_messages');
|
|
82
|
+
assert.ok(readMessages);
|
|
83
|
+
assert.ok(readMessages.inputSchema.properties.via_user,
|
|
84
|
+
'read_messages inputSchema should have via_user property');
|
|
85
|
+
assert.equal(readMessages.inputSchema.properties.via_user.type, 'boolean');
|
|
86
|
+
assert.ok(readMessages.inputSchema.properties.via_user.description.length > 30,
|
|
87
|
+
'via_user description should explain the routing semantics');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
console.log('via-user.js: PASS');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (require.main === module) run().catch(e => { console.error(e); process.exit(1); });
|
|
94
|
+
module.exports = { run };
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// src/test-with-uat-retry.js — unit test for the v1.3.12 widening of
|
|
2
|
+
// `withUAT` in src/auth/uat.js.
|
|
3
|
+
//
|
|
4
|
+
// Background: pre-v1.3.12 `withUAT` only retried fn() when the response
|
|
5
|
+
// carried code in {99991668, 99991663, 99991677} (it refreshed the UAT and
|
|
6
|
+
// re-ran). Anything else — network blip, truncated JSON body, ECONNRESET —
|
|
7
|
+
// bubbled straight out, even though one retry would have cleared the failure.
|
|
8
|
+
//
|
|
9
|
+
// New behaviour:
|
|
10
|
+
// 1. If fn() throws AND classifyError says action='retry', call fn() one
|
|
11
|
+
// more time with the *same* uat (it's an upstream flake, not an auth
|
|
12
|
+
// issue — refreshing wouldn't help).
|
|
13
|
+
// 2. Existing 99991668 / 99991663 / 99991677 path is unchanged: refresh
|
|
14
|
+
// then re-run.
|
|
15
|
+
// 3. Otherwise (success, or non-retriable code) return data as-is.
|
|
16
|
+
//
|
|
17
|
+
// Real Feishu API is NOT touched — we mock fn() directly.
|
|
18
|
+
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
const assert = require('node:assert/strict');
|
|
22
|
+
const { withUAT } = require('./auth/uat');
|
|
23
|
+
|
|
24
|
+
function fakeClient() {
|
|
25
|
+
const farFuture = Math.floor(Date.now() / 1000) + 3600;
|
|
26
|
+
return {
|
|
27
|
+
appId: 'cli_test',
|
|
28
|
+
appSecret: 'secret',
|
|
29
|
+
_uat: 'fake_token',
|
|
30
|
+
_uatRefresh: 'fake_refresh',
|
|
31
|
+
_uatExpires: farFuture,
|
|
32
|
+
get hasUAT() { return !!this._uat; },
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function run() {
|
|
37
|
+
// --- 1. Success on first try → no retry ---
|
|
38
|
+
{
|
|
39
|
+
let calls = 0;
|
|
40
|
+
const data = await withUAT(fakeClient(), async () => {
|
|
41
|
+
calls++;
|
|
42
|
+
return { code: 0, data: { ok: true } };
|
|
43
|
+
});
|
|
44
|
+
assert.equal(calls, 1);
|
|
45
|
+
assert.equal(data.code, 0);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// --- 2. Throws network error → retries once, succeeds ---
|
|
49
|
+
{
|
|
50
|
+
let calls = 0;
|
|
51
|
+
const data = await withUAT(fakeClient(), async () => {
|
|
52
|
+
calls++;
|
|
53
|
+
if (calls === 1) throw new Error('fetch timeout after 10000ms');
|
|
54
|
+
return { code: 0, data: { ok: true } };
|
|
55
|
+
});
|
|
56
|
+
assert.equal(calls, 2, 'should retry transient throw once');
|
|
57
|
+
assert.equal(data.code, 0);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// --- 3. Throws JSON parse error → retries once ---
|
|
61
|
+
{
|
|
62
|
+
let calls = 0;
|
|
63
|
+
const data = await withUAT(fakeClient(), async () => {
|
|
64
|
+
calls++;
|
|
65
|
+
if (calls === 1) {
|
|
66
|
+
const err = new SyntaxError('Unexpected end of JSON input');
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
return { code: 0, data: { ok: 'parsed' } };
|
|
70
|
+
});
|
|
71
|
+
assert.equal(calls, 2);
|
|
72
|
+
assert.equal(data.data.ok, 'parsed');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// --- 4. Throws non-retriable error → re-throws (no retry) ---
|
|
76
|
+
{
|
|
77
|
+
let calls = 0;
|
|
78
|
+
let caught;
|
|
79
|
+
try {
|
|
80
|
+
await withUAT(fakeClient(), async () => {
|
|
81
|
+
calls++;
|
|
82
|
+
throw new Error('something completely unexpected');
|
|
83
|
+
});
|
|
84
|
+
} catch (e) { caught = e; }
|
|
85
|
+
assert.equal(calls, 1, 'unknown error should not retry');
|
|
86
|
+
assert.ok(caught);
|
|
87
|
+
assert.ok(caught.message.includes('something completely unexpected'));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// --- 5. Throws transient on BOTH attempts → re-throws ---
|
|
91
|
+
{
|
|
92
|
+
let calls = 0;
|
|
93
|
+
let caught;
|
|
94
|
+
try {
|
|
95
|
+
await withUAT(fakeClient(), async () => {
|
|
96
|
+
calls++;
|
|
97
|
+
throw new Error('fetch timeout after 10000ms');
|
|
98
|
+
});
|
|
99
|
+
} catch (e) { caught = e; }
|
|
100
|
+
assert.equal(calls, 2, 'should give up after one retry');
|
|
101
|
+
assert.ok(caught);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// --- 6. ECONNRESET pattern → retry ---
|
|
105
|
+
{
|
|
106
|
+
let calls = 0;
|
|
107
|
+
const data = await withUAT(fakeClient(), async () => {
|
|
108
|
+
calls++;
|
|
109
|
+
if (calls === 1) throw new Error('socket hang up ECONNRESET');
|
|
110
|
+
return { code: 0 };
|
|
111
|
+
});
|
|
112
|
+
assert.equal(calls, 2);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// --- 7. Existing auth-code path still works: 99991663 triggers refresh ---
|
|
116
|
+
// We can't easily mock refreshUAT without rewiring; verify the data path
|
|
117
|
+
// for the simple non-refresh "no auth code" case here (refresh path is
|
|
118
|
+
// exercised by integration tests / real API).
|
|
119
|
+
{
|
|
120
|
+
let calls = 0;
|
|
121
|
+
const data = await withUAT(fakeClient(), async () => {
|
|
122
|
+
calls++;
|
|
123
|
+
return { code: 42101, msg: 'rate limited' };
|
|
124
|
+
});
|
|
125
|
+
// 42101 is not an auth code so withUAT returns it without refresh-retry.
|
|
126
|
+
// (Caller may decide to retry via classifyError separately.)
|
|
127
|
+
assert.equal(calls, 1);
|
|
128
|
+
assert.equal(data.code, 42101);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
console.log('with-uat-retry.js: PASS');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (require.main === module) run().catch(e => { console.error(e); process.exit(1); });
|
|
135
|
+
module.exports = { run };
|
package/src/tools/_registry.js
CHANGED
|
@@ -26,6 +26,29 @@ const json = (o) => {
|
|
|
26
26
|
return text(warn + JSON.stringify(o, null, 2));
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
// sendResult — unified shape for send_*_as_user tools (v1.3.12).
|
|
30
|
+
// Returns JSON inside an MCP text block:
|
|
31
|
+
// { ok, viaUser, description?, status?, messageId?, fallbackWarning? }
|
|
32
|
+
//
|
|
33
|
+
// `r` is the raw response from a Lark client:
|
|
34
|
+
// - LarkUserClient.send* → { success: bool, status: number }
|
|
35
|
+
// - LarkOfficialClient.sendMessageAsBot → { messageId: string }
|
|
36
|
+
//
|
|
37
|
+
// Back-compat signature: `sendResult(r, desc)` still works (desc treated as
|
|
38
|
+
// description). New callers can pass `sendResult(r, { desc, viaUser: false,
|
|
39
|
+
// fallbackWarning })`.
|
|
40
|
+
const sendResult = (r, descOrOpts) => {
|
|
41
|
+
const opts = typeof descOrOpts === 'string' ? { desc: descOrOpts } : (descOrOpts || {});
|
|
42
|
+
const { desc, viaUser = true, fallbackWarning } = opts;
|
|
43
|
+
const out = {
|
|
44
|
+
ok: !!(r && (r.success || r.messageId)),
|
|
45
|
+
viaUser,
|
|
46
|
+
};
|
|
47
|
+
if (desc) out.description = desc;
|
|
48
|
+
if (r?.messageId) out.messageId = r.messageId;
|
|
49
|
+
if (r && typeof r.status !== 'undefined') out.status = r.status;
|
|
50
|
+
if (fallbackWarning) out.fallbackWarning = fallbackWarning;
|
|
51
|
+
return json(out);
|
|
52
|
+
};
|
|
30
53
|
|
|
31
54
|
module.exports = { text, json, sendResult };
|
package/src/tools/calendar.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// get_calendar_event, plus the v1.3.7 write tools:
|
|
5
5
|
// create_calendar_event / update_calendar_event / delete_calendar_event
|
|
6
6
|
// respond_calendar_event / get_freebusy
|
|
7
|
-
// All UAT-first. Write tools require `calendar:calendar.event:
|
|
7
|
+
// All UAT-first. Write tools require `calendar:calendar.event:{create,update,delete,reply}` scope.
|
|
8
8
|
|
|
9
9
|
const { json, text } = require('./_registry');
|
|
10
10
|
|
|
@@ -53,7 +53,7 @@ const schemas = [
|
|
|
53
53
|
},
|
|
54
54
|
{
|
|
55
55
|
name: 'create_calendar_event',
|
|
56
|
-
description: `[Official API + UAT, v1.3.7] Create a new calendar event. Requires \`calendar:calendar.event:
|
|
56
|
+
description: `[Official API + UAT, v1.3.7] Create a new calendar event. Requires \`calendar:calendar.event:create\` scope (re-run \`npx feishu-user-plugin oauth\` after enabling). The current identity (UAT-first) must have writer or owner permission on the calendar.\n\nTime fields: ${TIME_NOTE}`,
|
|
57
57
|
inputSchema: {
|
|
58
58
|
type: 'object',
|
|
59
59
|
properties: {
|
|
@@ -75,7 +75,7 @@ const schemas = [
|
|
|
75
75
|
},
|
|
76
76
|
{
|
|
77
77
|
name: 'update_calendar_event',
|
|
78
|
-
description: '[Official API + UAT, v1.3.7] Patch fields on an existing calendar event. Pass only the fields you want to change. Requires `calendar:calendar.event:
|
|
78
|
+
description: '[Official API + UAT, v1.3.7] Patch fields on an existing calendar event. Pass only the fields you want to change. Requires `calendar:calendar.event:update` scope.',
|
|
79
79
|
inputSchema: {
|
|
80
80
|
type: 'object',
|
|
81
81
|
properties: {
|
|
@@ -98,7 +98,7 @@ const schemas = [
|
|
|
98
98
|
},
|
|
99
99
|
{
|
|
100
100
|
name: 'delete_calendar_event',
|
|
101
|
-
description: '[Official API + UAT, v1.3.7] Delete a calendar event. Requires `calendar:calendar.event:
|
|
101
|
+
description: '[Official API + UAT, v1.3.7] Delete a calendar event. Requires `calendar:calendar.event:delete` scope.',
|
|
102
102
|
inputSchema: {
|
|
103
103
|
type: 'object',
|
|
104
104
|
properties: {
|
|
@@ -112,7 +112,7 @@ const schemas = [
|
|
|
112
112
|
},
|
|
113
113
|
{
|
|
114
114
|
name: 'respond_calendar_event',
|
|
115
|
-
description: '[Official API + UAT, v1.3.7] Respond to an event invitation. The current identity must be in the event\'s attendee list. Requires `calendar:calendar.event:
|
|
115
|
+
description: '[Official API + UAT, v1.3.7] Respond to an event invitation. The current identity must be in the event\'s attendee list. Requires `calendar:calendar.event:reply` scope.',
|
|
116
116
|
inputSchema: {
|
|
117
117
|
type: 'object',
|
|
118
118
|
properties: {
|