feishu-user-plugin 1.3.7 → 1.3.8

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "feishu-user-plugin",
3
- "version": "1.3.7",
4
- "description": "All-in-one Feishu plugin for Claude Code — send messages as yourself, read chats (auto-expanded merge_forward), manage docs / bitable / wiki (full CRUD) / drive / OKR (with progress writes) / calendar (read+write) / Tasks v2 / multi-profile. 80 tools + 9 prompts, 3 auth layers.",
3
+ "version": "1.3.8",
4
+ "description": "All-in-one Feishu plugin for Claude Code — send messages as yourself, read chats (auto-expanded merge_forward), manage docs / bitable / wiki (full CRUD) / drive / OKR (with progress writes) / calendar (read+write) / Tasks v2 / multi-profile auto-switch / real-time WS events. 82 tools + 9 prompts, 3 auth layers.",
5
5
  "author": {
6
6
  "name": "EthanQC"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -4,6 +4,55 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [1.3.8] - 2026-05-05
8
+
9
+ ### Added
10
+ - **Multi-profile auto-switch (B)**: when `~/.feishu-user-plugin/credentials.json` has ≥2 profiles, read-only tools (`read_*` / `list_*` / `get_*` / `search_*` / `download_*` plus `manage_bitable_*` read-action variants) auto-retry across profiles on permission-denied errors. Trigger codes `91403 / 1254301 / 1254000 / 99991672 / HTTP 403` plus message patterns. The winning profile is cached to `credentials.json::profileHints` so subsequent calls go straight to the right account. Writes never auto-switch — pass `via_profile: "auto"` per call to opt in. New tool: `manage_profile_hints(action=list|set|clear, resource_key?, profile?)`. Single-profile users see no behaviour change.
11
+ - **Real-time WebSocket events (C)**: MCP server opens a `WSClient` connection at boot when `LARK_APP_ID/SECRET` are configured; events accumulate into an in-memory FIFO buffer (cap 1000) and surface via the new `get_new_events(event_type?, event_types?, chat_id?, since_seconds?, max_events=50, peek=false)` tool. Currently registers `im.message.receive_v1` (replies / group activity). feishu.cn only — Lark international not supported by Feishu's `WSClient`. Buffer is in-memory; events received before MCP boot aren't replayed.
12
+ - **Cookie protobuf wire-format tooling (A.0)**: `scripts/decode-feishu-protobuf.js` decodes a captured payload against `proto/lark.proto` and reports unknown field tags + wire types so we know what to add. `scripts/capture-feishu-protobuf.js` documents the per-type session recipe and runs DECODE on `/tmp/feishu-captures`. Living write-up in `docs/COOKIE-PROTOBUF-CAPTURES.md`. Actual IMAGE / AUDIO / STICKER / CARD / search_messages captures move to v1.3.9 — tooling is in place; capture session is high-touch and preferably done by hand.
13
+ - **`FEISHU_PLUGIN_PROFILE` env override (E.1)**: harness env can pin an active profile, validated at boot (fatal exit 2 on typo). Lets a single `credentials.json` serve different harnesses with different active profiles (Claude Code on "work", Codex on "personal").
14
+ - **`setup --pointer-only` mode (E.2)**: writes only `FEISHU_PLUGIN_PROFILE=default` to harness env; real creds live solely in `credentials.json`. Eliminates env-vs-file divergence on UAT refresh. Opt-in (interactive prompt + flag).
15
+ - **Migrate startup nudge (E.3)**: legacy env-only setups get a one-line TIP at MCP boot pointing at `npx feishu-user-plugin migrate --confirm`. Skipped when `credentials.json` already exists.
16
+ - **CI / docs gates (F)**: `scripts/sync-server-json.js` regenerates `server.json` from `package.json + TOOLS` (was frozen at v1.2.0 / 33 tools — now matches reality). `scripts/check-tool-count.js` extended to verify `SKILL.md::allowed-tools` in addition to README badge. `scripts/check-changelog.js` blocks publish when CHANGELOG has no section for the tag version. `scripts/check-docs-sync.js` enforces CLAUDE.md / AGENTS.md / skill-ref triple-sync at prepublish + CI.
17
+
18
+ ### Changed
19
+ - **`src/auth/uat.js` and `src/auth/cookie.js` extracted (D.1, D.2)** from `clients/official/base.js` and `clients/user.js` respectively. State (`this._uat` / `this._heartbeatTimer` / etc.) still lives on the client; only function bodies moved. base.js drops ~200 lines. Closes the v1.3.7 Phase B deferrals noted in `docs/REFACTOR-NOTES.md`.
20
+
21
+ ### Fixed
22
+ - **G.1 wiki-attach fallback retest scaffold**: `scripts/test-wiki-attach-fallback.js` monkey-patches `attachToWiki` to throw 91403 and verifies `upload_drive_file` surfaces the failure rather than silently uploading to drive root. POSIX skip 77 when missing creds / `FEISHU_TEST_FOLDER_TOKEN`.
23
+
24
+ ### Deferred to v1.3.9
25
+ - Cookie protobuf wire format reverse for IMAGE / AUDIO / STICKER / CARD / search_messages — tooling is in place (`scripts/decode-feishu-protobuf.js`, `scripts/capture-feishu-protobuf.js`, `docs/COOKIE-PROTOBUF-CAPTURES.md`), capture sessions are pending.
26
+ - `switch_profile` multi-profile e2e — needs a real second profile in tests.
27
+ - Test group `oc_daaa6a50f2a97dc668aaf79ae4dc6e4e` dissolution — needs owner-permission transfer.
28
+
29
+ ## [1.3.7] - 2026-05-04
30
+
31
+ ### Added
32
+ - **Wiki write (5 tools)**: `create_wiki_node` / `update_wiki_node` / `move_wiki_node` / `copy_wiki_node` / `delete_wiki_node`. UAT-first. `create_wiki_node` builds doc/sheet/bitable/mindnote/file/docx/slides directly inside a wiki space, or `node_type=shortcut` for a pointer. `update_wiki_node` only patches `title` (Feishu wiki API doesn't accept content edits — those go through docx/bitable/sheet). `move`/`copy` accept `target_parent_token` + optional `target_space_id` for cross-space migration. `delete_wiki_node` calls `DELETE /wiki/v2/spaces/{id}/nodes/{token}` via raw REST (SDK doesn't type it) — only deletes the node pointer, not the underlying drive resource.
33
+ - **OKR progress writes (3 tools)**: `create_okr_progress_record` / `list_okr_progress_records` / `delete_okr_progress_record`. UAT-first. Requires `okr:okr.content:write` scope. `create` accepts a simplified `content_text` (auto-wrapped into Feishu's block schema) plus optional `source_title` / `source_url` / `progress_percent`. `list` extracts `{progress_id, target_id, target_type}` triples from `get_okrs` since Feishu has no native list endpoint.
34
+ - **Calendar write (5 tools)**: `create_calendar_event` / `update_calendar_event` / `delete_calendar_event` / `respond_calendar_event` / `get_freebusy`. UAT-first. Requires `calendar:calendar.event:write` scope. `start_time` / `end_time` are objects: `{timestamp:"<unix-seconds>", timezone?}` or `{date:"YYYY-MM-DD"}`. `delete` accepts `meeting_chat_id` to also dissolve the linked meeting chat. `respond` is the RSVP path.
35
+ - **Tasks v2 (7 tools, new domain)**: `list_tasks` / `get_task` / `create_task` / `update_task` / `complete_task` / `delete_task` / `manage_task_members`. UAT-first. Requires `task:task` scope. v2 uses `task_guid` instead of v1 numeric `task_id`. `update_task` requires explicit `update_fields=["summary","due","completed_at",...]` — Feishu only patches the listed fields. `complete_task(completed=true|false)` is a convenience wrapper.
36
+ - **MCP prompts (9)**: `/send` `/reply` `/digest` `/search` `/doc` `/table` `/wiki` `/drive` `/status`. Mirror the Claude Code skills via `prompts/list` + `prompts/get`, so Codex / Cursor / OpenClaw / Windsurf get the same guided UX. Reference bodies are read at server start from `skills/feishu-user-plugin/references/`.
37
+ - **Single-source credentials store**: `~/.feishu-user-plugin/credentials.json` (mode 0600, schema `docs/CREDENTIALS-FORMAT.md`). Multiple MCP processes (Claude Code + Codex sharing the file) see token rotations consistently — closes the "Codex still has the old UAT after a refresh in Claude Code" drift. Cookie heartbeat + UAT refresh persist back atomically. Opt-in: `npx feishu-user-plugin migrate` (dry-run) / `migrate --confirm` (writes). Env vars remain as backward-compat fallback. Server's `Auth:` startup line on stderr shows source (`credentials.json profile=default` vs `env vars (legacy)`).
38
+ - **Semi-automated regression**: `scripts/test-all-tools.js` walks every tool with representative payloads. `tests/baseline/` snapshots `tools-list.json` / `prompts-list.json` / `login-status-shape.json`; `npm run smoke` diffs against them, `npm run smoke:baseline` regenerates after intentional schema change. `docs/TESTING-METHODOLOGY.md` documents when to use unit / smoke / live MCP / `test-all-tools`.
39
+
40
+ ### Fixed
41
+ - **C1.4 — `send_*_as_user` silently dropped messages with `oc_xxx` chat IDs**: cookie protobuf gateway's `PutMessageRequest.chatId` only recognizes numeric IDs; an `oc_xxx` was treated as unknown and the server returned an empty packet. Now auto-resolves `oc_xxx` via `getChatInfo(name) → cookie search(name) → numeric` and caches the mapping. Covers `send_as_user` / `send_image_as_user` / `send_file_as_user` / `send_post_as_user` / `send_card_as_user` / `batch_send`. Numeric IDs pass through unchanged. Resolution failure throws a clear error.
42
+ - **`list_wiki_nodes` returned 131006 in spaces the bot wasn't invited to**: `list_wiki_spaces` was already UAT-first, but `list_wiki_nodes` was bot-only. Made `list_wiki_nodes` UAT-first to match.
43
+ - **C1.15 — `get_user_info` showed current user as external tenant**: `getUserById` previously hit contact API first (requires `contact:user.base:readonly`); some OAuth configs returned no permission for same-tenant queries and the user was wrongly downgraded. Now UAT-first, contact API as fallback.
44
+ - **`manage_drive_file(action=delete)` printed `task=undefined`**: `DELETE /drive/v1/files/{token}` is synchronous and returns no `task_id`. Switched to `File deleted ({type})` when no task_id, `File deletion queued: task=...` when one is returned.
45
+ - **`send_image_as_user` failed silently**: cookie protobuf gateway rejects the simple `{imageKey}` content payload (HTTP 400) because Feishu Web actually encodes images with extra metadata (dimensions, MIME, thumbnails) that aren't in `proto/lark.proto`. Now throws a clear error pointing to `send_message_as_bot(msg_type="image", payload={image_key:"..."})` as the workaround. Wire format reverse-engineering deferred to v1.3.8 (needs Chrome DevTools traffic capture).
46
+ - Documented common error codes in tool schemas: 9499 (`manage_members` missing `member_id_type`, default `open_id`), 1062501 / 1061002 (`manage_drive_file` missing `type`).
47
+
48
+ ### Changed
49
+ - **Phase A refactor**: 7,500-line `src/index.js` split into `src/tools/<domain>.js` (handlers + schemas) and `src/clients/official/<domain>.js` (API methods). `src/server.js` orchestrates registration; `src/tools/_registry.js` provides shared `ctx` (factories, profile state, `resolveDocId`). See `docs/REFACTOR-NOTES.md` for the file-responsibility matrix.
50
+ - **Tool consolidation (82 → 80)**: 21 bitable tools collapsed into 5 `manage_bitable_*` dispatchers (app / table / field / view / record, each with `action=list|create|update|delete|...`). 3 doc-block tools → `manage_doc_block(action=create|update|delete)`. 3 drive ops → `manage_drive_file(action=copy|move|delete)`. 2 download tools → `download_message_resource(kind=image|file)` + `download_doc_image`. Semantics unchanged; parameters collapsed onto an `action` field.
51
+ - **Writes default to UAT**: every `create`/`edit` for docx / bitable / drive / wiki / OKR / calendar / tasks runs through `_asUserOrApp` — UAT first, bot only as fallback. Forced bot fallback appends a ⚠ warning to the response (and points to `npx feishu-user-plugin oauth`) so the ownership shift surfaces immediately.
52
+ - **ID input normalization**: docx / bitable tools' `document_id` / `app_token` accept native token (`doccnXXX` / `docxXXX` / `bascnXXX`), wiki node token (`wikcnXXX` / `wikmXXX` / `wiknXXX`), and full Feishu URLs. Internally resolved via `getWikiNode` with a 10-minute cache.
53
+ - **Upload scope inventory**: `uploadMedia` / `upload_drive_file` / `upload_bitable_attachment` / `manage_doc_block(image_path|file_path)` collectively need `drive:drive`, `drive:file:upload`, `docs:document.media:upload`, and `sheets:spreadsheet` (sheet uploads only). Documented in CLAUDE.md and the OAuth scope table.
54
+ - **team-skills sync via PR**: post-merge hook in this repo now opens an auto-merging PR against team-skills instead of pushing to main. CI `validate.yml` enforces a version triangle across `plugin.json` / `SKILL.md` / `README.md` first `### vX.Y.Z` heading.
55
+
7
56
  ## [1.3.6] - 2026-05-03
8
57
 
9
58
  ### Added
package/README.md CHANGED
@@ -3,10 +3,10 @@
3
3
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
4
4
  [![Node.js](https://img.shields.io/badge/Node.js-%3E%3D18-green.svg)](https://nodejs.org)
5
5
  [![MCP](https://img.shields.io/badge/MCP-Compatible-purple.svg)](https://modelcontextprotocol.io)
6
- [![Tools](https://img.shields.io/badge/Tools-80-orange.svg)](#tools)
6
+ [![Tools](https://img.shields.io/badge/Tools-82-orange.svg)](#tools)
7
7
  [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md)
8
8
 
9
- **All-in-one Feishu/Lark MCP Server -- 80 tools, 9 skills, 3 auth layers for messaging, docs, bitable, calendar, tasks, drive, OKR, and more.**
9
+ **All-in-one Feishu/Lark MCP Server -- 82 tools, 9 skills, 3 auth layers for messaging, docs, bitable, calendar, tasks, drive, OKR, and more.**
10
10
 
11
11
  The only MCP server that lets you send messages as your **personal identity** (not a bot), while also integrating the full official Feishu API. Works with Claude Code, Cursor, Windsurf, OpenClaw, and any MCP-compatible client.
12
12
 
@@ -21,6 +21,7 @@ The only MCP server that lets you send messages as your **personal identity** (n
21
21
  - **Calendar & Tasks** -- Create events, check free/busy, manage tasks.
22
22
  - **9 slash commands** for Claude Code -- `/send`, `/reply`, `/search`, `/digest`, `/doc`, `/table`, `/wiki`, `/drive`, `/status`
23
23
  - **Auto session management** -- Cookie heartbeat every 4h, UAT auto-refresh with token rotation.
24
+ - **Real-time events** (v1.3.8) -- `get_new_events` drains an in-memory queue of incoming Feishu messages — react to replies / group activity within seconds without polling. WS connection auto-starts on boot.
24
25
  - **Multi-platform** -- Claude Code, Cursor, Windsurf, VS Code, OpenClaw.
25
26
 
26
27
  ## Why This Exists
@@ -474,12 +475,27 @@ Identifier is `task_guid` (not v1's numeric `task_id`). Requires `task:task` sco
474
475
  | `move_wiki_node` | Move a wiki node to a different parent or different space |
475
476
  | `copy_wiki_node` | Deep-copy a wiki node to a different location (optionally to a different space) |
476
477
 
477
- ### Plugin -- Profiles (2 tools, v1.3.6)
478
+ ### Plugin -- Profiles (3 tools, v1.3.6 + v1.3.8)
478
479
 
479
480
  | Tool | Description |
480
481
  |------|-------------|
481
482
  | `list_profiles` | List available identity profiles (default + extras from `LARK_PROFILES_JSON`) and the active one |
482
483
  | `switch_profile` | Hot-swap active profile; cached client instances rebuild against new credentials |
484
+ | `manage_profile_hints` | Inspect/set/clear the resourceKey → profile cache used by the v1.3.8 auto-switch middleware |
485
+
486
+ ### Multi-profile auto-switch (v1.3.8)
487
+
488
+ When `~/.feishu-user-plugin/credentials.json` has more than one profile, the plugin auto-switches between them on **read** paths when the active profile gets a permission-denied error. The winning profile is cached per resource so subsequent calls go straight to the right account.
489
+
490
+ **Whitelist** -- only `read_*`, `list_*`, `get_*`, `search_*`, `download_*` (and the read-action variants of `manage_bitable_*`) get auto-retry. Writes never auto-switch -- they fail loud so you don't accidentally create resources under the wrong account.
491
+
492
+ **Triggers** -- error codes 91403, 1254301, 1254000, 99991672, HTTP 403, plus message patterns `access_denied / permission_denied / docx_no_permission / no permission / forbidden`.
493
+
494
+ **Per-call override** -- pass `via_profile: "<name>"` in any tool call to pin to that profile (no auto-switch). Pass `via_profile: "auto"` to opt **into** auto-switch on a write call (escape hatch -- be careful).
495
+
496
+ **Cache management** -- `manage_profile_hints(action="list" | "set" | "clear", resource_key?, profile?)` lets you inspect or edit the cache.
497
+
498
+ Single-profile users (the vast majority): zero behaviour change -- the router short-circuits and `manage_profile_hints` is a no-op.
483
499
 
484
500
  ## Claude Code Slash Commands (9 skills)
485
501
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "feishu-user-plugin",
3
- "version": "1.3.7",
4
- "description": "All-in-one Feishu plugin for Claude Code & Codex — messaging (with merge_forward expansion + batch_send), docs (image + file blocks read/write), bitable, wiki (full CRUD), drive, OKR (with progress writes), calendar (read+write), Tasks v2, multi-profile. 80 tools + 9 prompts, 3 auth layers.",
3
+ "version": "1.3.8",
4
+ "description": "All-in-one Feishu plugin for Claude Code & Codex — messaging (with merge_forward expansion + batch_send), docs (image + file blocks read/write), bitable, wiki (full CRUD), drive, OKR (with progress writes), calendar (read+write), Tasks v2, multi-profile auto-switch, real-time WS events. 82 tools + 9 prompts, 3 auth layers.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
7
7
  "feishu-user-plugin": "src/cli.js"
@@ -14,7 +14,7 @@
14
14
  "smoke": "node scripts/smoke.js diff",
15
15
  "smoke:baseline": "node scripts/smoke.js write-baseline",
16
16
  "test:tools": "node scripts/test-all-tools.js",
17
- "prepublishOnly": "node scripts/check-version.js && node scripts/check-tool-count.js && node scripts/confirm-version.js",
17
+ "prepublishOnly": "node scripts/check-version.js && node scripts/check-tool-count.js && node scripts/sync-server-json.js check && node scripts/check-docs-sync.js && node scripts/check-changelog.js && node scripts/confirm-version.js",
18
18
  "prepare": "husky"
19
19
  },
20
20
  "keywords": [
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // Companion script to docs/COOKIE-PROTOBUF-CAPTURES.md.
4
+ //
5
+ // Drives a single capture session: prints the recipe to follow, sets up
6
+ // the output dir, and after capture, decodes everything dropped into it.
7
+ //
8
+ // Usage:
9
+ // node scripts/capture-feishu-protobuf.js IMAGE # prints the IMAGE recipe
10
+ // node scripts/capture-feishu-protobuf.js DECODE # decodes everything in /tmp/feishu-captures/
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const { execSync } = require('child_process');
15
+
16
+ const CAPTURE_DIR = '/tmp/feishu-captures';
17
+ const TYPES = {
18
+ IMAGE: {
19
+ description: 'Image message via cookie protobuf — cmd=5 PutMessageRequest, content.type=5 (IMAGE)',
20
+ targetMessage: 'Content',
21
+ },
22
+ AUDIO: {
23
+ description: 'Audio message — cmd=5, content.type=7 (AUDIO)',
24
+ targetMessage: 'Content',
25
+ },
26
+ STICKER: {
27
+ description: 'Sticker — cmd=5, content.type=10 (STICKER)',
28
+ targetMessage: 'Content',
29
+ },
30
+ CARD: {
31
+ description: 'Interactive card — cmd=5, content.type=14 (CARD)',
32
+ targetMessage: 'Content',
33
+ },
34
+ };
35
+
36
+ const cmd = process.argv[2] || 'help';
37
+
38
+ if (cmd === 'DECODE') {
39
+ if (!fs.existsSync(CAPTURE_DIR)) { console.error('No captures yet — run a TYPE first.'); process.exit(1); }
40
+ const files = fs.readdirSync(CAPTURE_DIR).filter(f => f.endsWith('.bin') || f.endsWith('.b64'));
41
+ if (!files.length) { console.error('No capture files in ' + CAPTURE_DIR); process.exit(1); }
42
+ for (const f of files) {
43
+ const type = path.basename(f).split('-')[0].toUpperCase();
44
+ const meta = TYPES[type] || { targetMessage: 'Packet' };
45
+ console.log(`\n=== ${f} (decoding as ${meta.targetMessage}) ===`);
46
+ const fullPath = path.join(CAPTURE_DIR, f);
47
+ const decodeScript = path.join(__dirname, 'decode-feishu-protobuf.js');
48
+ try {
49
+ if (f.endsWith('.b64')) {
50
+ const b64 = fs.readFileSync(fullPath, 'utf8').trim();
51
+ execSync(`node ${decodeScript} ${meta.targetMessage} --b64 ${JSON.stringify(b64)}`, { stdio: 'inherit' });
52
+ } else {
53
+ execSync(`node ${decodeScript} ${meta.targetMessage} < ${fullPath}`, { stdio: 'inherit' });
54
+ }
55
+ } catch (e) { console.error(` decode failed: ${e.message}`); }
56
+ }
57
+ process.exit(0);
58
+ }
59
+
60
+ if (!TYPES[cmd]) {
61
+ console.log('Usage: node scripts/capture-feishu-protobuf.js [IMAGE|AUDIO|STICKER|CARD|DECODE]');
62
+ console.log('\nCapture types:');
63
+ for (const [k, v] of Object.entries(TYPES)) console.log(` ${k} — ${v.description}`);
64
+ console.log('\nRecipe (IMAGE example):');
65
+ console.log(' 1. The agent uses Playwright MCP to:');
66
+ console.log(' a. Open https://www.feishu.cn/messenger/ with LARK_COOKIE');
67
+ console.log(' b. Click "我自己" / self-chat');
68
+ console.log(' c. Drag-drop a small test PNG OR click the image button + select file');
69
+ console.log(' d. Wait for the upload to complete');
70
+ console.log(' e. Click "send" and watch network for the POST to /im/gateway/');
71
+ console.log(` 2. Save the raw POST body to ${CAPTURE_DIR}/image-1.bin`);
72
+ console.log(` 3. Run: node scripts/capture-feishu-protobuf.js DECODE`);
73
+ process.exit(0);
74
+ }
75
+
76
+ fs.mkdirSync(CAPTURE_DIR, { recursive: true });
77
+ console.log(`=== ${cmd} capture session ===`);
78
+ console.log(TYPES[cmd].description);
79
+ console.log(`\nCapture dir: ${CAPTURE_DIR}`);
80
+ console.log('\nRecipe:');
81
+ console.log(' 1. Use Playwright MCP to open feishu.cn/messenger/ with cookie auth');
82
+ console.log(' 2. Send the message of type ' + cmd + ' to "我自己" via the web UI');
83
+ console.log(' 3. Capture POST /im/gateway/ request body via fetch monkey-patch');
84
+ console.log(` 4. Drop the raw body to ${CAPTURE_DIR}/${cmd.toLowerCase()}-1.bin (or .b64)`);
85
+ console.log(` 5. Run: node scripts/capture-feishu-protobuf.js DECODE`);
86
+ console.log('\nSee docs/COOKIE-PROTOBUF-CAPTURES.md for full step-by-step.');
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // Verifies CHANGELOG.md has an "## [vX.Y.Z]" section matching package.json
4
+ // version. Run from publish workflow + locally before tagging.
5
+ //
6
+ // Usage:
7
+ // node scripts/check-changelog.js → checks current package.json version
8
+ // node scripts/check-changelog.js 1.3.8 → checks given version explicitly
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+
13
+ const explicit = process.argv[2];
14
+ const pkgVersion = explicit || require(path.join(__dirname, '..', 'package.json')).version;
15
+
16
+ const cl = fs.readFileSync(path.join(__dirname, '..', 'CHANGELOG.md'), 'utf8');
17
+
18
+ // Common section headings used in this repo: "## [v1.3.7]" or "## v1.3.7" or "## 1.3.7" or "### v1.3.7".
19
+ const patterns = [
20
+ new RegExp(`^##\\s*\\[?v?${pkgVersion.replace(/\./g, '\\.')}\\]?`, 'm'),
21
+ new RegExp(`^###\\s*v?${pkgVersion.replace(/\./g, '\\.')}`, 'm'),
22
+ ];
23
+ const match = patterns.some(re => re.test(cl));
24
+
25
+ if (!match) {
26
+ console.error(`ERROR: CHANGELOG.md has no section for v${pkgVersion}.`);
27
+ console.error(`Add "## [v${pkgVersion}]" or "### v${pkgVersion}" with the release notes before tagging.`);
28
+ process.exit(1);
29
+ }
30
+
31
+ console.log(`OK: CHANGELOG.md has v${pkgVersion} section`);
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // Verifies CLAUDE.md is in sync with AGENTS.md (Codex) and
4
+ // skills/feishu-user-plugin/references/CLAUDE.md (skill reference copy).
5
+ //
6
+ // Pre-commit hook (scripts/sync-claude-md.sh) already auto-regenerates these
7
+ // from CLAUDE.md, but this script gives prepublishOnly + CI a hard gate.
8
+ //
9
+ // Match logic mirrors validate.yml's diff steps:
10
+ // AGENTS.md = "# feishu-user-plugin — Codex Instructions\n" + tail -n +2 CLAUDE.md
11
+ // skills/.../CLAUDE.md = identical to CLAUDE.md
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+
16
+ const ROOT = path.join(__dirname, '..');
17
+ const claude = fs.readFileSync(path.join(ROOT, 'CLAUDE.md'), 'utf8');
18
+
19
+ // AGENTS.md: header replaced with "# feishu-user-plugin — Codex Instructions"
20
+ const claudeBody = claude.split('\n').slice(1).join('\n'); // drop first line
21
+ const expectedAgents = '# feishu-user-plugin — Codex Instructions\n' + claudeBody;
22
+ const actualAgents = fs.readFileSync(path.join(ROOT, 'AGENTS.md'), 'utf8');
23
+
24
+ const failures = [];
25
+ if (actualAgents !== expectedAgents) {
26
+ failures.push('AGENTS.md is out of sync with CLAUDE.md');
27
+ failures.push('Fix: bash scripts/sync-claude-md.sh (or edit CLAUDE.md and re-stage)');
28
+ }
29
+
30
+ const skillRef = path.join(ROOT, 'skills', 'feishu-user-plugin', 'references', 'CLAUDE.md');
31
+ const actualSkillRef = fs.readFileSync(skillRef, 'utf8');
32
+ if (actualSkillRef !== claude) {
33
+ failures.push('skills/feishu-user-plugin/references/CLAUDE.md is out of sync with CLAUDE.md');
34
+ failures.push('Fix: bash scripts/sync-claude-md.sh (or edit CLAUDE.md and re-stage)');
35
+ }
36
+
37
+ if (failures.length) {
38
+ for (const f of failures) console.error(f);
39
+ process.exit(1);
40
+ }
41
+ console.log('OK: CLAUDE.md / AGENTS.md / skill reference all in sync');
@@ -3,13 +3,38 @@
3
3
  const path = require('path');
4
4
  const fs = require('fs');
5
5
  const { TOOLS } = require(path.join(__dirname, '..', 'src', 'server'));
6
+
7
+ const failures = [];
8
+
9
+ // Source 1: README.md "N tools" badge
6
10
  const readme = fs.readFileSync(path.join(__dirname, '..', 'README.md'), 'utf8');
7
- // Find the canonical "N tools" reference. Pick the highest-confidence match.
8
- const m = readme.match(/(\d+)\s+tools/);
9
- if (!m) { console.error('No "N tools" badge in README.md'); process.exit(1); }
10
- const claimed = parseInt(m[1], 10);
11
- if (claimed !== TOOLS.length) {
12
- console.error(`README claims ${claimed} tools, src/server.js has ${TOOLS.length}`);
11
+ const readmeMatch = readme.match(/(\d+)\s+tools/);
12
+ if (!readmeMatch) {
13
+ failures.push('No "N tools" badge in README.md');
14
+ } else if (parseInt(readmeMatch[1], 10) !== TOOLS.length) {
15
+ failures.push(`README.md claims ${readmeMatch[1]} tools, src/server.js has ${TOOLS.length}`);
16
+ }
17
+
18
+ // Source 2: SKILL.md `allowed-tools` frontmatter — comma-separated list.
19
+ const skillMd = fs.readFileSync(path.join(__dirname, '..', 'skills', 'feishu-user-plugin', 'SKILL.md'), 'utf8');
20
+ const skillMatch = skillMd.match(/^allowed-tools:\s*(.+)$/m);
21
+ if (!skillMatch) {
22
+ failures.push('No `allowed-tools:` line in skills/feishu-user-plugin/SKILL.md frontmatter');
23
+ } else {
24
+ const skillTools = skillMatch[1].split(',').map(s => s.trim()).filter(Boolean);
25
+ const skillSet = new Set(skillTools);
26
+ const toolSet = new Set(TOOLS.map(t => t.name));
27
+ const missingFromSkill = [...toolSet].filter(t => !skillSet.has(t)).sort();
28
+ const extraInSkill = [...skillSet].filter(t => !toolSet.has(t)).sort();
29
+ if (missingFromSkill.length || extraInSkill.length) {
30
+ failures.push(`SKILL.md allowed-tools out of sync (server has ${TOOLS.length}, SKILL.md has ${skillTools.length}):`);
31
+ if (missingFromSkill.length) failures.push(` missing from SKILL.md: ${missingFromSkill.join(', ')}`);
32
+ if (extraInSkill.length) failures.push(` extra in SKILL.md (not registered): ${extraInSkill.join(', ')}`);
33
+ }
34
+ }
35
+
36
+ if (failures.length) {
37
+ for (const f of failures) console.error(f);
13
38
  process.exit(1);
14
39
  }
15
- console.log(`OK: ${TOOLS.length} tools`);
40
+ console.log(`OK: ${TOOLS.length} tools (README badge + SKILL.md allowed-tools both match)`);
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // Decode a captured Feishu protobuf payload against proto/lark.proto.
4
+ //
5
+ // Usage:
6
+ // node scripts/decode-feishu-protobuf.js Packet < /path/to/payload.bin
7
+ // echo "0a..." | node scripts/decode-feishu-protobuf.js Packet --hex
8
+ // node scripts/decode-feishu-protobuf.js Packet --b64 'CgRwYWNr...'
9
+ //
10
+ // Output:
11
+ // - Decoded JSON of the named message
12
+ // - "Unknown fields detected" section listing tag numbers + wire types we
13
+ // don't have in the proto (these are what we need to add).
14
+
15
+ const path = require('path');
16
+ const protobuf = require('protobufjs');
17
+
18
+ async function main() {
19
+ const args = process.argv.slice(2);
20
+ const messageName = args[0];
21
+ if (!messageName) {
22
+ console.error('Usage: node scripts/decode-feishu-protobuf.js <MessageName> [--hex | --b64 <data>]');
23
+ process.exit(2);
24
+ }
25
+ const flagIdx = args.indexOf('--hex');
26
+ const b64Idx = args.indexOf('--b64');
27
+
28
+ let buf;
29
+ if (b64Idx !== -1) {
30
+ buf = Buffer.from(args[b64Idx + 1], 'base64');
31
+ } else if (flagIdx !== -1) {
32
+ const hex = await readStdin();
33
+ buf = Buffer.from(hex.replace(/\s+/g, ''), 'hex');
34
+ } else {
35
+ buf = await readStdinBuffer();
36
+ }
37
+
38
+ const proto = await protobuf.load(path.join(__dirname, '..', 'proto', 'lark.proto'));
39
+ const T = proto.lookupType(messageName);
40
+ const decoded = T.decode(buf);
41
+ const obj = T.toObject(decoded, { defaults: false, bytes: String });
42
+ // Walk the buffer to find unknown field tags.
43
+ const unknown = scanUnknownFields(buf, T);
44
+ console.log(JSON.stringify(obj, _dumpBytes, 2));
45
+ if (unknown.length) {
46
+ console.log('\n--- Unknown fields detected ---');
47
+ for (const u of unknown) console.log(` field ${u.tag} (wire type ${u.wireType}, ${u.length} bytes): ${u.preview}`);
48
+ console.log('\nFor each unknown tag, add the field to proto/lark.proto and re-run to see the decoded shape.');
49
+ } else {
50
+ console.log('\n--- All fields known ---');
51
+ }
52
+ }
53
+
54
+ function _dumpBytes(_, v) {
55
+ if (Buffer.isBuffer(v)) return `<${v.length} bytes 0x${v.slice(0, 16).toString('hex')}${v.length > 16 ? '…' : ''}>`;
56
+ return v;
57
+ }
58
+
59
+ function readStdin() {
60
+ return new Promise((resolve) => {
61
+ const chunks = [];
62
+ process.stdin.on('data', (c) => chunks.push(c));
63
+ process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
64
+ });
65
+ }
66
+
67
+ function readStdinBuffer() {
68
+ return new Promise((resolve) => {
69
+ const chunks = [];
70
+ process.stdin.on('data', (c) => chunks.push(c));
71
+ process.stdin.on('end', () => resolve(Buffer.concat(chunks)));
72
+ });
73
+ }
74
+
75
+ // Walks raw protobuf bytes, decoding tag headers, and reports tags whose
76
+ // number+wireType is not present in the schema. Matches protobufjs's reader
77
+ // state machine but operates entry-point-only (no recursion into subtrees).
78
+ function scanUnknownFields(buf, type) {
79
+ const known = new Set(type.fieldsArray.map(f => f.id));
80
+ const reader = protobuf.Reader.create(buf);
81
+ const out = [];
82
+ while (reader.pos < reader.len) {
83
+ const tagInt = reader.uint32();
84
+ const tag = tagInt >>> 3;
85
+ const wireType = tagInt & 7;
86
+ const start = reader.pos;
87
+ let value;
88
+ try {
89
+ value = readValueByWireType(reader, wireType);
90
+ } catch (e) {
91
+ out.push({ tag, wireType, length: 0, preview: `decode error: ${e.message}` });
92
+ break;
93
+ }
94
+ if (!known.has(tag)) {
95
+ const len = reader.pos - start;
96
+ let preview;
97
+ if (Buffer.isBuffer(value)) preview = `0x${value.slice(0, 24).toString('hex')}${value.length > 24 ? '…' : ''}`;
98
+ else preview = String(value).slice(0, 80);
99
+ out.push({ tag, wireType, length: len, preview });
100
+ }
101
+ }
102
+ return out;
103
+ }
104
+
105
+ function readValueByWireType(reader, wireType) {
106
+ switch (wireType) {
107
+ case 0: return reader.uint64(); // varint
108
+ case 1: return reader.fixed64(); // 64-bit
109
+ case 2: return Buffer.from(reader.bytes()); // length-delimited
110
+ case 5: return reader.fixed32(); // 32-bit
111
+ default: reader.skipType(wireType); return null;
112
+ }
113
+ }
114
+
115
+ main().catch(e => { console.error('Error:', e.message); process.exit(1); });
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // Regenerates server.json so it never drifts from package.json + src/server.js.
4
+ // Reads:
5
+ // - package.json: version, description (truncated to ~220 chars for display)
6
+ // - src/server.js TOOLS: tool list (name + description from inputSchema.description)
7
+ // Preserves:
8
+ // - display_name, icon, repository, license, categories, tags,
9
+ // installations, environment_variables (these don't drift, edited by hand)
10
+ // CI gate (validate.yml) re-runs this and diffs — drift = build fail.
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ const ROOT = path.join(__dirname, '..');
16
+ const SERVER_JSON = path.join(ROOT, 'server.json');
17
+ const PKG = require(path.join(ROOT, 'package.json'));
18
+ const { TOOLS } = require(path.join(ROOT, 'src', 'server'));
19
+
20
+ function deriveToolEntry(t) {
21
+ // Strip the "[Plugin]"/"[Cookie]"/etc category prefix from descriptions for compactness.
22
+ const desc = (t.description || '').replace(/^\[[^\]]+\]\s*/, '');
23
+ return { name: t.name, description: desc.split('\n')[0].slice(0, 200) };
24
+ }
25
+
26
+ const existing = JSON.parse(fs.readFileSync(SERVER_JSON, 'utf8'));
27
+
28
+ // Truncate package.json description for the marketplace display field.
29
+ // The package.json one is intentionally long for npm searches; server.json
30
+ // trims it for cleaner cards.
31
+ const shortDesc = PKG.description.replace(/\s+/g, ' ').slice(0, 220);
32
+
33
+ const next = {
34
+ name: PKG.name,
35
+ display_name: existing.display_name || 'Feishu User Plugin for Claude Code',
36
+ description: shortDesc,
37
+ version: PKG.version,
38
+ icon: existing.icon || 'https://www.feishu.cn/favicon.ico',
39
+ repository: existing.repository || { type: 'git', url: PKG.repository?.url || '' },
40
+ license: existing.license || PKG.license || 'MIT',
41
+ categories: existing.categories || ['communication', 'messaging', 'productivity'],
42
+ tags: existing.tags || ['feishu', 'lark', 'im', 'messaging', 'docs', 'bitable', 'wiki', 'protobuf', 'plugin', 'claude-code'],
43
+ tools: TOOLS.map(deriveToolEntry),
44
+ installations: existing.installations || {
45
+ 'claude-code': { type: 'stdio', command: 'npx', args: ['-y', 'feishu-user-plugin'] },
46
+ },
47
+ environment_variables: existing.environment_variables || [
48
+ { name: 'LARK_COOKIE', description: 'Feishu web login cookie string (required for user identity messaging)', required: true },
49
+ { name: 'LARK_APP_ID', description: 'Feishu Open Platform App ID (required for official API)', required: true },
50
+ { name: 'LARK_APP_SECRET', description: 'Feishu Open Platform App Secret (required for official API)', required: true },
51
+ { name: 'LARK_USER_ACCESS_TOKEN', description: 'OAuth user_access_token for P2P chat reading (run: npx feishu-user-plugin oauth)', required: true },
52
+ { name: 'LARK_USER_REFRESH_TOKEN', description: 'OAuth refresh_token for automatic UAT renewal (obtained via OAuth flow)', required: true },
53
+ ],
54
+ };
55
+
56
+ const cmd = process.argv[2] || 'write';
57
+ const nextStr = JSON.stringify(next, null, 2) + '\n';
58
+
59
+ if (cmd === 'check') {
60
+ const cur = fs.readFileSync(SERVER_JSON, 'utf8');
61
+ if (cur !== nextStr) {
62
+ console.error('ERROR: server.json is out of sync with package.json + src/server.js TOOLS.');
63
+ console.error('Fix: node scripts/sync-server-json.js');
64
+ process.exit(1);
65
+ }
66
+ console.log(`OK: server.json in sync (${TOOLS.length} tools, v${PKG.version})`);
67
+ process.exit(0);
68
+ }
69
+
70
+ fs.writeFileSync(SERVER_JSON, nextStr);
71
+ console.log(`Regenerated server.json (${TOOLS.length} tools, v${PKG.version})`);
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // Simulates wiki:wiki scope insufficient and verifies the upload fallback path
4
+ // surfaces a clear error rather than burying the wiki failure under a generic
5
+ // "uploaded to drive root, attach failed" silent miss.
6
+ //
7
+ // Approach: monkey-patch attachToWiki on the LarkOfficialClient prototype to
8
+ // throw a 91403 (the production wiki "no permission" code), then call
9
+ // upload_drive_file with wiki_space_id and check the response.
10
+
11
+ const { readCredentials } = require('../src/auth/credentials');
12
+ const creds = readCredentials();
13
+ if (!creds.LARK_APP_ID || !creds.LARK_APP_SECRET || !creds.LARK_USER_ACCESS_TOKEN) {
14
+ console.error('Skipped: needs LARK_APP_ID / LARK_APP_SECRET / UAT (skip on CI).');
15
+ process.exit(77); // POSIX skip code
16
+ }
17
+
18
+ (async () => {
19
+ const { LarkOfficialClient } = require('../src/clients/official');
20
+ const client = new LarkOfficialClient(creds.LARK_APP_ID, creds.LARK_APP_SECRET);
21
+ client.loadUAT();
22
+
23
+ const original = client.attachToWiki?.bind(client);
24
+ if (!original) { console.error('attachToWiki not present — wiki domain may not be loaded.'); process.exit(2); }
25
+
26
+ client.attachToWiki = async function(...args) {
27
+ const err = new Error('attachToWiki failed (HTTP 403, code=91403): wiki scope not granted');
28
+ err.code = 91403;
29
+ throw err;
30
+ };
31
+
32
+ const tmpFile = '/tmp/feishu-test-attach-fallback.txt';
33
+ require('fs').writeFileSync(tmpFile, 'attach-fallback-test ' + Date.now());
34
+
35
+ // Need a real folder_token: this script is opportunistic — pass via env
36
+ // FEISHU_TEST_FOLDER_TOKEN. Skip cleanly otherwise.
37
+ const folderToken = process.env.FEISHU_TEST_FOLDER_TOKEN;
38
+ if (!folderToken) {
39
+ console.error('Skipped: set FEISHU_TEST_FOLDER_TOKEN env (a real Drive folder token you can write to) to exercise this fallback.');
40
+ require('fs').unlinkSync(tmpFile);
41
+ process.exit(77);
42
+ }
43
+
44
+ try {
45
+ const res = await client.uploadDriveFile({
46
+ file_path: tmpFile,
47
+ file_name: 'attach-fallback-test.txt',
48
+ folder_token: folderToken,
49
+ parent_type: 'explorer',
50
+ wiki_space_id: '0000000000000000', // bogus; attachToWiki monkey-patch throws 91403 either way
51
+ });
52
+ console.log('Result:', JSON.stringify(res, null, 2));
53
+ if (res?._wikiAttachWarning || res?.error || /91403|wiki/i.test(JSON.stringify(res))) {
54
+ console.log('PASS: upload surfaces the wiki attach failure');
55
+ process.exit(0);
56
+ }
57
+ console.log('FAIL: upload did not surface the wiki attach failure');
58
+ process.exit(1);
59
+ } catch (e) {
60
+ // The monkey-patched attachToWiki throws — uploadDriveFile may rethrow.
61
+ // That's also acceptable as long as the message preserves the wiki failure.
62
+ if (/91403|wiki/i.test(e.message)) {
63
+ console.log('PASS: upload surfaces the wiki attach failure via thrown error:', e.message);
64
+ process.exit(0);
65
+ }
66
+ console.error('FAIL: upload threw, but message does not mention wiki/91403:', e.message);
67
+ process.exit(1);
68
+ } finally {
69
+ try { require('fs').unlinkSync(tmpFile); } catch {}
70
+ }
71
+ })().catch((e) => { console.error('Error:', e.message); process.exit(1); });