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
package/src/setup.js CHANGED
@@ -10,9 +10,12 @@
10
10
  */
11
11
 
12
12
  const readline = require('readline');
13
+ const fs = require('fs');
14
+ const os = require('os');
15
+ const path = require('path');
13
16
  const { findMcpConfig, writeNewConfig } = require('./config');
14
17
 
15
- // Parse CLI args: --app-id, --app-secret, --cookie, --client
18
+ // Parse CLI args: --app-id, --app-secret, --cookie, --client, --force, --profile
16
19
  function parseArgs() {
17
20
  const args = {};
18
21
  const argv = process.argv.slice(2);
@@ -21,6 +24,10 @@ function parseArgs() {
21
24
  else if (argv[i] === '--app-secret' && argv[i + 1]) args.appSecret = argv[++i];
22
25
  else if (argv[i] === '--cookie' && argv[i + 1]) args.cookie = argv[++i];
23
26
  else if (argv[i] === '--client' && argv[i + 1]) args.client = argv[++i];
27
+ else if (argv[i] === '--pointer-only') args.pointerOnly = true; // kept for backward compat; now implicit default
28
+ else if (argv[i] === '--force') args.force = true;
29
+ else if (argv[i] === '--profile' && argv[i + 1]) args.profile = argv[++i];
30
+ else if (argv[i] === '--activate') args.activate = true;
24
31
  }
25
32
  return args;
26
33
  }
@@ -150,20 +157,102 @@ async function main() {
150
157
  }
151
158
  if (!client) client = 'claude';
152
159
 
153
- // Write config
154
- console.log('\n--- Writing Config ---');
160
+ // --- 4-state SSOT matrix (v1.3.9 A.3) ---
161
+ // Determines how credentials.json is created/updated based on current state.
162
+ //
163
+ // State 1 (fresh): credentials.json absent, no harness LARK_* env → create fresh
164
+ // State 2 (auto-migrate): credentials.json absent, harness LARK_* env exists → migrate
165
+ // State 3 (preserve): credentials.json present, no --app-id → touch nothing in file
166
+ // State 4 (update): credentials.json present, --app-id given → update/add profile
167
+ const credentials = require('./auth/credentials');
168
+ const credsPath = path.join(os.homedir(), '.feishu-user-plugin', 'credentials.json');
169
+ const credsExist = !!credentials.readCanonical();
170
+ const targetProfile = cliArgs.profile || 'default';
171
+ const harnessHasLark = !!(existingEnv.LARK_APP_ID || existingEnv.LARK_COOKIE || existingEnv.LARK_USER_ACCESS_TOKEN);
172
+
173
+ let mode;
174
+ if (!credsExist && !harnessHasLark) mode = 'fresh';
175
+ else if (!credsExist && harnessHasLark) mode = 'auto-migrate';
176
+ else if (credsExist && !cliArgs.appId) mode = 'preserve';
177
+ else mode = 'update';
178
+ console.log(`\nSetup mode: ${mode}`);
155
179
 
