feishu-user-plugin 1.3.11 → 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.
Files changed (50) hide show
  1. package/.claude-plugin/plugin.json +2 -2
  2. package/.cursor-plugin/plugin.json +2 -2
  3. package/.mcpb/manifest.json +3 -3
  4. package/CHANGELOG.md +108 -8
  5. package/README.en.md +130 -413
  6. package/README.md +69 -259
  7. package/package.json +2 -2
  8. package/scripts/check-description-drift.js +73 -0
  9. package/scripts/check-docs-sync.js +7 -16
  10. package/scripts/check-scopes.js +99 -0
  11. package/scripts/check-tool-count.js +4 -3
  12. package/scripts/sync-claude-md.sh +3 -4
  13. package/scripts/verify-app-name.js +64 -0
  14. package/skills/feishu-user-plugin/SKILL.md +3 -3
  15. package/skills/feishu-user-plugin/references/search.md +3 -3
  16. package/src/auth/credentials-monitor.js +185 -0
  17. package/src/auth/identity-state.js +204 -0
  18. package/src/auth/uat.js +49 -35
  19. package/src/cli.js +87 -0
  20. package/src/clients/official/base.js +145 -14
  21. package/src/clients/official/calendar.js +3 -1
  22. package/src/clients/official/im.js +76 -2
  23. package/src/clients/official/okr.js +2 -1
  24. package/src/error-codes.js +40 -0
  25. package/src/events/lockfile.js +40 -4
  26. package/src/events/owner.js +11 -2
  27. package/src/index.js +1 -1
  28. package/src/logger.js +11 -5
  29. package/src/oauth.js +46 -10
  30. package/src/server.js +60 -37
  31. package/src/test-all.js +40 -0
  32. package/src/test-cli-tool.js +87 -0
  33. package/src/test-credentials-monitor.js +124 -0
  34. package/src/test-display-label.js +88 -0
  35. package/src/test-error-codes.js +85 -0
  36. package/src/test-identity-state.js +172 -0
  37. package/src/test-lockfile-pid.js +90 -0
  38. package/src/test-lru-cache.js +145 -0
  39. package/src/test-negative-cache.js +85 -0
  40. package/src/test-populate-sender-names.js +98 -0
  41. package/src/test-search-messages.js +101 -0
  42. package/src/test-send-shape.js +115 -0
  43. package/src/test-via-user.js +94 -0
  44. package/src/test-with-uat-retry.js +135 -0
  45. package/src/tools/_registry.js +24 -1
  46. package/src/tools/calendar.js +5 -5
  47. package/src/tools/im-read.js +52 -4
  48. package/src/tools/messaging-user.js +1 -1
  49. package/src/utils.js +83 -0
  50. package/skills/feishu-user-plugin/references/CLAUDE.md +0 -524
@@ -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 };
@@ -26,6 +26,29 @@ const json = (o) => {
26
26
  return text(warn + JSON.stringify(o, null, 2));
27
27
  };
28
28
 
29
- const sendResult = (r, desc) => text(r.success ? desc : `Send failed (status: ${r.status})`);
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 };
@@ -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:write` scope.
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:write\` 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}`,
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:write` scope.',
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:write` scope.',
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:write` scope.',
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: {
@@ -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 text(`Card sent (bot): ${r.messageId}`);
297
+ return sendResult(r, { desc: 'Card sent via bot (cookie channel rejects interactive)', viaUser: false });
298
298
  },
299
299
  };
300
300