feishu-user-plugin 1.3.7 → 1.3.9

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 (54) hide show
  1. package/.claude-plugin/plugin.json +13 -3
  2. package/CHANGELOG.md +87 -0
  3. package/README.md +20 -4
  4. package/package.json +10 -6
  5. package/proto/lark.proto +10 -0
  6. package/scripts/capture-feishu-protobuf.js +86 -0
  7. package/scripts/check-changelog.js +31 -0
  8. package/scripts/check-docs-sync.js +41 -0
  9. package/scripts/check-tool-count.js +32 -7
  10. package/scripts/decode-feishu-protobuf.js +115 -0
  11. package/scripts/explore-card-protobuf.js +144 -0
  12. package/scripts/explore-image-minimize.js +163 -0
  13. package/scripts/generate-release-artifacts.js +318 -0
  14. package/scripts/probe-feishu-docx.js +203 -0
  15. package/scripts/sync-server-json.js +71 -0
  16. package/scripts/sync-team-skills.sh +109 -7
  17. package/scripts/test-wiki-attach-fallback.js +71 -0
  18. package/scripts/test-ws-events.js +84 -0
  19. package/skills/feishu-user-plugin/SKILL.md +77 -5
  20. package/skills/feishu-user-plugin/references/CLAUDE.md +208 -297
  21. package/src/auth/cookie.js +30 -0
  22. package/src/auth/credentials.js +85 -0
  23. package/src/auth/profile-router.js +248 -0
  24. package/src/auth/uat.js +231 -0
  25. package/src/cli.js +86 -42
  26. package/src/clients/official/base.js +12 -248
  27. package/src/clients/user.js +19 -31
  28. package/src/config.js +13 -8
  29. package/src/events/cursor.js +103 -0
  30. package/src/events/event-buffer.js +103 -0
  31. package/src/events/event-log.js +151 -0
  32. package/src/events/index.js +12 -0
  33. package/src/events/lockfile.js +126 -0
  34. package/src/events/owner.js +73 -0
  35. package/src/events/ws-server.js +156 -0
  36. package/src/oauth.js +48 -7
  37. package/src/resolver.js +10 -0
  38. package/src/server.js +285 -3
  39. package/src/setup.js +100 -11
  40. package/src/test-all.js +12 -9
  41. package/src/test-events-cursor.js +56 -0
  42. package/src/test-events-lockfile.js +36 -0
  43. package/src/test-events-log.js +67 -0
  44. package/src/test-events-owner.js +64 -0
  45. package/src/test-fixtures/doc-blocks/sample-1.json +1256 -0
  46. package/src/test-read-doc-markdown.js +61 -0
  47. package/src/test-switch-profile.js +171 -0
  48. package/src/tools/_registry.js +1 -0
  49. package/src/tools/diagnostics.js +10 -3
  50. package/src/tools/docs.js +93 -3
  51. package/src/tools/events.js +174 -0
  52. package/src/tools/messaging-bot.js +2 -3
  53. package/src/tools/messaging-user.js +23 -14
  54. package/src/tools/profile.js +43 -7