156
- const env = {
157
- LARK_COOKIE: cookie,
158
- LARK_APP_ID: appId,
159
- LARK_APP_SECRET: appSecret,
160
- LARK_USER_ACCESS_TOKEN: hasUAT ? existingUAT : 'SETUP_NEEDED',
161
- LARK_USER_REFRESH_TOKEN: hasUAT ? (existingRT || '') : '',
162
- };
180
+ if (mode === 'fresh' || mode === 'update') {
181
+ // Gather profile values — only include keys that have real values.
182
+ const profileValues = {};
183
+ if (appId) profileValues.LARK_APP_ID = appId;
184
+ if (appSecret) profileValues.LARK_APP_SECRET = appSecret;
185
+ if (cookie && cookie !== 'SETUP_NEEDED') profileValues.LARK_COOKIE = cookie;
186
+ else if (existingEnv.LARK_COOKIE && existingEnv.LARK_COOKIE !== 'SETUP_NEEDED') profileValues.LARK_COOKIE = existingEnv.LARK_COOKIE;
187
+ if (existingEnv.LARK_USER_ACCESS_TOKEN && existingEnv.LARK_USER_ACCESS_TOKEN !== 'SETUP_NEEDED') profileValues.LARK_USER_ACCESS_TOKEN = existingEnv.LARK_USER_ACCESS_TOKEN;
188
+ if (existingEnv.LARK_USER_REFRESH_TOKEN) profileValues.LARK_USER_REFRESH_TOKEN = existingEnv.LARK_USER_REFRESH_TOKEN;
163
189
 
164
- const result = writeNewConfig(env, undefined, undefined, client);
190
+ if (mode === 'fresh') {
191
+ fs.mkdirSync(path.dirname(credsPath), { recursive: true, mode: 0o700 });
192
+ const data = { version: 1, active: targetProfile, profiles: { [targetProfile]: profileValues }, profileHints: {} };
193
+ fs.writeFileSync(credsPath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
194
+ console.log(`Created ${credsPath} with profile "${targetProfile}"`);
195
+ } else {
196
+ // mode === 'update'
197
+ const canonical = credentials.readCanonical();
198
+ const profileExists = !!(canonical && canonical.profiles[targetProfile]);
199
+ if (profileExists && targetProfile === 'default' && !cliArgs.force && !cliArgs.profile) {
200
+ console.error(`Profile "default" already exists. Pass --force to overwrite, or --profile <name> to create a new profile.`);
201
+ rl.close();
202
+ process.exit(1);
203
+ }
204
+ if (profileExists && cliArgs.profile && !cliArgs.force) {
205
+ console.error(`Profile "${targetProfile}" already exists. Pass --force to overwrite, or pick a different --profile name.`);
206
+ rl.close();
207
+ process.exit(1);
208
+ }
209
+ canonical.profiles[targetProfile] = { ...(canonical.profiles[targetProfile] || {}), ...profileValues };
210
+ // v1.3.9 fix: only flip credentials.json::active when --activate is given.
211
+ // Without --activate, adding/updating a non-active profile leaves the
212
+ // current active alone (least-surprise: "I added work2, default is still
213
+ // active, I'll switch when I want via MCP switch_profile").
214
+ if (cliArgs.activate || (cliArgs.force && targetProfile === canonical.active)) {
215
+ canonical.active = targetProfile;
216
+ }
217
+ fs.writeFileSync(credsPath, JSON.stringify(canonical, null, 2) + '\n', { mode: 0o600 });
218
+ console.log(`Updated profile "${targetProfile}" in ${credsPath}`);
219
+ if (cliArgs.activate) console.log(` → active profile flipped to "${targetProfile}"`);
220
+ else if (canonical.active !== targetProfile) {
221
+ console.log(` → active profile unchanged ("${canonical.active}"). Pass --activate to flip, or use switch_profile MCP tool at runtime.`);
222
+ }
223
+ if (cliArgs.force) console.warn(` warning: overwrote existing profile credentials with --force`);
224
+ }
225
+ } else if (mode === 'auto-migrate') {
226
+ // Run migrate to consolidate harness env → credentials.json, then optionally
227
+ // override the default profile with any explicitly provided --app-id.
228
+ const result = credentials.migrate({ dryRun: false });
229
+ if (!result.ok) {
230
+ console.error('Auto-migrate failed; aborting setup.');
231
+ rl.close();
232
+ process.exit(1);
233
+ }
234
+ if (cliArgs.appId) {
235
+ credentials.persistProfileUpdate('default', { LARK_APP_ID: appId, LARK_APP_SECRET: appSecret });
236
+ console.log('Updated default profile with new app credentials.');
237
+ }
238
+ }
239
+ // mode === 'preserve': credentials.json is unchanged; we only update the harness pointer.
240
+
241
+ // --- Write harness config ---
242
+ // Always write pointer-only env to harness configs (v1.3.9 SSOT).
243
+ // The harness env block only needs FEISHU_PLUGIN_PROFILE; all real creds
244
+ // live in credentials.json.
245
+ console.log('\n--- Writing Config ---');
246
+ // v1.3.9 fix: harness env pointer should reflect what credentials.json::active
247
+ // will end up as, not blindly the targetProfile (which would mislead users
248
+ // who added a non-active profile via --profile alt without --activate).
249
+ const finalCanonical = credentials.readCanonical();
250
+ const harnessActive = finalCanonical?.active || targetProfile;
251
+ const pointerEnv = { FEISHU_PLUGIN_PROFILE: harnessActive };
252
+ const result = writeNewConfig(pointerEnv, undefined, undefined, client, { pointerOnly: true });
165
253
  if (result.configPath) console.log(`Written to ${result.configPath} (Claude Code)`);
166
254
  if (result.codexConfigPath) console.log(`Written to ${result.codexConfigPath} (Codex)`);
255
+ console.log(`Mode: pointer-only (env block contains only FEISHU_PLUGIN_PROFILE=${targetProfile})`);
167
256
 
168
257
  // Summary
169
258
  console.log('\n' + '='.repeat(60));
package/src/test-all.js CHANGED
@@ -266,14 +266,6 @@ async function main() {
266
266
  // 31. create_folder (skip)
267
267
  log('create_folder', 'SKIP', 'skipped to avoid creating unnecessary folders');
268
268
 
269
- // 32. find_user
270
- try {
271
- const res = await officialClient.findUserByIdentity({ emails: 'test@test.com' });
272
- log('find_user', 'PASS', `returned ${(res.userList || []).length} users (expected 0 for test email)`);
273
- } catch (e) {
274
- log('find_user', 'FAIL', e.message);
275
- }
276
-
277
269
  // ========== UAT Tests ==========
278
270
 
279
271
  if (officialClient.hasUAT) {
@@ -321,4 +313,15 @@ async function main() {
321
313
  }
322
314
  }
323
315
 
324
- main().catch(console.error);
316
+ main().catch(console.error).finally(() => {
317
+ // Fixture-based unit test — runs regardless of credential availability
318
+ require('./test-read-doc-markdown').run();
319
+ require('./test-switch-profile').run().catch(e => {
320
+ console.error('switch-profile-e2e: FAIL', e);
321
+ process.exitCode = 1;
322
+ });
323
+ require('./test-events-lockfile').run();
324
+ require('./test-events-log').run();
325
+ require('./test-events-cursor').run();
326
+ require('./test-events-owner').run();
327
+ });
@@ -0,0 +1,56 @@
1
+ // src/test-events-cursor.js
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const os = require('os');
5
+ const assert = require('node:assert/strict');
6
+ const { ensureLog, appendEvent } = require('./events/event-log');
7
+ const { drain, readSnapshot, resetCursorTo } = require('./events/cursor');
8
+
9
+ function run() {
10
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fish-cur-'));
11
+ const logPath = path.join(dir, 'events.jsonl');
12
+ ensureLog(logPath);
13
+
14
+ // No events → empty drain.
15
+ let r = drain(dir);
16
+ assert.equal(r.events.length, 0);
17
+
18
+ // Append 3 events, drain reads them.
19
+ appendEvent(logPath, { event_id: 'a', ts: 1, profile: 'default', payload: {} });
20
+ appendEvent(logPath, { event_id: 'b', ts: 2, profile: 'default', payload: {} });
21
+ appendEvent(logPath, { event_id: 'c', ts: 3, profile: 'default', payload: {} });
22
+
23
+ r = drain(dir);
24
+ assert.equal(r.events.length, 3);
25
+ assert.equal(r.advanced, true);
26
+
27
+ // Second drain returns nothing (cursor advanced).
28
+ r = drain(dir);
29
+ assert.equal(r.events.length, 0);
30
+
31
+ // Peek doesn't advance.
32
+ appendEvent(logPath, { event_id: 'd', ts: 4, profile: 'default', payload: {} });
33
+ r = drain(dir, { peek: true });
34
+ assert.equal(r.events.length, 1);
35
+ assert.equal(r.advanced, false);
36
+ // Real drain afterward still returns the same event.
37
+ r = drain(dir);
38
+ assert.equal(r.events.length, 1);
39
+ assert.equal(r.events[0].event_id, 'd');
40
+
41
+ // Snapshot.
42
+ const snap = readSnapshot(dir);
43
+ assert.equal(snap.pending, 0);
44
+ assert.ok(snap.cursor.offset > 0);
45
+
46
+ // Reset cursor to 0 → next drain returns all 4.
47
+ resetCursorTo(dir, 0);
48
+ r = drain(dir);
49
+ assert.equal(r.events.length, 4);
50
+
51
+ fs.rmSync(dir, { recursive: true, force: true });
52
+ console.log('cursor.js: PASS');
53
+ }
54
+
55
+ if (require.main === module) run();
56
+ module.exports = { run };
@@ -0,0 +1,36 @@
1
+ // src/test-events-lockfile.js (new file; require it from src/test-all.js)
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const os = require('os');
5
+ const assert = require('node:assert/strict');
6
+ const { acquireLongLived, withMutex } = require('./events/lockfile');
7
+
8
+ function run() {
9
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fish-lock-'));
10
+ const lockPath = path.join(dir, 'test.lock');
11
+
12
+ // Long-lived: first acquire works, second returns null.
13
+ const handle1 = acquireLongLived(lockPath, { info: { test: 1 } });
14
+ assert.ok(handle1, 'first acquire should succeed');
15
+ const handle2 = acquireLongLived(lockPath, { info: { test: 2 } });
16
+ assert.equal(handle2, null, 'second acquire should fail (lock held)');
17
+ handle1.release();
18
+
19
+ // After release, can acquire again.
20
+ const handle3 = acquireLongLived(lockPath, { info: { test: 3 } });
21
+ assert.ok(handle3, 'third acquire after release should succeed');
22
+ handle3.release();
23
+
24
+ // withMutex serializes.
25
+ const mutexPath = path.join(dir, 'mutex.lock');
26
+ let counter = 0;
27
+ withMutex(mutexPath, () => { counter += 1; });
28
+ withMutex(mutexPath, () => { counter += 1; });
29
+ assert.equal(counter, 2);
30
+
31
+ fs.rmSync(dir, { recursive: true, force: true });
32
+ console.log('lockfile.js: PASS');
33
+ }
34
+
35
+ if (require.main === module) run();
36
+ module.exports = { run };
@@ -0,0 +1,67 @@
1
+ // src/test-events-log.js
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const os = require('os');
5
+ const assert = require('node:assert/strict');
6
+ const { ensureLog, appendEvent, readFrom, repairTail, maybeRotate, forceRotate } = require('./events/event-log');
7
+
8
+ function run() {
9
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fish-log-'));
10
+ const logPath = path.join(dir, 'events.jsonl');
11
+
12
+ // Append + read
13
+ ensureLog(logPath);
14
+ appendEvent(logPath, { event_id: 'a', ts: 1, profile: 'default', payload: {} });
15
+ appendEvent(logPath, { event_id: 'b', ts: 2, profile: 'default', payload: {} });
16
+
17
+ let r = readFrom(logPath, 0);
18
+ assert.equal(r.events.length, 2);
19
+ assert.equal(r.events[0].event_id, 'a');
20
+ assert.equal(r.events[1].event_id, 'b');
21
+
22
+ // Read with offset
23
+ r = readFrom(logPath, r.events.length === 2 ? r.nextOffset : 0);
24
+ assert.equal(r.events.length, 0);
25
+
26
+ // Partial-line tolerance: append broken line
27
+ fs.appendFileSync(logPath, '{"event_id":"c","ts":3,"profile":"default","payload":{}}');
28
+ r = readFrom(logPath, 0);
29
+ assert.equal(r.events.length, 2, 'partial last line should not be consumed');
30
+
31
+ // Repair tail
32
+ const beforeSize = fs.statSync(logPath).size;
33
+ const repair = repairTail(logPath);
34
+ assert.equal(repair.repaired, true, 'repair should run');
35
+ assert.ok(repair.sizeAfter < beforeSize, 'truncation occurred');
36
+
37
+ // After repair, can append normally
38
+ appendEvent(logPath, { event_id: 'd', ts: 4, profile: 'default', payload: {} });
39
+ r = readFrom(logPath, 0);
40
+ assert.equal(r.events.length, 3, 'after repair + append, 3 full events');
41
+ assert.equal(r.events[2].event_id, 'd');
42
+
43
+ // Defer-rotate: size below threshold → no rotation
44
+ let rot = maybeRotate(logPath, r.nextOffset, 1024 * 1024);
45
+ assert.equal(rot.rotated, false);
46
+
47
+ // Defer-rotate: size above threshold + cursor caught up → rotation
48
+ // (we'll use a tiny threshold)
49
+ rot = maybeRotate(logPath, r.nextOffset, 1);
50
+ assert.equal(rot.rotated, true);
51
+ assert.ok(fs.existsSync(rot.droppedPath));
52
+ assert.equal(fs.statSync(logPath).size, 0, 'new log empty after rotate');
53
+
54
+ // Force rotate: even with cursor 0 (behind), drops + writes _rotated event
55
+ appendEvent(logPath, { event_id: 'e', ts: 5, profile: 'default', payload: {} });
56
+ forceRotate(logPath, fs.statSync(logPath).size);
57
+ r = readFrom(logPath, 0);
58
+ assert.equal(r.events.length, 1);
59
+ assert.equal(r.events[0].event_id, '_rotated');
60
+ assert.equal(r.events[0].profile, '_system');
61
+
62
+ fs.rmSync(dir, { recursive: true, force: true });
63
+ console.log('event-log.js: PASS');
64
+ }
65
+
66
+ if (require.main === module) run();
67
+ module.exports = { run };
@@ -0,0 +1,64 @@
1
+ // src/test-events-owner.js
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const os = require('os');
5
+ const assert = require('node:assert/strict');
6
+ const { tryClaim, readOwnerInfo, STALE_MS } = require('./events/owner');
7
+
8
+ function run() {
9
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fish-own-'));
10
+
11
+ // Initial claim succeeds.
12
+ const h1 = tryClaim(dir, { info: { wsProfile: 'default' } });
13
+ assert.equal(h1.isOwner, true);
14
+
15
+ // Second claim fails (lock held).
16
+ const h2 = tryClaim(dir);
17
+ assert.equal(h2.isOwner, false);
18
+ assert.equal(h2.ownerInfo.pid, process.pid);
19
+
20
+ // readOwnerInfo reflects state.
21
+ const info = readOwnerInfo(dir);
22
+ assert.equal(info.exists, true);
23
+ assert.equal(info.alive, true);
24
+ assert.equal(info.pid, process.pid);
25
+
26
+ // Heartbeat updates mtime.
27
+ const beforeMtime = info.mtimeMs;
28
+ // Force a slight delay then heartbeat
29
+ const target = Date.now();
30
+ while (Date.now() - target < 50) {} // small busy wait
31
+ const ok = h1.heartbeat();
32
+ assert.equal(ok, true);
33
+ const afterMtime = readOwnerInfo(dir).mtimeMs;
34
+ assert.ok(afterMtime >= beforeMtime);
35
+
36
+ h1.release();
37
+
38
+ // After release, readOwnerInfo says no owner.
39
+ const info2 = readOwnerInfo(dir);
40
+ assert.equal(info2.exists, false);
41
+
42
+ // After release, can re-claim.
43
+ const h3 = tryClaim(dir);
44
+ assert.equal(h3.isOwner, true);
45
+ h3.release();
46
+
47
+ // Force claim over an existing lock.
48
+ const h4 = tryClaim(dir, { info: { wsProfile: 'A' } });
49
+ const h5 = tryClaim(dir, { info: { wsProfile: 'B' }, force: true });
50
+ assert.equal(h5.isOwner, true);
51
+ // h4's heartbeat should now fail because its lock file got stolen.
52
+ // (Best-effort — heartbeat returns false if file isn't there to utimes.)
53
+ // We don't assert this strictly because the file's still around (we renamed
54
+ // it, didn't delete). Instead just verify h5 holds the new lock.
55
+ const info3 = readOwnerInfo(dir);
56
+ assert.equal(info3.exists, true);
57
+
58
+ h5.release();
59
+ fs.rmSync(dir, { recursive: true, force: true });
60
+ console.log('owner.js: PASS');
61
+ }
62
+
63
+ if (require.main === module) run();
64
+ module.exports = { run };