feishu-user-plugin 1.3.8 → 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 (40) hide show
  1. package/.claude-plugin/plugin.json +12 -2
  2. package/CHANGELOG.md +50 -12
  3. package/README.md +4 -4
  4. package/package.json +9 -5
  5. package/proto/lark.proto +10 -0
  6. package/scripts/explore-card-protobuf.js +144 -0
  7. package/scripts/explore-image-minimize.js +163 -0
  8. package/scripts/generate-release-artifacts.js +318 -0
  9. package/scripts/probe-feishu-docx.js +203 -0
  10. package/scripts/sync-team-skills.sh +109 -7
  11. package/skills/feishu-user-plugin/SKILL.md +76 -4
  12. package/skills/feishu-user-plugin/references/CLAUDE.md +74 -54
  13. package/src/auth/credentials.js +36 -0
  14. package/src/cli.js +86 -45
  15. package/src/clients/user.js +15 -13
  16. package/src/events/cursor.js +103 -0
  17. package/src/events/event-buffer.js +8 -5
  18. package/src/events/event-log.js +151 -0
  19. package/src/events/index.js +8 -1
  20. package/src/events/lockfile.js +126 -0
  21. package/src/events/owner.js +73 -0
  22. package/src/events/ws-server.js +95 -25
  23. package/src/oauth.js +48 -7
  24. package/src/resolver.js +10 -0
  25. package/src/server.js +248 -29
  26. package/src/setup.js +99 -25
  27. package/src/test-all.js +12 -9
  28. package/src/test-events-cursor.js +56 -0
  29. package/src/test-events-lockfile.js +36 -0
  30. package/src/test-events-log.js +67 -0
  31. package/src/test-events-owner.js +64 -0
  32. package/src/test-fixtures/doc-blocks/sample-1.json +1256 -0
  33. package/src/test-read-doc-markdown.js +61 -0
  34. package/src/test-switch-profile.js +171 -0
  35. package/src/tools/diagnostics.js +10 -3
  36. package/src/tools/docs.js +93 -3
  37. package/src/tools/events.js +143 -33
  38. package/src/tools/messaging-bot.js +2 -3
  39. package/src/tools/messaging-user.js +23 -14
  40. package/src/tools/profile.js +12 -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 };
@@ -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 };
@@ -1,63 +1,173 @@
1
- // src/tools/events.js — real-time event consumption (v1.3.8).
1
+ // src/tools/events.js — v1.3.9 reads from events.jsonl via cursor.
2
2
  //
3
- // Single tool: get_new_events. Drains the EventBuffer that ws-server.js fills
4
- // from Feishu's realtime WS push. Default: pulls all events accumulated since
5
- // the last call (drain semantics — consumers must accept that events vanish
6
- // after read).
3
+ // Backwards compat: when ctx.getEventBuffer() is the legacy in-memory buffer
4
+ // (no events.jsonl exists), fall back to the old behaviour.
7
5
 
6
+ const path = require('path');
7
+ const os = require('os');
8
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
+ }
9
41
 
10
42
  const schemas = [
11
43
  {
12
44
  name: 'get_new_events',
13
- description: '[Plugin v1.3.8] Drain real-time events received since the last call. Currently surfaces "im.message.receive_v1" events (replies, group messages). Returns empty when WS isn\'t connected or no events have arrived. Use filter to scope by event_type or chat_id; max_events caps response size.',
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).',
14
62
  inputSchema: {
15
63
  type: 'object',
16
64
  properties: {
17
- event_type: { type: 'string', description: 'Optional: only events of this type (e.g. "im.message.receive_v1").' },
18
- event_types: { type: 'array', items: { type: 'string' }, description: 'Optional: any-of list of event types.' },
19
- chat_id: { type: 'string', description: 'Optional: only events from this chat (oc_xxx for groups, message events expose chat_id).' },
20
- since_seconds: { type: 'integer', description: 'Optional: only events received in the last N seconds.' },
21
- max_events: { type: 'integer', description: 'Cap on returned events (default 50). Drained events beyond the cap are returned in subsequent calls.' },
22
- peek: { type: 'boolean', description: 'When true, leave events in the buffer (default false = drain).' },
65
+ action: { type: 'string', enum: ['info', 'reconnect', 'claim', 'rotate', 'reconfig'] },
66
+ force: { type: 'boolean', description: 'For claim only: steal an active owner lock' },
23
67
  },
68
+ required: ['action'],
24
69
  },
25
70
  },
26
71
  ];
27
72
 
28
73
  const handlers = {
29
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
30
95
  const buffer = ctx.getEventBuffer && ctx.getEventBuffer();
31
96
  if (!buffer) {
32
- 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. Check server stderr for "WS connected" / "WS start failed".');
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.');
33
98
  }
34
-
35
99
  const filter = {};
36
- if (args.event_type) filter.event_type = args.event_type;
37
- if (args.event_types) filter.event_types = args.event_types;
38
- if (args.chat_id) filter.chat_id = args.chat_id;
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;
39
103
  if (args.since_seconds) filter.since_seconds = args.since_seconds;
40
-
41
104
  const cap = Math.max(1, parseInt(args.max_events, 10) || 50);
42
-
43
- let events = args.peek ? buffer.peek(filter) : buffer.drain(filter);
105
+ let evts = args.peek ? buffer.peek(filter) : buffer.drain(filter);
44
106
  let truncated = false;
45
- if (events.length > cap) {
46
- const kept = events.slice(0, cap);
47
- const overflow = events.slice(cap);
48
- if (!args.peek) {
49
- for (const e of overflow) buffer.push(e);
50
- }
51
- events = kept;
107
+ if (evts.length > cap) {
108
+ evts = evts.slice(0, cap);
52
109
  truncated = true;
53
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
+ }
54
169
 
55
- return json({
56
- events,
57
- stats: buffer.stats(),
58
- truncated,
59
- hint: events.length === 0 ? 'No new events. Call again later, or check stats.totalSeen / .totalDropped to confirm WS is alive.' : undefined,
60
- });
170
+ return text(`unknown action: ${args.action}`);
61
171
  },
62
172
  };
63
173
 
@@ -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