@@ -0,0 +1,61 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const assert = require('node:assert/strict');
6
+
7
+ const { handlers } = require('./tools/docs');
8
+
9
+ async function run() {
10
+ const fixturePath = path.join(__dirname, 'test-fixtures', 'doc-blocks', 'sample-1.json');
11
+ if (!fs.existsSync(fixturePath)) {
12
+ console.log('read-doc-markdown: no fixture, skipping');
13
+ return;
14
+ }
15
+ const blocks = JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
16
+
17
+ // feishu-docx is a hard dep in v1.3.9, but the handler has a graceful skip;
18
+ // honour the same pattern so the test passes in lean test envs.
19
+ try { require.resolve('feishu-docx'); } catch (_) {
20
+ console.log('read-doc-markdown: feishu-docx not installed, skipping');
21
+ return;
22
+ }
23
+
24
+ // Mock ctx: passthrough resolveDocId, return fixture from getDocBlocks.
25
+ const ctx = {
26
+ resolveDocId: async (id) => id,
27
+ getOfficialClient: () => ({
28
+ getDocBlocks: async (_id) => ({ items: blocks }),
29
+ }),
30
+ };
31
+
32
+ const result = await handlers.read_doc_markdown({ document_id: 'fixture' }, ctx);
33
+ // handler returns MCP text shape: { content: [{ type: 'text', text: string }] }
34
+ assert.ok(result, 'handler should return a result');
35
+ const md = result.content?.[0]?.text;
36
+ assert.ok(typeof md === 'string', 'handler output should be a markdown string');
37
+ assert.ok(md.length > 0, 'output should be non-empty');
38
+
39
+ // Token saving check
40
+ const jsonSize = JSON.stringify(blocks).length;
41
+ const ratio = md.length / jsonSize;
42
+ if (ratio > 0.6) {
43
+ console.warn(`read-doc-markdown: ratio ${(ratio * 100).toFixed(1)}% > 60% — consider tightening post-processor`);
44
+ }
45
+
46
+ // Spot-check: post-processor MUST have converted inline HTML tags
47
+ // (these are the bugs the post-processor exists to handle — without them,
48
+ // user-facing output is contaminated with <b>, <em>, <del> raw HTML).
49
+ assert.ok(!md.includes('<b>'), 'output should not contain raw <b> tags');
50
+ assert.ok(!md.includes('<em>'), 'output should not contain raw <em> tags');
51
+ assert.ok(!md.includes('<del>'), 'output should not contain raw <del> tags');
52
+ // External links preserved (the file regex must not over-match)
53
+ assert.ok(md.includes('[Anthropic](https://anthropic.com)'), 'external link [Anthropic] should be preserved');
54
+
55
+ console.log(`read-doc-markdown: PASS (ratio ${(ratio * 100).toFixed(1)}%)`);
56
+ }
57
+
58
+ if (require.main === module) {
59
+ run().catch(e => { console.error('read-doc-markdown: FAIL', e); process.exit(1); });
60
+ }
61
+ module.exports = { run };
@@ -0,0 +1,171 @@
1
+ // src/test-switch-profile.js
2
+ // e2e test: validate switch_profile invalidates cached clients + persists active.
3
+ //
4
+ // CAUTION: this test temporarily modifies ~/.feishu-user-plugin/credentials.json.
5
+ // Backup is taken at start and restored at end (try/finally). If the test
6
+ // crashes mid-run, the backup file is named cred-backup-<ts>.json — restore
7
+ // manually if you see one in /tmp/.
8
+
9
+ 'use strict';
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const os = require('os');
14
+ const assert = require('node:assert/strict');
15
+
16
+ const credPath = path.join(os.homedir(), '.feishu-user-plugin', 'credentials.json');
17
+
18
+ function _readJson(p) {
19
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch (_) { return null; }
20
+ }
21
+
22
+ function _writeJson(p, obj) {
23
+ fs.mkdirSync(path.dirname(p), { recursive: true, mode: 0o700 });
24
+ fs.writeFileSync(p, JSON.stringify(obj, null, 2) + '\n', { mode: 0o600 });
25
+ }
26
+
27
+ async function run() {
28
+ // Warn user before doing anything
29
+ console.error('[test-switch-profile] WARNING: temporarily modifying ~/.feishu-user-plugin/credentials.json. Stop running MCP processes (pkill -f feishu-user-plugin) to avoid interference.');
30
+
31
+ const ts = Date.now();
32
+ const backupPath = path.join(os.tmpdir(), `cred-backup-${ts}.json`);
33
+ const originalCanonical = _readJson(credPath);
34
+
35
+ // Backup existing file (or note absence)
36
+ if (originalCanonical) {
37
+ fs.copyFileSync(credPath, backupPath);
38
+ } else {
39
+ _writeJson(backupPath, { absent: true });
40
+ }
41
+ console.error(`[test-switch-profile] backup → ${backupPath}`);
42
+
43
+ try {
44
+ // Write fixture with active=default + two profiles (default + alt)
45
+ // Use user's real default profile if available (so the APP_ID check works),
46
+ // otherwise fall back to dummy. The test only needs a recognisable prefix.
47
+ const baseDefault = (originalCanonical?.profiles?.default) || {
48
+ LARK_APP_ID: 'cli_test_def_xxxxxxxx',
49
+ LARK_APP_SECRET: 'test_secret_def',
50
+ };
51
+
52
+ const dummyAlt = {
53
+ LARK_APP_ID: 'cli_test_alt_xxxxxxxx',
54
+ LARK_APP_SECRET: 'test_secret_alt',
55
+ LARK_COOKIE: 'session=fake_alt_cookie',
56
+ LARK_USER_ACCESS_TOKEN: 'u-fake-alt-uat',
57
+ LARK_USER_REFRESH_TOKEN: 'fake-alt-refresh',
58
+ };
59
+
60
+ const fixture = {
61
+ version: 1,
62
+ active: 'default',
63
+ profiles: { default: baseDefault, alt: dummyAlt },
64
+ profileHints: {},
65
+ };
66
+ _writeJson(credPath, fixture);
67
+
68
+ // Bust require cache so credentials.js re-reads the fresh fixture on next require.
69
+ // Also bust resolver / server in case they cache credential-derived state.
70
+ for (const mod of ['./auth/credentials', './resolver', './tools/profile']) {
71
+ try {
72
+ delete require.cache[require.resolve(mod)];
73
+ } catch (_) { /* module may not be in cache yet — harmless */ }
74
+ }
75
+ // server.js import chain is large; skip busting it to avoid side-effects.
76
+ // We replicate the minimal ctx surface below instead.
77
+
78
+ // ── Minimal ctx (mirrors what src/server.js builds, minus MCP transport) ──
79
+ const credentials = require('./auth/credentials');
80
+ const { LarkUserClient } = require('./clients/user');
81
+ const { LarkOfficialClient } = require('./clients/official');
82
+
83
+ let userClient = null;
84
+ let officialClient = null;
85
+ let currentProfile = credentials.getActiveProfileName();
86
+
87
+ async function getUserClient() {
88
+ if (userClient) return userClient;
89
+ const env = credentials.getActiveProfileEnv(currentProfile);
90
+ // Do NOT call await userClient.init() — that hits the network.
91
+ userClient = new LarkUserClient(env.LARK_COOKIE || 'dummy');
92
+ return userClient;
93
+ }
94
+
95
+ function getOfficialClient() {
96
+ if (officialClient) return officialClient;
97
+ const env = credentials.getActiveProfileEnv(currentProfile);
98
+ officialClient = new LarkOfficialClient(env.LARK_APP_ID, env.LARK_APP_SECRET);
99
+ return officialClient;
100
+ }
101
+
102
+ const ctx = {
103
+ getUserClient,
104
+ getOfficialClient,
105
+ listProfiles: () => credentials.listProfileNames(),
106
+ getActiveProfile: () => currentProfile,
107
+ setActiveProfile: (n) => {
108
+ // Validate profile exists (throws if not)
109
+ credentials.getActiveProfileEnv(n);
110
+ currentProfile = n;
111
+ userClient = null;
112
+ officialClient = null;
113
+ // Persist to credentials.json (already validated above)
114
+ credentials.setActiveProfile(n);
115
+ },
116
+ };
117
+
118
+ const profile = require('./tools/profile');
119
+
120
+ // ── Assertion 1: initial state ──
121
+ assert.equal(currentProfile, 'default', 'initial profile should be "default"');
122
+ const c1 = getOfficialClient();
123
+ // Use baseDefault's actual APP_ID if present, otherwise the dummy prefix
124
+ const expectedDefaultPrefix = baseDefault.LARK_APP_ID || 'cli_test_def_';
125
+ assert.equal(c1.appId, expectedDefaultPrefix, 'first client uses default profile APP_ID');
126
+
127
+ // ── Assertion 2: switch to alt ──
128
+ await profile.handlers.switch_profile({ name: 'alt' }, ctx);
129
+ assert.equal(currentProfile, 'alt', 'currentProfile should be "alt" after switch');
130
+
131
+ // ── Assertion 3: credentials.json::active updated ──
132
+ const fresh = _readJson(credPath);
133
+ assert.equal(fresh.active, 'alt', 'credentials.json::active should be "alt"');
134
+
135
+ // ── Assertion 4: cached client invalidated; next getOfficialClient rebuilds ──
136
+ const c2 = getOfficialClient();
137
+ assert.notEqual(c2, c1, 'client instance should differ after profile switch');
138
+ assert.ok(c2.appId.startsWith('cli_test_alt_'), `alt client APP_ID should start with "cli_test_alt_" (got "${c2.appId}")`);
139
+
140
+ // ── Assertion 5: switch back to default ──
141
+ await profile.handlers.switch_profile({ name: 'default' }, ctx);
142
+ assert.equal(currentProfile, 'default', 'currentProfile should be "default" after switch back');
143
+ const afterSwitch = _readJson(credPath);
144
+ assert.equal(afterSwitch.active, 'default', 'credentials.json::active should be "default" after switch back');
145
+
146
+ const c3 = getOfficialClient();
147
+ assert.notEqual(c3, c2, 'client instance should differ again after switch back');
148
+ assert.equal(c3.appId, expectedDefaultPrefix, 'client after switch-back uses default profile APP_ID');
149
+
150
+ console.log('switch-profile-e2e: PASS');
151
+ } finally {
152
+ // Restore original credentials.json (or remove if it didn't exist)
153
+ if (originalCanonical) {
154
+ _writeJson(credPath, originalCanonical);
155
+ } else {
156
+ try { fs.unlinkSync(credPath); } catch (_) {}
157
+ }
158
+ console.error(`[test-switch-profile] restored credentials.json from ${backupPath}`);
159
+ // Clean up backup on success (it's in /tmp/, so not critical, but tidy)
160
+ try { fs.unlinkSync(backupPath); } catch (_) {}
161
+ }
162
+ }
163
+
164
+ if (require.main === module) {
165
+ run().catch((e) => {
166
+ console.error('switch-profile-e2e: FAIL', e);
167
+ process.exit(1);
168
+ });
169
+ }
170
+
171
+ module.exports = { run };
@@ -7,6 +7,7 @@
7
7
  // the v1.3.7 phase A migration, temporarily in src/index.js) and provides:
