feishu-user-plugin 1.3.10 → 1.3.11
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.
- package/.claude-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +27 -0
- package/.mcpb/manifest.json +91 -0
- package/CHANGELOG.md +18 -0
- package/PRIVACY.md +105 -0
- package/README.md +20 -0
- package/package.json +4 -2
- package/scripts/build-mcpb.js +119 -0
- package/scripts/check-mcp-registry-version.js +43 -0
- package/scripts/check-mcpb-version.js +33 -0
- package/scripts/check-version.js +5 -0
- package/scripts/sync-team-skills.sh +72 -57
- package/skills/feishu-user-plugin/SKILL.md +1 -1
- package/skills/feishu-user-plugin/references/CLAUDE.md +1 -0
- package/src/auth/credentials.js +49 -0
- package/src/auth/lark-desktop.js +135 -0
- package/src/server.js +42 -0
- package/src/setup.js +44 -0
- package/src/test-lark-desktop.js +300 -0
- package/scripts/generate-og-image.js +0 -39
|
@@ -1,28 +1,30 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# scripts/sync-team-skills.sh — post-merge hook on main.
|
|
3
3
|
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
# 2. Run team-skills' generate-catalog.py (forced manual-yaml path for byte
|
|
7
|
-
# parity with CI)
|
|
8
|
-
# 3. Run our scripts/generate-release-artifacts.js to produce
|
|
9
|
-
# changelog + readme-row from CHANGELOG.md
|
|
10
|
-
# 4. Inject the changelog block into team-skills child README before the
|
|
11
|
-
# previous version's heading
|
|
12
|
-
# 5. Replace the team-skills root README catalog row matching feishu-user-plugin
|
|
13
|
-
# 6. Commit + push branch + open PR
|
|
14
|
-
# 7. Auto-merge: --admin --squash (we have admin on team-skills repo;
|
|
15
|
-
# org-level setting blocks repo PATCH for allow_auto_merge so we use
|
|
16
|
-
# --admin to bypass review wait. CI is non-blocking via "Check catalog"
|
|
17
|
-
# drift never happening since step 2 produced byte-identical output.)
|
|
4
|
+
# Idempotent + conflict-resilient sync from feishu-user-plugin's main into
|
|
5
|
+
# zhuzhen-team/team-skills. Designed so retries always converge:
|
|
18
6
|
#
|
|
19
|
-
#
|
|
7
|
+
# Flow:
|
|
8
|
+
# 1. Generate release artifacts in feishu repo (changelog block + readme row).
|
|
9
|
+
# 2. cd team-skills repo, fetch origin main.
|
|
10
|
+
# 3. Close any stale OPEN sync PRs whose branch is for an older version
|
|
11
|
+
# (so v1.3.10 sync doesn't pile up behind a never-merged v1.3.9 sync).
|
|
12
|
+
# 4. Delete any local stale sync/feishu-v$VERSION branch and recreate from
|
|
13
|
+
# origin/main — always starts fresh, never carries leftover commits.
|
|
14
|
+
# 5. Copy plugin tree + inject changelog + replace catalog row + regen catalog.
|
|
15
|
+
# 6. If nothing changed → exit 0 (already in sync for v$VERSION).
|
|
16
|
+
# 7. Commit + push --force-with-lease (safe: only this script writes to sync/* branches).
|
|
17
|
+
# 8. Open PR if not exists; merge --admin --squash.
|
|
18
|
+
#
|
|
19
|
+
# Failure modes:
|
|
20
20
|
# - team-skills repo not cloned at expected path → clean skip
|
|
21
|
-
# -
|
|
22
|
-
# -
|
|
23
|
-
#
|
|
21
|
+
# - generate-release-artifacts.js fails → exit non-zero (visible in stderr)
|
|
22
|
+
# - PR merge fails (rare; should be impossible after force-recreate from origin/main)
|
|
23
|
+
# → exit non-zero, post-merge wrapper labels as "non-fatal" but user sees stderr
|
|
24
24
|
set -e
|
|
25
|
-
|
|
25
|
+
|
|
26
|
+
TEAM_SKILLS_REPO="/Users/abble/team-skills"
|
|
27
|
+
TEAM_SKILLS="$TEAM_SKILLS_REPO/plugins/feishu-user-plugin"
|
|
26
28
|
if [ ! -d "$TEAM_SKILLS" ]; then echo "[hook] team-skills not present, skip"; exit 0; fi
|
|
27
29
|
|
|
28
30
|
ROOT="$(git rev-parse --show-toplevel)"
|
|
@@ -30,25 +32,43 @@ cd "$ROOT"
|
|
|
30
32
|
|
|
31
33
|
VERSION=$(node -e "console.log(require('./package.json').version)")
|
|
32
34
|
ARTIFACTS="/tmp/feishu-release/v${VERSION}"
|
|
35
|
+
BRANCH="sync/feishu-v$VERSION"
|
|
33
36
|
|
|
34
|
-
# Generate release artifacts FIRST so we can inject them into team-skills.
|
|
35
|
-
# This reads CHANGELOG.md for the v$VERSION section and emits team-skills
|
|
36
|
-
# changelog markdown + root readme row + announcement card JSON.
|
|
37
|
+
# 1. Generate release artifacts FIRST so we can inject them into team-skills.
|
|
37
38
|
node scripts/generate-release-artifacts.js "$VERSION" >/dev/null
|
|
38
39
|
|
|
39
|
-
#
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
# 2. cd team-skills, fetch origin main.
|
|
41
|
+
cd "$TEAM_SKILLS_REPO"
|
|
42
|
+
git fetch origin main --quiet
|
|
43
|
+
|
|
44
|
+
# 3. Close any stale OPEN sync PRs (different version branch). Idempotent —
|
|
45
|
+
# any matching PR for this same $VERSION is preserved (we'll force-update
|
|
46
|
+
# its branch in step 7 instead).
|
|
47
|
+
STALE_PRS=$(gh pr list --state open --search "Sync feishu-user-plugin in:title" \
|
|
48
|
+
--json number,headRefName --jq ".[] | select(.headRefName != \"$BRANCH\") | .number")
|
|
49
|
+
if [ -n "$STALE_PRS" ]; then
|
|
50
|
+
for stale_num in $STALE_PRS; do
|
|
51
|
+
gh pr close "$stale_num" \
|
|
52
|
+
--comment "Superseded by sync/feishu-v$VERSION (auto-closed by sync-team-skills.sh)" \
|
|
53
|
+
--delete-branch 2>&1 | tail -1 || true
|
|
54
|
+
echo "[hook] closed stale sync PR #$stale_num"
|
|
55
|
+
done
|
|
56
|
+
fi
|
|
57
|
+
|
|
58
|
+
# 4. Delete any local stale sync branch + recreate from origin/main.
|
|
59
|
+
# `git checkout -B` is "create or reset". We always start from latest main
|
|
60
|
+
# so there are no inherited commits from older sync attempts.
|
|
61
|
+
git checkout -B "$BRANCH" origin/main
|
|
62
|
+
|
|
63
|
+
# 5. Copy plugin tree from feishu repo, inject changelog, regen catalog.
|
|
64
|
+
cp -r "$ROOT/skills/." "$TEAM_SKILLS/skills/"
|
|
65
|
+
cp "$ROOT/.claude-plugin/plugin.json" "$TEAM_SKILLS/.claude-plugin/"
|
|
42
66
|
|
|
43
|
-
# Inject changelog block into team-skills
|
|
44
|
-
# Insert just before the existing first "### vX.Y.Z" heading, OR after
|
|
45
|
-
# "## 更新日志" if no prior version exists.
|
|
67
|
+
# 5a. Inject changelog block into team-skills child README (idempotent).
|
|
46
68
|
README="$TEAM_SKILLS/README.md"
|
|
47
69
|
if grep -q "^### v${VERSION} " "$README"; then
|
|
48
70
|
echo "[hook] team-skills child README already has v${VERSION} section, skipping inject"
|
|
49
71
|
else
|
|
50
|
-
# awk: print everything; when we hit the FIRST `### vX.Y.Z (date)` heading,
|
|
51
|
-
# insert the new block before it.
|
|
52
72
|
awk -v block_file="$ARTIFACTS/team-skills-changelog.md" '
|
|
53
73
|
BEGIN { inserted = 0 }
|
|
54
74
|
/^### v[0-9]+\.[0-9]+\.[0-9]+ \(/ && !inserted {
|
|
@@ -61,14 +81,12 @@ else
|
|
|
61
81
|
echo "[hook] injected v${VERSION} changelog block into child README"
|
|
62
82
|
fi
|
|
63
83
|
|
|
64
|
-
# Replace
|
|
65
|
-
ROOT_README="$
|
|
84
|
+
# 5b. Replace root README catalog row matching feishu-user-plugin.
|
|
85
|
+
ROOT_README="$TEAM_SKILLS_REPO/README.md"
|
|
66
86
|
NEW_ROW=$(cat "$ARTIFACTS/team-skills-readme-row.md")
|
|
67
87
|
if grep -q "^| \\*\\*feishu-user-plugin\\*\\* |" "$ROOT_README"; then
|
|
68
|
-
# Replace the line in-place. Use Python (sed regex with table chars + |
|
|
69
|
-
# quotes is brittle across BSD/GNU).
|
|
70
88
|
python3 -c "
|
|
71
|
-
import
|
|
89
|
+
import re
|
|
72
90
|
p = '$ROOT_README'
|
|
73
91
|
new_row = '''$NEW_ROW'''.strip()
|
|
74
92
|
text = open(p, 'r', encoding='utf-8').read()
|
|
@@ -78,47 +96,44 @@ open(p, 'w', encoding='utf-8').write(text)
|
|
|
78
96
|
echo "[hook] updated root README catalog row to v${VERSION}"
|
|
79
97
|
fi
|
|
80
98
|
|
|
81
|
-
#
|
|
82
|
-
cd "$TEAM_SKILLS/../.."
|
|
83
|
-
|
|
84
|
-
BRANCH="sync/feishu-v$VERSION"
|
|
85
|
-
if git rev-parse --verify "$BRANCH" >/dev/null 2>&1; then
|
|
86
|
-
echo "[hook] branch $BRANCH already exists locally, skipping"; exit 0
|
|
87
|
-
fi
|
|
88
|
-
git checkout -b "$BRANCH"
|
|
89
|
-
|
|
90
|
-
# team-skills CI runs generate-catalog.py without PyYAML; force the same path
|
|
91
|
-
# locally for byte-identical output. Verified in PR #36.
|
|
99
|
+
# 5c. Regenerate catalog.yaml (force PyYAML-less path for byte parity with CI).
|
|
92
100
|
if [ -f "scripts/generate-catalog.py" ]; then
|
|
93
101
|
python3 -c "import sys, runpy; sys.modules['yaml']=None; runpy.run_path('scripts/generate-catalog.py', run_name='__main__')" >/dev/null 2>&1
|
|
94
102
|
fi
|
|
95
103
|
|
|
96
|
-
# Stage
|
|
97
|
-
# on already-clean files, so unchanged ones stage as no-op. Files that don't
|
|
98
|
-
# exist (e.g., catalog.yaml when team-skills repo doesn't have the generator)
|
|
99
|
-
# would fail under `set -e`, so guard explicitly.
|
|
104
|
+
# 6. Stage everything the hook touched.
|
|
100
105
|
git add "plugins/feishu-user-plugin/"
|
|
101
106
|
[ -f "README.md" ] && git add README.md
|
|
102
107
|
[ -f "catalog.yaml" ] && git add catalog.yaml
|
|
103
108
|
|
|
104
|
-
# If nothing actually changed, exit 0 — the v$VERSION sync was already done.
|
|
105
109
|
if git diff --cached --quiet; then
|
|
106
110
|
echo "[hook] nothing to sync (working tree clean for v$VERSION)"; exit 0
|
|
107
111
|
fi
|
|
108
112
|
|
|
109
113
|
git commit -m "chore: sync feishu-user-plugin v$VERSION (skills + plugin.json + README changelog + catalog)"
|
|
110
|
-
git push -u origin "$BRANCH"
|
|
111
114
|
|
|
112
|
-
|
|
115
|
+
# 7. Push (force-with-lease since this branch is exclusively written by this
|
|
116
|
+
# script — safe even if a previous run pushed something we just rebuilt).
|
|
117
|
+
git push --force-with-lease -u origin "$BRANCH"
|
|
118
|
+
|
|
119
|
+
# 8. Open PR if not exists, then merge.
|
|
120
|
+
PR_NUM=$(gh pr list --head "$BRANCH" --state open --json number --jq ".[0].number // empty")
|
|
121
|
+
if [ -z "$PR_NUM" ]; then
|
|
122
|
+
gh pr create --title "Sync feishu-user-plugin v$VERSION" --body "Auto-sync from feishu-user-plugin main. Includes:
|
|
113
123
|
- plugins/feishu-user-plugin/.claude-plugin/plugin.json bumped to v$VERSION
|
|
114
124
|
- plugins/feishu-user-plugin/skills/ regenerated
|
|
115
125
|
- plugins/feishu-user-plugin/README.md: v$VERSION changelog section auto-generated from feishu-user-plugin's CHANGELOG.md
|
|
116
126
|
- README.md (root): catalog row updated
|
|
117
127
|
- catalog.yaml regenerated"
|
|
128
|
+
PR_NUM=$(gh pr view "$BRANCH" --json number --jq .number)
|
|
129
|
+
echo "[hook] opened sync PR #$PR_NUM"
|
|
130
|
+
else
|
|
131
|
+
echo "[hook] reusing existing sync PR #$PR_NUM (branch force-updated)"
|
|
132
|
+
fi
|
|
118
133
|
|
|
119
|
-
|
|
120
|
-
#
|
|
121
|
-
#
|
|
122
|
-
#
|
|
134
|
+
# Use --admin --squash: we have admin permissions on team-skills (verified).
|
|
135
|
+
# After step 4's force-recreate from origin/main, this PR is always cleanly
|
|
136
|
+
# mergeable (no carried conflicts). --admin bypasses required reviews; CI
|
|
137
|
+
# is informational since step 5c produced byte-identical catalog output.
|
|
123
138
|
gh pr merge "$PR_NUM" --admin --squash
|
|
124
139
|
echo "[hook] team-skills sync PR #$PR_NUM merged"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: feishu-user-plugin
|
|
3
|
-
version: "1.3.
|
|
3
|
+
version: "1.3.11"
|
|
4
4
|
description: "All-in-one Feishu plugin — send messages as yourself (incl. batch_send), read group/P2P chats (auto-expands merge_forward), manage docs/tables/wiki (full CRUD)/drive, OKR (with progress writes), calendar (read+write), Tasks v2, multi-profile auto-switch, real-time WS events. v1.3.8: multi-profile auto-switch on read errors (B), WebSocket realtime im.message events via get_new_events (C), credential pointer-only mode (E), CI gates (F), auth/uat.js + auth/cookie.js extracts (D)."
|
|
5
5
|
allowed-tools: send_to_user, send_to_group, send_as_user, send_image_as_user, send_file_as_user, send_post_as_user, batch_send, send_card_as_user, search_contacts, create_p2p_chat, get_chat_info, get_user_info, get_login_status, list_profiles, switch_profile, manage_profile_hints, read_p2p_messages, list_user_chats, list_chats, read_messages, send_message_as_bot, reply_message, forward_message, delete_message, update_message, add_reaction, delete_reaction, pin_message, create_group, update_group, list_members, manage_members, search_docs, read_doc, get_doc_blocks, create_doc, manage_doc_block, read_doc_markdown, manage_bitable_app, manage_bitable_table, manage_bitable_field, manage_bitable_view, manage_bitable_record, upload_bitable_attachment, list_wiki_spaces, search_wiki, list_wiki_nodes, get_wiki_node, create_wiki_node, update_wiki_node, move_wiki_node, copy_wiki_node, delete_wiki_node, list_files, create_folder, upload_drive_file, manage_drive_file, upload_image, upload_file, download_message_resource, download_doc_image, list_user_okrs, get_okrs, list_okr_periods, create_okr_progress_record, list_okr_progress_records, delete_okr_progress_record, list_calendars, list_calendar_events, get_calendar_event, create_calendar_event, update_calendar_event, delete_calendar_event, respond_calendar_event, get_freebusy, list_tasks, get_task, create_task, update_task, complete_task, delete_task, manage_task_members, get_new_events, manage_ws_status
|
|
6
6
|
user_invocable: true
|
|
@@ -197,6 +197,7 @@ For more profiles beyond the default, set `LARK_PROFILES_JSON` in the MCP env (o
|
|
|
197
197
|
- `~/.feishu-user-plugin/ws-owner.lock`: lock file owned by the one MCP process driving the WS connection (O_CREAT|O_EXCL, 30 s stale).
|
|
198
198
|
- `~/.feishu-user-plugin/events.jsonl`: append-only event log written by the WS owner; 10 MB soft / 20 MB hard cap then rotated to `events.jsonl.old`.
|
|
199
199
|
- `~/.feishu-user-plugin/events.cursor.json`: global drain cursor shared across all MCP processes — advancing it marks events as consumed for all harnesses on the machine.
|
|
200
|
+
- **Lark Desktop multi-account auto-switch (v1.3.11)**: when `credentials.json::profiles[*].larkHash` is bound, the owner heartbeat (15 s) watches `~/Library/.../sdk_storage/<hash>/cookie_store.db` mtime; switching the active Lark Desktop account auto-flips `credentials.json::active` to the bound profile. macOS-only. Bind via `setup --bind-hash <hash>` or auto-detect on `setup` (single account binds silently; multiple prompts in interactive mode / picks most-recent in non-interactive mode). Cookies stay per-profile in `LARK_COOKIE`; the encrypted `cookie_store.db` is never read.
|
|
200
201
|
|
|
201
202
|
### Credentials store (v1.3.7+)
|
|
202
203
|
Single source of truth at `~/.feishu-user-plugin/credentials.json` (mode 0600). Schema documented at `docs/CREDENTIALS-FORMAT.md`. The MCP server reads from this file when present; cookie heartbeat and UAT refresh persist back to it atomically. Multiple harnesses (Claude Code, Codex) sharing the same file see token rotations consistently — no more "Codex still has the old UAT" drift after a refresh in Claude Code.
|
package/src/auth/credentials.js
CHANGED
|
@@ -360,6 +360,51 @@ function setProfileEvents(name, eventList) {
|
|
|
360
360
|
return true;
|
|
361
361
|
}
|
|
362
362
|
|
|
363
|
+
// --- Lark Desktop hash bindings (v1.3.11 §A) ---
|
|
364
|
+
//
|
|
365
|
+
// Each profile may carry an optional `larkHash` 32-char-hex field binding it
|
|
366
|
+
// to one of `~/Library/Containers/com.bytedance.macos.feishu/.../sdk_storage/<hash>/`.
|
|
367
|
+
// The owner heartbeat reactor uses this binding to decide which profile to
|
|
368
|
+
// switch to when the user changes account in Lark Desktop. See
|
|
369
|
+
// src/auth/lark-desktop.js for the detection / switch logic; this module
|
|
370
|
+
// just owns the persisted binding.
|
|
371
|
+
|
|
372
|
+
const _LARK_HASH_RE = /^[a-f0-9]{32}$/;
|
|
373
|
+
|
|
374
|
+
function getProfileLarkHash(name) {
|
|
375
|
+
const f = _readFile();
|
|
376
|
+
const target = name || (f ? f.active : 'default');
|
|
377
|
+
if (f && f.profiles[target] && typeof f.profiles[target].larkHash === 'string') {
|
|
378
|
+
return f.profiles[target].larkHash;
|
|
379
|
+
}
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function setProfileLarkHash(name, hash) {
|
|
384
|
+
if (hash !== null && (typeof hash !== 'string' || !_LARK_HASH_RE.test(hash))) {
|
|
385
|
+
throw new Error('setProfileLarkHash: hash must be 32-char hex (a-f, 0-9) or null');
|
|
386
|
+
}
|
|
387
|
+
const f = _readFile();
|
|
388
|
+
if (!f) throw new Error('No credentials.json — cannot set profile larkHash. Run `npx feishu-user-plugin migrate --confirm` first.');
|
|
389
|
+
if (!f.profiles[name]) {
|
|
390
|
+
throw new Error(`setProfileLarkHash: profile "${name}" not found. Available: ${Object.keys(f.profiles).join(', ')}`);
|
|
391
|
+
}
|
|
392
|
+
if (hash === null) delete f.profiles[name].larkHash;
|
|
393
|
+
else f.profiles[name].larkHash = hash;
|
|
394
|
+
_atomicWriteJson(_credentialsPath(), f);
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function findProfileByHash(hash) {
|
|
399
|
+
if (typeof hash !== 'string' || !_LARK_HASH_RE.test(hash)) return null;
|
|
400
|
+
const f = _readFile();
|
|
401
|
+
if (!f) return null;
|
|
402
|
+
for (const [name, profile] of Object.entries(f.profiles)) {
|
|
403
|
+
if (profile.larkHash === hash) return name;
|
|
404
|
+
}
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
|
|
363
408
|
// --- Profile hints (v1.3.8) ---
|
|
364
409
|
//
|
|
365
410
|
// profileHints maps resourceKey → profileName, persisted in credentials.json.
|
|
@@ -419,6 +464,10 @@ module.exports = {
|
|
|
419
464
|
// per-profile events list (v1.3.9 A.4)
|
|
420
465
|
getProfileEvents,
|
|
421
466
|
setProfileEvents,
|
|
467
|
+
// Lark Desktop hash bindings (v1.3.11 §A)
|
|
468
|
+
getProfileLarkHash,
|
|
469
|
+
setProfileLarkHash,
|
|
470
|
+
findProfileByHash,
|
|
422
471
|
// profile hints (v1.3.8)
|
|
423
472
|
getProfileHints,
|
|
424
473
|
setProfileHint,
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// src/auth/lark-desktop.js — Lark Desktop sdk_storage detection (v1.3.11 §A).
|
|
2
|
+
//
|
|
3
|
+
// macOS-only: Linux/Windows return null from getSdkStorageDir() and all
|
|
4
|
+
// callers no-op gracefully. We never read the encrypted cookie_store.db —
|
|
5
|
+
// only stat its mtime to detect account switches. Profile↔hash bindings
|
|
6
|
+
// live in credentials.json::profiles[*].larkHash.
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
|
|
14
|
+
const HASH_RE = /^[a-f0-9]{32}$/;
|
|
15
|
+
|
|
16
|
+
// Debounce + freshness windows for the heartbeat reactor.
|
|
17
|
+
const SWITCH_DEBOUNCE_MS = 5_000;
|
|
18
|
+
const UNBOUND_FRESH_WINDOW_MS = 60_000;
|
|
19
|
+
|
|
20
|
+
function _macSdkStorageDir() {
|
|
21
|
+
return path.join(
|
|
22
|
+
os.homedir(),
|
|
23
|
+
'Library/Containers/com.bytedance.macos.feishu/Data/Library/Application Support/LarkShell/sdk_storage'
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getSdkStorageDir() {
|
|
28
|
+
if (process.platform !== 'darwin') return null;
|
|
29
|
+
const dir = _macSdkStorageDir();
|
|
30
|
+
try {
|
|
31
|
+
return fs.statSync(dir).isDirectory() ? dir : null;
|
|
32
|
+
} catch (_) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// List Lark account hash directories under sdk_storage, sorted by
|
|
38
|
+
// cookie_store.db mtime descending. Hash dirs without a cookie_store.db
|
|
39
|
+
// are filtered (account never logged in / cleared).
|
|
40
|
+
//
|
|
41
|
+
// Returns: [{ hash, mtimeMs, dir }]
|
|
42
|
+
function listAccountHashes({ dir } = {}) {
|
|
43
|
+
const root = dir || getSdkStorageDir();
|
|
44
|
+
if (!root) return [];
|
|
45
|
+
let entries;
|
|
46
|
+
try { entries = fs.readdirSync(root); } catch (_) { return []; }
|
|
47
|
+
const out = [];
|
|
48
|
+
for (const name of entries) {
|
|
49
|
+
if (!HASH_RE.test(name)) continue;
|
|
50
|
+
const accountDir = path.join(root, name);
|
|
51
|
+
const dbPath = path.join(accountDir, 'cookie_store.db');
|
|
52
|
+
let mtimeMs;
|
|
53
|
+
try { mtimeMs = fs.statSync(dbPath).mtimeMs; } catch (_) { continue; }
|
|
54
|
+
out.push({ hash: name, mtimeMs, dir: accountDir });
|
|
55
|
+
}
|
|
56
|
+
out.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function mostRecentHash(opts = {}) {
|
|
61
|
+
const list = listAccountHashes(opts);
|
|
62
|
+
return list.length > 0 ? list[0] : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Pure-ish reactor logic, dependency-injected for unit tests.
|
|
66
|
+
//
|
|
67
|
+
// Inputs:
|
|
68
|
+
// prevSnapshot: { [hash]: mtimeMs } from the previous heartbeat
|
|
69
|
+
// lastSwitchAt: ms timestamp of the last auto-switch (debounce key)
|
|
70
|
+
// seenUnboundHashes: Set<hash> — emit hint once per hash per session
|
|
71
|
+
// credsApi: { getActiveProfileName, getProfileLarkHash, findProfileByHash }
|
|
72
|
+
// listFn: () => [...] — defaults to listAccountHashes() with auto-detected dir
|
|
73
|
+
// now: ms — defaults to Date.now()
|
|
74
|
+
// log: (msg) => void — defaults to console.error (the unbound-hash hint goes here)
|
|
75
|
+
//
|
|
76
|
+
// Returns:
|
|
77
|
+
// { switchTo: { hash, profile } | null, isUnbound: boolean, hash?: string }
|
|
78
|
+
//
|
|
79
|
+
// Mutates seenUnboundHashes (adds the hash when it emits a hint).
|
|
80
|
+
function detectSwitch({
|
|
81
|
+
prevSnapshot,
|
|
82
|
+
lastSwitchAt,
|
|
83
|
+
seenUnboundHashes,
|
|
84
|
+
credsApi,
|
|
85
|
+
listFn,
|
|
86
|
+
now,
|
|
87
|
+
log,
|
|
88
|
+
} = {}) {
|
|
89
|
+
if (!credsApi) credsApi = require('./credentials');
|
|
90
|
+
if (!listFn) listFn = () => listAccountHashes();
|
|
91
|
+
if (typeof now !== 'number') now = Date.now();
|
|
92
|
+
if (typeof log !== 'function') log = console.error;
|
|
93
|
+
|
|
94
|
+
if (now - lastSwitchAt < SWITCH_DEBOUNCE_MS) {
|
|
95
|
+
return { switchTo: null, isUnbound: false };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const list = listFn();
|
|
99
|
+
if (list.length === 0) return { switchTo: null, isUnbound: false };
|
|
100
|
+
|
|
101
|
+
const top = list[0];
|
|
102
|
+
const activeProfile = credsApi.getActiveProfileName();
|
|
103
|
+
const activeHash = credsApi.getProfileLarkHash(activeProfile);
|
|
104
|
+
if (top.hash === activeHash) return { switchTo: null, isUnbound: false };
|
|
105
|
+
|
|
106
|
+
// Only act on a true mtime advance — this prevents repeatedly switching
|
|
107
|
+
// when the snapshot baseline shows a stable older delta.
|
|
108
|
+
const prev = prevSnapshot[top.hash] || 0;
|
|
109
|
+
if (top.mtimeMs <= prev) return { switchTo: null, isUnbound: false };
|
|
110
|
+
|
|
111
|
+
const targetProfile = credsApi.findProfileByHash(top.hash);
|
|
112
|
+
if (!targetProfile) {
|
|
113
|
+
const isFresh = (now - top.mtimeMs) < UNBOUND_FRESH_WINDOW_MS;
|
|
114
|
+
if (isFresh && seenUnboundHashes && !seenUnboundHashes.has(top.hash)) {
|
|
115
|
+
seenUnboundHashes.add(top.hash);
|
|
116
|
+
log(
|
|
117
|
+
`[feishu-user-plugin] Lark Desktop active account hash ${top.hash} is not bound to any MCP profile. ` +
|
|
118
|
+
`Run: npx feishu-user-plugin setup --profile <name> --bind-hash ${top.hash}`
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
return { switchTo: null, isUnbound: true, hash: top.hash };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return { switchTo: { hash: top.hash, profile: targetProfile }, isUnbound: false };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
module.exports = {
|
|
128
|
+
HASH_RE,
|
|
129
|
+
SWITCH_DEBOUNCE_MS,
|
|
130
|
+
UNBOUND_FRESH_WINDOW_MS,
|
|
131
|
+
getSdkStorageDir,
|
|
132
|
+
listAccountHashes,
|
|
133
|
+
mostRecentHash,
|
|
134
|
+
detectSwitch,
|
|
135
|
+
};
|
package/src/server.js
CHANGED
|
@@ -80,6 +80,11 @@ let ownerHeartbeatTimer = null;
|
|
|
80
80
|
let nonOwnerPollTimer = null;
|
|
81
81
|
let _ownerStartCallbacks = [];
|
|
82
82
|
|
|
83
|
+
// Lark Desktop reactor state (v1.3.11 §A) — owned by the heartbeat callback.
|
|
84
|
+
let _lastHashMtimes = {};
|
|
85
|
+
let _lastSwitchAt = 0;
|
|
86
|
+
const _seenUnboundHashes = new Set();
|
|
87
|
+
|
|
83
88
|
function _onBecomeOwner(cb) { _ownerStartCallbacks.push(cb); }
|
|
84
89
|
|
|
85
90
|
function _stopHeartbeat() {
|
|
@@ -203,6 +208,9 @@ async function _claimAndStart() {
|
|
|
203
208
|
|
|
204
209
|
// Heartbeat + check active changes every 15s.
|
|
205
210
|
let lastCredMtime = _credMtime();
|
|
211
|
+
// Bootstrap baseline so the very first heartbeat doesn't trigger a switch.
|
|
212
|
+
_lastHashMtimes = require('./auth/lark-desktop').listAccountHashes()
|
|
213
|
+
.reduce((acc, h) => { acc[h.hash] = h.mtimeMs; return acc; }, {});
|
|
206
214
|
ownerHeartbeatTimer = setInterval(() => {
|
|
207
215
|
if (ownerHandle) ownerHandle.heartbeat();
|
|
208
216
|
const m = _credMtime();
|
|
@@ -210,6 +218,12 @@ async function _claimAndStart() {
|
|
|
210
218
|
lastCredMtime = m;
|
|
211
219
|
_maybeReconfigure().catch((e) => console.error(`[feishu-user-plugin] reconfigure failed: ${e.message}`));
|
|
212
220
|
}
|
|
221
|
+
// Lark Desktop reactor (v1.3.11 §A)
|
|
222
|
+
try {
|
|
223
|
+
_runLarkDesktopReactor();
|
|
224
|
+
} catch (e) {
|
|
225
|
+
console.error(`[feishu-user-plugin] Lark reactor error: ${e.message}`);
|
|
226
|
+
}
|
|
213
227
|
// Defer-rotate check
|
|
214
228
|
try {
|
|
215
229
|
const snap = events.cursor.readSnapshot(FEISHU_HOME);
|
|
@@ -242,6 +256,34 @@ function _credMtime() {
|
|
|
242
256
|
} catch (_) { return null; }
|
|
243
257
|
}
|
|
244
258
|
|
|
259
|
+
// Lark Desktop reactor (v1.3.11 §A).
|
|
260
|
+
// Called from the owner heartbeat. When the most-recently-active hash differs
|
|
261
|
+
// from the active profile's bound hash AND its mtime advanced since the last
|
|
262
|
+
// snapshot, flip credentials.json::active to the matching profile (the existing
|
|
263
|
+
// _credMtime delta on the next tick triggers _maybeReconfigure which restarts
|
|
264
|
+
// the WS client with the new profile's events list).
|
|
265
|
+
function _runLarkDesktopReactor() {
|
|
266
|
+
const ld = require('./auth/lark-desktop');
|
|
267
|
+
const out = ld.detectSwitch({
|
|
268
|
+
prevSnapshot: _lastHashMtimes,
|
|
269
|
+
lastSwitchAt: _lastSwitchAt,
|
|
270
|
+
seenUnboundHashes: _seenUnboundHashes,
|
|
271
|
+
});
|
|
272
|
+
if (out.switchTo) {
|
|
273
|
+
_lastSwitchAt = Date.now();
|
|
274
|
+
console.error(
|
|
275
|
+
`[feishu-user-plugin] Lark Desktop account changed; switching profile to ` +
|
|
276
|
+
`"${out.switchTo.profile}" (hash ${out.switchTo.hash})`
|
|
277
|
+
);
|
|
278
|
+
try { credentials.setActiveProfile(out.switchTo.profile); }
|
|
279
|
+
catch (e) { console.error(`[feishu-user-plugin] setActiveProfile failed: ${e.message}`); }
|
|
280
|
+
}
|
|
281
|
+
// Refresh snapshot regardless of switch outcome — keeps debounce + advance
|
|
282
|
+
// detection consistent on subsequent ticks.
|
|
283
|
+
_lastHashMtimes = ld.listAccountHashes()
|
|
284
|
+
.reduce((acc, h) => { acc[h.hash] = h.mtimeMs; return acc; }, {});
|
|
285
|
+
}
|
|
286
|
+
|
|
245
287
|
// Cross-process active-profile sync (v1.3.9 A.2).
|
|
246
288
|
// Each tool call: stat credentials.json; if mtime changed AND active differs
|
|
247
289
|
// from in-memory currentProfile, do an in-process setActiveProfile().
|
package/src/setup.js
CHANGED
|
@@ -28,6 +28,8 @@ function parseArgs() {
|
|
|
28
28
|
else if (argv[i] === '--force') args.force = true;
|
|
29
29
|
else if (argv[i] === '--profile' && argv[i + 1]) args.profile = argv[++i];
|
|
30
30
|
else if (argv[i] === '--activate') args.activate = true;
|
|
31
|
+
else if (argv[i] === '--bind-hash' && argv[i + 1]) args.bindHash = argv[++i];
|
|
32
|
+
else if (argv[i] === '--no-bind-hash') args.noBindHash = true;
|
|
31
33
|
}
|
|
32
34
|
return args;
|
|
33
35
|
}
|
|
@@ -238,6 +240,48 @@ async function main() {
|
|
|
238
240
|
}
|
|
239
241
|
// mode === 'preserve': credentials.json is unchanged; we only update the harness pointer.
|
|
240
242
|
|
|
243
|
+
// --- Lark Desktop hash auto-bind (v1.3.11 §A) ---
|
|
244
|
+
// Triggers on fresh / update (i.e. whenever credentials.json was just modified).
|
|
245
|
+
// Skipped via --no-bind-hash. Explicit --bind-hash overrides auto-detect.
|
|
246
|
+
if ((mode === 'fresh' || mode === 'update') && !cliArgs.noBindHash) {
|
|
247
|
+
try {
|
|
248
|
+
const larkDesktop = require('./auth/lark-desktop');
|
|
249
|
+
const hashes = larkDesktop.listAccountHashes();
|
|
250
|
+
if (hashes.length > 0) {
|
|
251
|
+
let chosenHash = cliArgs.bindHash;
|
|
252
|
+
if (!chosenHash) {
|
|
253
|
+
if (hashes.length === 1) {
|
|
254
|
+
chosenHash = hashes[0].hash;
|
|
255
|
+
console.log(`\n[Lark Desktop] Detected single account hash: ${chosenHash}`);
|
|
256
|
+
} else if (nonInteractive) {
|
|
257
|
+
chosenHash = hashes[0].hash;
|
|
258
|
+
console.log(`\n[Lark Desktop] Detected ${hashes.length} accounts; auto-binding "${targetProfile}" to most-recent: ${chosenHash}`);
|
|
259
|
+
console.log(` Other hashes (run setup --profile <name> --bind-hash <hash> to bind):`);
|
|
260
|
+
hashes.slice(1).forEach((h) => {
|
|
261
|
+
const ts = new Date(h.mtimeMs).toISOString();
|
|
262
|
+
console.log(` - ${h.hash} (last active ${ts})`);
|
|
263
|
+
});
|
|
264
|
+
} else {
|
|
265
|
+
console.log(`\n[Lark Desktop] Multiple accounts detected:`);
|
|
266
|
+
hashes.forEach((h, i) => {
|
|
267
|
+
const ts = new Date(h.mtimeMs).toISOString();
|
|
268
|
+
console.log(` ${i + 1}. ${h.hash} (last active ${ts})`);
|
|
269
|
+
});
|
|
270
|
+
const pick = (await ask(`Bind profile "${targetProfile}" to which? [1]: `)).trim() || '1';
|
|
271
|
+
const idx = parseInt(pick, 10) - 1;
|
|
272
|
+
chosenHash = (idx >= 0 && idx < hashes.length) ? hashes[idx].hash : hashes[0].hash;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
credentials.setProfileLarkHash(targetProfile, chosenHash);
|
|
276
|
+
console.log(`Bound profile "${targetProfile}" to Lark account hash ${chosenHash}`);
|
|
277
|
+
console.log(` → MCP will auto-switch to this profile when Lark Desktop activates this account.`);
|
|
278
|
+
}
|
|
279
|
+
// hashes.length === 0 → silent (Lark not installed, or non-darwin) — don't disrupt setup
|
|
280
|
+
} catch (e) {
|
|
281
|
+
console.error(`[Lark Desktop] auto-bind skipped: ${e.message}`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
241
285
|
// --- Write harness config ---
|
|
242
286
|
// Always write pointer-only env to harness configs (v1.3.9 SSOT).
|
|
243
287
|
// The harness env block only needs FEISHU_PLUGIN_PROFILE; all real creds
|