8
8
  // - getUserClient(): Promise<LarkUserClient>
9
9
  // - getOfficialClient(): LarkOfficialClient
10
+ // - getEventBuffer(): EventBuffer | null — null when WS isn't running
10
11
  // - resolveDocId(x): Promise<string> — wiki-node / URL → native token
11
12
  // - listProfiles(): string[] — names from LARK_PROFILES_JSON + 'default'
12
13
  // - getActiveProfile():string
@@ -68,17 +68,23 @@ function maybeSave(savePath, base64) {
68
68
  const handlers = {
69
69
  async get_login_status(_args, ctx) {
70
70
  const parts = [];
71
+ parts.push(`Active profile: ${ctx.getActiveProfile()} (available: ${ctx.listProfiles().join(', ')})`);
71
72
  try {
72
73
  const c = await ctx.getUserClient();
73
74
  const status = await c.checkSession();
74
75
  parts.push(`Cookie: ${status.valid ? 'Active' : 'Expired'} (${status.userName || status.userId || 'unknown'})`);
75
76
  parts.push(` ${status.message}`);
76
77
  } catch (e) { parts.push(`Cookie: ${e.message}`); }
77
- const hasApp = !!(process.env.LARK_APP_ID && process.env.LARK_APP_SECRET);
78
+ // v1.3.9: read APP creds via ctx (profile-aware), not process.env directly,
79
+ // so SSOT users (env pointer-only) don't see false "Not set" reports.
80
+ let official, hasApp = false;
81
+ try {
82
+ official = ctx.getOfficialClient();
83
+ hasApp = !!(official.appId && official.appSecret);
84
+ } catch (_) {}
78
85
  if (!hasApp) {
79
86
  parts.push(`App credentials: Not set`);
80
87
  } else {
81
- const official = ctx.getOfficialClient();
82
88
  const probe = await official.verifyApp();
83
89
  if (probe.valid) {
84
90
  const nameBit = probe.appName ? ` "${probe.appName}"` : '';
@@ -87,7 +93,8 @@ const handlers = {
87
93
  parts.push(`App credentials: INVALID — app_id=${probe.appId} rejected by Feishu (${probe.error})`);
88
94
  parts.push(` → Likely wrong/stale APP_ID. Re-run the install prompt from team-skills/plugins/feishu-user-plugin/README.md to get the correct credentials.`);
89
95
  }
90
- if (official.hasUAT) {
96
+ // official.hasUAT (when available)
97
+ if (official && official.hasUAT) {
91
98
  try {
92
99
  await official.listChatsAsUser({ pageSize: 1 });
93
100
  parts.push('User access token: Valid (P2P/group UAT reading enabled)');
package/src/tools/docs.js CHANGED
@@ -1,8 +1,8 @@
1
1
  // src/tools/docs.js — Feishu document operations.
2
2
  //
3
- // 5 tools (was 7 in v1.3.6): search_docs, read_doc, get_doc_blocks, create_doc,
4
- // and the consolidated manage_doc_block (action=create|update|delete) which
5
- // replaces the v1.3.6 trio create_doc_block / update_doc_block / delete_doc_blocks.
3
+ // 6 tools (was 7 in v1.3.6): search_docs, read_doc, get_doc_blocks, create_doc,
4
+ // manage_doc_block (action=create|update|delete, replaces the v1.3.6 trio
5
+ // create_doc_block / update_doc_block / delete_doc_blocks), and read_doc_markdown.
6
6
 
7
7
  const { text, json } = require('./_registry');
8
8
 
@@ -73,6 +73,17 @@ const schemas = [
73
73
  required: ['action', 'document_id'],
74
74
  },
75
75
  },
76
+ {
77
+ name: 'read_doc_markdown',
78
+ description: '[Plugin v1.3.9] Read a Feishu doc as Markdown (vs get_doc_blocks JSON). Saves ~60% tokens for RAG / digest / summarisation use cases. Accepts native docx token, wiki node token, or full Feishu URL. Embedded images / files appear as feishu://image_token/<TOKEN> placeholders — call download_doc_image for the binary if needed.',
79
+ inputSchema: {
80
+ type: 'object',
81
+ properties: {
82
+ document_id: { type: 'string', description: 'docx token / wiki node / full URL' },
83
+ },
84
+ required: ['document_id'],
85
+ },
86
+ },
76
87
  ];
77
88
 
78
89
  function need(arg, name, action) {
@@ -153,6 +164,85 @@ const handlers = {
153
164
  }
154
165
  }
155
166
  },
167
+ async read_doc_markdown(args, ctx) {
168
+ const docId = await ctx.resolveDocId(args.document_id);
169
+ const result = await ctx.getOfficialClient().getDocBlocks(docId);
170
+
171
+ // Lazy-load feishu-docx (so environments without the dep don't crash on startup)
172
+ let MarkdownRenderer;
173
+ try {
174
+ ({ MarkdownRenderer } = require('feishu-docx'));
175
+ } catch (e) {
176
+ return text('read_doc_markdown: feishu-docx package not installed. Run: npm install feishu-docx@^0.7.0');
177
+ }
178
+
179
+ const blocks = result.items || result;
180
+ if (!blocks || !blocks.length) {
181
+ return text('read_doc_markdown: document has no blocks (empty or unpublished draft). Try read_doc for plain text.');
182
+ }
183
+ const pageBlock = blocks.find(b => b.block_type === 1);
184
+ const documentId = pageBlock ? pageBlock.block_id : blocks[0].block_id;
185
+
186
+ let md;
187
+ try {
188
+ const renderer = new MarkdownRenderer({ document: { document_id: documentId }, blocks });
189
+ md = renderer.parse();
190
+ } catch (e) {
191
+ return text(`read_doc_markdown: feishu-docx render failed — ${e.message}. Try get_doc_blocks for raw JSON fallback. (feishu-docx version may need upgrading)`);
192
+ }
193
+
194
+ return text(_normaliseEmbeds(md));
195
+ },
156
196
  };
157
197
 
198
+ // Post-processor applied to feishu-docx output before returning to the caller.
199
+ // Converts inline HTML tags emitted by feishu-docx to Markdown equivalents,
200
+ // converts callout <div> wrappers to > blockquotes, decodes HTML entities, and
201
+ // normalises embedded image/file URLs to feishu:// scheme placeholders.
202
+ function _normaliseEmbeds(md) {
203
+ // 1. Inline bold: <b>...</b> → **...**
204
+ md = md.replace(/<b>([\s\S]*?)<\/b>/g, '**$1**');
205
+ // 2. Inline italic: <em>...</em> → *...*
206
+ md = md.replace(/<em>([\s\S]*?)<\/em>/g, '*$1*');
207
+ // 3. Inline strikethrough: <del>...</del> → ~~...~~
208
+ md = md.replace(/<del>([\s\S]*?)<\/del>/g, '~~$1~~');
209
+ // 4. Inline underline: <u>...</u> → strip tags, keep inner text (no native Markdown underline)
210
+ md = md.replace(/<u>([\s\S]*?)<\/u>/g, '$1');
211
+ // 5. Callout divs → > blockquote. feishu-docx emits:
212
+ // <div class="callout callout-bg-N callout-border-N">
213
+ // <div class='callout-emoji'>EMOJI</div>
214
+ // <p>content...</p>
215
+ // </div>
216
+ // Strip the outer div + emoji div; prefix each non-empty inner line with "> ".
217
+ // Note: nested callouts will partially leak as raw HTML — feishu-docx encloses
218
+ // the inner block in its own div, and our non-greedy match may close on the
219
+ // first </div>. Acceptable for v1.3.9; revisit if real docs exhibit this.
220
+ md = md.replace(
221
+ /<div class="callout[^"]*">\s*<div class=['"]callout-emoji['"][^<]*<\/div>\s*([\s\S]*?)\s*<\/div>/g,
222
+ (match, inner) => {
223
+ const stripped = inner.replace(/<\/?[^>]+>/g, '');
224
+ return stripped.split('\n').map(l => l.trim() ? '> ' + l : '').join('\n');
225
+ },
226
+ );
227
+ // 6. Decode common HTML entities (&lt; &gt; &amp; appear in doc body text).
228
+ // &amp; must be decoded first so that double-encoded sequences like &amp;lt;
229
+ // are fully resolved (otherwise &amp; → & yields &lt; which stays escaped).
230
+ md = md.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>');
231
+ // 7. Image URL normalization.
232
+ // feishu-docx parseImage (verified from dist/markdown_renderer.js) emits an HTML <img> tag
233
+ // with the image token as src, e.g. <img src="img_v3_02k0XXX"/> or
234
+ // <img src="img_v3_02k0XXX" src-width="800" src-height="600" align="center"/>
235
+ // Convert to: ![](feishu://image_token/TOKEN)
236
+ md = md.replace(/<img\s+src="([^"]+)"[^>]*\/?>/g, '![](feishu://image_token/$1)');
237
+ // 8. File embed normalization.
238
+ // feishu-docx parseFile (verified from dist/markdown_renderer.js) emits the
239
+ // file token directly as the markdown link URL: [document.pdf](boxcnAbCdEfGhIj)
240
+ // Feishu Drive file tokens always start with "box" — anchoring on that
241
+ // prefix excludes wiki/docx/bitable/sheet mention links that share the
242
+ // [name](token) syntax (wikcn/wikm/wikn/docx/doccn/bascn/sheet prefixes).
243
+ // Convert to: [name](feishu://file_token/TOKEN)
244
+ md = md.replace(/\[([^\]]+)\]\((box[a-zA-Z0-9_-]{8,})\)/g, '[$1](feishu://file_token/$2)');
245
+ return md;
246
+ }
247
+
158
248
  module.exports = { schemas, handlers };
@@ -0,0 +1,174 @@
1
+ // src/tools/events.js — v1.3.9 reads from events.jsonl via cursor.
2
+ //
3
+ // Backwards compat: when ctx.getEventBuffer() is the legacy in-memory buffer
4
+ // (no events.jsonl exists), fall back to the old behaviour.
5
+
6
+ const path = require('path');
7
+ const os = require('os');
8
+ const { text, json } = require('./_registry');
9
+ const { drain, readSnapshot } = require('../events/cursor');
10
+ const { readOwnerInfo } = require('../events/owner');
11
+ const fs = require('fs');
12
+
13
+ const FEISHU_HOME = path.join(os.homedir(), '.feishu-user-plugin');
14
+ const EVENTS_LOG_PATH = path.join(FEISHU_HOME, 'events.jsonl');
15
+
16
+ function _hasJsonlMode() {
17
+ try { fs.statSync(EVENTS_LOG_PATH); return true; } catch (_) { return false; }
18
+ }
19
+
20
+ function _filter(event, args, currentProfile) {
21
+ // Profile filter (v1.3.9 default = current active; "*"/"any" = all)
22
+ const profFilter = args.profile;
23
+ if (!profFilter || profFilter === 'auto') {
24
+ if (event.profile && event.profile !== currentProfile && event.profile !== '_system') return false;
25
+ } else if (profFilter !== '*' && profFilter !== 'any') {
26
+ if (event.profile !== profFilter) return false;
27
+ }
28
+
29
+ if (args.event_type && event.event_type !== args.event_type) return false;
30
+ if (args.event_types && !args.event_types.includes(event.event_type)) return false;
31
+ if (args.chat_id) {
32
+ const chatId = event?.event?.message?.chat_id || event?.event?.chat_id;
33
+ if (chatId !== args.chat_id) return false;
34
+ }
35
+ if (args.since_seconds) {
36
+ const cutoff = Date.now() - args.since_seconds * 1000;
37
+ if ((event.ts || 0) < cutoff) return false;
38
+ }
39
+ return true;
40
+ }
41
+
42
+ const schemas = [
43
+ {
44
+ name: 'get_new_events',
45
+ description: '[Plugin v1.3.9] Drain real-time events from the machine-level shared event log. v1.3.8 used per-process in-memory buffers (with duplicate-event problem); v1.3.9 uses ~/.feishu-user-plugin/events.jsonl with a single global cursor — every event delivered exactly once across all MCP processes on this machine. Default returns events from the current active profile only; pass profile="*" to see all.',
46
+ inputSchema: {
47
+ type: 'object',
48
+ properties: {
49
+ event_type: { type: 'string' },
50
+ event_types: { type: 'array', items: { type: 'string' } },
51
+ chat_id: { type: 'string' },
52
+ since_seconds: { type: 'integer' },
53
+ profile: { type: 'string', description: 'Profile filter. Default = current active. Pass "*" or "any" for all profiles.' },
54
+ max_events: { type: 'integer' },
55
+ peek: { type: 'boolean' },
56
+ },
57
+ },
58
+ },
59
+ {
60
+ name: 'manage_ws_status',
61
+ description: '[Plugin v1.3.9] Inspect or control the machine-level WS owner. Actions: info (status dump), reconnect (owner-only; restart WS), claim (try become owner; force=true to steal active lock), rotate (owner-only; force events.jsonl rotation), reconfig (owner-only; re-read credentials.json + apply event subscriptions).',
62
+ inputSchema: {
63
+ type: 'object',
64
+ properties: {
65
+ action: { type: 'string', enum: ['info', 'reconnect', 'claim', 'rotate', 'reconfig'] },
66
+ force: { type: 'boolean', description: 'For claim only: steal an active owner lock' },
67
+ },
68
+ required: ['action'],
69
+ },
70
+ },
71
+ ];
72
+
73
+ const handlers = {
74
+ async get_new_events(args, ctx) {
75
+ if (_hasJsonlMode()) {
76
+ const cap = Math.max(1, parseInt(args.max_events, 10) || 50);
77
+ const peek = !!args.peek;
78
+ const r = drain(FEISHU_HOME, { peek });
79
+ const currentProfile = ctx.getActiveProfile();
80
+ let filtered = r.events.filter((e) => _filter(e, args, currentProfile));
81
+ let truncated = false;
82
+ if (filtered.length > cap) {
83
+ filtered = filtered.slice(0, cap);
84
+ truncated = true;
85
+ }
86
+ const snap = readSnapshot(FEISHU_HOME);
87
+ return json({
88
+ events: filtered,
89
+ cursor: { offset: snap.cursor.offset, file: snap.cursor.file },
90
+ log: { size_bytes: snap.fileSize, pending_bytes: snap.pending },
91
+ truncated,
92
+ });
93
+ }
94
+ // Legacy in-memory fallback
95
+ const buffer = ctx.getEventBuffer && ctx.getEventBuffer();
96
+ if (!buffer) {
97
+ return text('Realtime events are not available. Reasons: APP_ID/SECRET not configured, OR Lark international tenant (Feishu WS only supports feishu.cn), OR the WS handshake failed at startup.');
98
+ }
99
+ const filter = {};
100
+ if (args.event_type) filter.event_type = args.event_type;
101
+ if (args.event_types) filter.event_types = args.event_types;
102
+ if (args.chat_id) filter.chat_id = args.chat_id;
103
+ if (args.since_seconds) filter.since_seconds = args.since_seconds;
104
+ const cap = Math.max(1, parseInt(args.max_events, 10) || 50);
105
+ let evts = args.peek ? buffer.peek(filter) : buffer.drain(filter);
106
+ let truncated = false;
107
+ if (evts.length > cap) {
108
+ evts = evts.slice(0, cap);
109
+ truncated = true;
110
+ }
111
+ return json({ events: evts, stats: buffer.stats(), truncated });
112
+ },
113
+
114
+ async manage_ws_status(args, ctx) {
115
+ const ws = ctx.getWsServer && ctx.getWsServer();
116
+ const ownerInfo = readOwnerInfo(FEISHU_HOME);
117
+ const isOwner = ws !== null && ws !== undefined;
118
+
119
+ if (args.action === 'info') {
120
+ const snap = readSnapshot(FEISHU_HOME);
121
+ const cred = require('../auth/credentials').readCanonical();
122
+ const activeProfile = ctx.getActiveProfile();
123
+ const configuredEvents = cred?.profiles?.[activeProfile]?.events || ['im.message.receive_v1'];
124
+ return json({
125
+ this_process: { is_owner: isOwner, pid: process.pid },
126
+ owner: ownerInfo.exists
127
+ ? { pid: ownerInfo.pid, start_time: ownerInfo.start_time, last_heartbeat_age_seconds: ownerInfo.last_heartbeat_age_seconds, alive: ownerInfo.alive }
128
+ : { exists: false },
129
+ ws: isOwner && ws ? ws.getStatus() : undefined,
130
+ log: { size_bytes: snap.fileSize, cursor_offset: snap.cursor.offset, pending_bytes: snap.pending },
131
+ config: { active_profile: activeProfile, configured_events: configuredEvents },
132
+ });
133
+ }
134
+
135
+ if (args.action === 'reconnect') {
136
+ if (!isOwner) return json({ error: 'not_owner', owner_pid: ownerInfo.pid });
137
+ ws.stop().then(() => ws.start()).catch(() => {});
138
+ return json({ ok: true, ws_state: 'switching' });
139
+ }
140
+
141
+ if (args.action === 'claim') {
142
+ if (isOwner) return json({ ok: true, became_owner: false, reason: 'already_owner' });
143
+ // Trigger _claimAndStart-like flow via ctx.
144
+ if (ctx.requestClaim) {
145
+ const r = await ctx.requestClaim({ force: !!args.force });
146
+ return json(r);
147
+ }
148
+ return json({ error: 'claim_not_supported_in_this_ctx' });
149
+ }
150
+
151
+ if (args.action === 'rotate') {
152
+ if (!isOwner) return json({ error: 'not_owner', owner_pid: ownerInfo.pid });
153
+ const snap = readSnapshot(FEISHU_HOME);
154
+ const { forceRotate } = require('../events/event-log');
155
+ const r = forceRotate(EVENTS_LOG_PATH, snap.fileSize);
156
+ const { resetCursorTo } = require('../events/cursor');
157
+ resetCursorTo(FEISHU_HOME, 0);
158
+ return json({ ok: true, prev_size: snap.fileSize, dropped_file: r.droppedPath });
159
+ }
160
+
161
+ if (args.action === 'reconfig') {
162
+ if (!isOwner) return json({ error: 'not_owner', owner_pid: ownerInfo.pid });
163
+ if (ctx.requestReconfigure) {
164
+ const r = await ctx.requestReconfigure();
165
+ return json({ ok: true, ...r });
166
+ }
167
+ return json({ error: 'reconfig_not_supported_in_this_ctx' });
168
+ }
169
+
170
+ return text(`unknown action: ${args.action}`);
171
+ },
172
+ };
173
+
174
+ module.exports = { schemas, handlers };
@@ -1,7 +1,6 @@
1
1
  // src/tools/messaging-bot.js — Bot-identity messaging operations (send, reply,
2
- // forward, delete, update, pin, reactions). send_card_as_user is intentionally
3
- // kept inline in src/index.js until v1.3.7's user-identity card path lands; it
4
- // will move to src/tools/messaging-user.js together with that work.
2
+ // forward, delete, update, pin, reactions). send_card_as_user lives in
3
+ // messaging-user.js (historical naming) but routes through bot see that file.
5
4
 
6
5
  const { text, json } = require('./_registry');
7
6
 
@@ -1,10 +1,12 @@
1
1
  // src/tools/messaging-user.js — User-identity (cookie-based) messaging plus
2
- // batch_send fan-out and the v1.3.6 bot-default send_card_as_user.
2
+ // batch_send fan-out. send_card_as_user lives here (historical naming) but
3
+ // always routes through bot — user-identity card sending is server-side
4
+ // disabled in Feishu at the cookie auth tier.
3
5
  //
4
- // All send_*_as_user handlers route through ctx.getUserClient() (cookie identity).
5
- // batch_send mixes user + bot identities per target. send_card_as_user currently
6
- // delegates to bot via ctx.getOfficialClient() — the "as_user" suffix is reserved
7
- // for v1.3.7's reverse-engineered cookie path; default flips when that lands.
6
+ // All send_*_as_user handlers route through ctx.getUserClient() (cookie identity)
7
+ // EXCEPT send_card_as_user which delegates to bot via ctx.getOfficialClient().
8
+ // The "as_user" suffix on the card tool is historical — v1.3.9 confirmed the
9
+ // cookie protobuf path for CARD is server-side disabled, brute-force exhausted.
8
10
 
9
11
  const { text, sendResult, json } = require('./_registry');
10
12
 
@@ -115,12 +117,17 @@ const schemas = [
115
117
  },
116
118
  {
117
119
  name: 'send_image_as_user',
118
- description: '[User Identity] Send an image as the logged-in user. Requires image_key (upload via Official API first).',
120
+ description: '[User Identity, v1.3.9] Send an image as the logged-in user (NOT bot). Requires image_key from a prior upload_image call. Cookie-protobuf wire format requires both imageKey + thumbnailKey — when no separate thumbnail is provided, plugin defaults thumbnailKey to imageKey (Feishu accepts this for messenger-uploaded images). Width/height/mime/size are optional metadata; Feishu auto-derives display sizing on its side.',
119
121
  inputSchema: {
120
122
  type: 'object',
121
123
  properties: {
122
124
  chat_id: { type: 'string', description: 'Target chat ID. Numeric preferred; oc_xxx is auto-resolved (v1.3.7 C1.4).' },
123
125
  image_key: { type: 'string', description: 'Image key from upload (img_v2_xxx or img_v3_xxx)' },
126
+ thumbnail_key: { type: 'string', description: 'Optional separate thumbnail image key. Defaults to image_key when omitted.' },
127
+ width: { type: 'number', description: 'Optional image width in pixels.' },
128
+ height: { type: 'number', description: 'Optional image height in pixels.' },
129
+ mime: { type: 'string', description: 'Optional MIME type (e.g. "image/png").' },
130
+ size: { type: 'number', description: 'Optional file size in bytes.' },
124
131
  root_id: { type: 'string', description: 'Thread root message ID (optional)' },
125
132
  },
126
133
  required: ['chat_id', 'image_key'],
@@ -160,13 +167,12 @@ const schemas = [
160
167
  },
161
168
  {
162
169
  name: 'send_card_as_user',
163
- description: '[v1.3.6: bot-routed default] Send an interactive card to a chat. **As of v1.3.6, identity defaults to BOT** because user-identity card sending requires reverse-engineering the Feishu web protobuf and is deferred to v1.3.7. The tool name keeps the "as_user" suffix so callers don\'t have to migrate when v1.3.7 lands; once user-identity is implemented the default flips. Pass `card` as a JSON object (Feishu card schema). To force bot explicitly set via="bot".',
170
+ description: '[v1.3.9+: bot-only] Send an interactive Feishu card to a chat via bot identity (Official API). User-identity cookie protobuf path is server-side disabled at the auth tier confirmed by exhaustive brute-force in v1.3.9, see scripts/explore-card-protobuf.js. The "as_user" suffix is historical naming kept for backward compat; the tool always routes through bot. Pass `card` as a JSON object (Feishu card schema, see https://open.feishu.cn/cardkit).',
164
171
  inputSchema: {
165
172
  type: 'object',
166
173
  properties: {
167
174
  chat_id: { type: 'string', description: 'Target chat_id (oc_xxx) or open_id' },
168
175
  card: { description: 'Feishu card JSON. See https://open.feishu.cn/cardkit for the schema; build cards visually then paste the resulting JSON here.' },
169
- via: { type: 'string', enum: ['bot', 'user'], description: 'Identity to send as. Default "bot". "user" returns an explicit not-yet-implemented error in v1.3.6.' },
170
176
  },
171
177
  required: ['chat_id', 'card'],
172
178
  },
@@ -264,7 +270,14 @@ const handlers = {
264
270
  async send_image_as_user(args, ctx) {
265
271
  const c = await ctx.getUserClient();
266
272
  const chatId = await _resolveCookieChatId(args.chat_id, ctx);
267
- const r = await c.sendImage(chatId, args.image_key, { rootId: args.root_id });
273
+ const r = await c.sendImage(chatId, args.image_key, {
274
+ rootId: args.root_id,
275
+ thumbnailKey: args.thumbnail_key,
276
+ width: args.width,
277
+ height: args.height,
278
+ mime: args.mime,
279
+ size: args.size,
280
+ });
268
281
  return sendResult(r, `Image sent to ${args.chat_id}`);
269
282
  },
270
283
  async send_file_as_user(args, ctx) {
@@ -280,12 +293,8 @@ const handlers = {
280
293
  return sendResult(r, `Post sent to ${args.chat_id}`);
281
294
  },
282
295
  async send_card_as_user(args, ctx) {
283
- const via = args.via || 'bot';
284
- if (via === 'user') {
285
- return text('send_card_as_user via="user" is not implemented in v1.3.6 — user-identity card sending requires reverse-engineering the Feishu web protobuf and is scheduled for v1.3.7. Use via="bot" (default) for now.');
286
- }
287
296
  const r = await ctx.getOfficialClient().sendMessageAsBot(args.chat_id, 'interactive', args.card);
288
- return text(`Card sent (${via}): ${r.messageId}`);
297
+ return text(`Card sent (bot): ${r.messageId}`);
289
298
  },
290
299
  };
291
300