feishu-user-plugin 1.3.8 → 1.3.10

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 (42) hide show
  1. package/.claude-plugin/plugin.json +12 -2
  2. package/CHANGELOG.md +100 -12
  3. package/README.en.md +610 -0
  4. package/README.md +292 -532
  5. package/package.json +12 -5
  6. package/proto/lark.proto +10 -0
  7. package/scripts/explore-card-protobuf.js +144 -0
  8. package/scripts/explore-image-minimize.js +163 -0
  9. package/scripts/generate-og-image.js +39 -0
  10. package/scripts/generate-release-artifacts.js +318 -0
  11. package/scripts/probe-feishu-docx.js +203 -0
  12. package/scripts/sync-team-skills.sh +109 -7
  13. package/skills/feishu-user-plugin/SKILL.md +76 -4
  14. package/skills/feishu-user-plugin/references/CLAUDE.md +74 -54
  15. package/src/auth/credentials.js +36 -0
  16. package/src/cli.js +86 -45
  17. package/src/clients/user.js +15 -13
  18. package/src/events/cursor.js +103 -0
  19. package/src/events/event-buffer.js +8 -5
  20. package/src/events/event-log.js +151 -0
  21. package/src/events/index.js +8 -1
  22. package/src/events/lockfile.js +126 -0
  23. package/src/events/owner.js +73 -0
  24. package/src/events/ws-server.js +95 -25
  25. package/src/oauth.js +48 -7
  26. package/src/resolver.js +10 -0
  27. package/src/server.js +248 -29
  28. package/src/setup.js +99 -25
  29. package/src/test-all.js +12 -9
  30. package/src/test-events-cursor.js +56 -0
  31. package/src/test-events-lockfile.js +36 -0
  32. package/src/test-events-log.js +67 -0
  33. package/src/test-events-owner.js +64 -0
  34. package/src/test-fixtures/doc-blocks/sample-1.json +1256 -0
  35. package/src/test-read-doc-markdown.js +61 -0
  36. package/src/test-switch-profile.js +171 -0
  37. package/src/tools/diagnostics.js +10 -3
  38. package/src/tools/docs.js +93 -3
  39. package/src/tools/events.js +143 -33
  40. package/src/tools/messaging-bot.js +2 -3
  41. package/src/tools/messaging-user.js +23 -14
  42. package/src/tools/profile.js +12 -7
@@ -0,0 +1,103 @@
1
+ // src/events/cursor.js — events.cursor.json + drain protocol.
2
+ //
3
+ // cursor.json schema: { version: 1, file: "events.jsonl", offset: <int> }
4
+ // Atomic write: tmp + rename.
5
+ // Drain: take per-operation mutex, read cursor, read events.jsonl[offset:], advance.
6
+
7
+ 'use strict';
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const { withMutex } = require('./lockfile');
12
+ const { readFrom } = require('./event-log');
13
+
14
+ const CURSOR_FILENAME = 'events.cursor.json';
15
+ const CURSOR_LOCK_FILENAME = 'events.cursor.lock';
16
+
17
+ function _cursorPath(dir) { return path.join(dir, CURSOR_FILENAME); }
18
+ function _lockPath(dir) { return path.join(dir, CURSOR_LOCK_FILENAME); }
19
+
20
+ function _readCursor(cursorPath, defaultFileSize) {
21
+ try {
22
+ const raw = fs.readFileSync(cursorPath, 'utf8');
23
+ const parsed = JSON.parse(raw);
24
+ if (!parsed || parsed.version !== 1) throw new Error('bad version');
25
+ if (typeof parsed.file !== 'string' || typeof parsed.offset !== 'number') throw new Error('bad shape');
26
+ return parsed;
27
+ } catch (e) {
28
+ // Conservative reset: skip history (offset = current size), don't replay.
29
+ if (e.code !== 'ENOENT') {
30
+ console.error(`[feishu-user-plugin] cursor.json read failed: ${e.message}; resetting to current EOF.`);
31
+ }
32
+ return { version: 1, file: 'events.jsonl', offset: defaultFileSize };
33
+ }
34
+ }
35
+
36
+ function _writeCursorAtomic(cursorPath, cursor) {
37
+ const tmpPath = cursorPath + '.tmp.' + process.pid;
38
+ fs.writeFileSync(tmpPath, JSON.stringify(cursor) + '\n', { mode: 0o600 });
39
+ fs.renameSync(tmpPath, cursorPath);
40
+ }
41
+
42
+ // Sanity check: offset in [0, fileSize]. Returns clamped cursor.
43
+ function _sanitize(cursor, fileSize) {
44
+ if (cursor.offset < 0 || cursor.offset > fileSize) {
45
+ console.error(`[feishu-user-plugin] cursor offset ${cursor.offset} out of range [0, ${fileSize}]; resetting to EOF.`);
46
+ return { ...cursor, offset: fileSize };
47
+ }
48
+ return cursor;
49
+ }
50
+
51
+ // Drain: read events from current offset; advance cursor (unless peek).
52
+ //
53
+ // Returns { events, nextOffset, advanced }.
54
+ // `events` is the raw event objects from events.jsonl (filter applied by caller).
55
+ function drain(dir, { peek = false } = {}) {
56
+ const logPath = path.join(dir, 'events.jsonl');
57
+ const curPath = _cursorPath(dir);
58
+ const lockPath = _lockPath(dir);
59
+
60
+ return withMutex(lockPath, () => {
61
+ let stat;
62
+ try { stat = fs.statSync(logPath); } catch (e) {
63
+ if (e.code === 'ENOENT') return { events: [], nextOffset: 0, advanced: false };
64
+ throw e;
65
+ }
66
+ let cursor = _readCursor(curPath, stat.size);
67
+ cursor = _sanitize(cursor, stat.size);
68
+
69
+ const { events, nextOffset } = readFrom(logPath, cursor.offset);
70
+ if (!peek && nextOffset !== cursor.offset) {
71
+ _writeCursorAtomic(curPath, { version: 1, file: 'events.jsonl', offset: nextOffset });
72
+ return { events, nextOffset, advanced: true };
73
+ }
74
+ // Always persist cursor.json on first drain (ENOENT) so subsequent calls
75
+ // don't trigger the conservative-reset-to-EOF path.
76
+ if (!peek) {
77
+ const exists = (() => { try { fs.statSync(curPath); return true; } catch (_) { return false; } })();
78
+ if (!exists) _writeCursorAtomic(curPath, { version: 1, file: 'events.jsonl', offset: nextOffset });
79
+ }
80
+ return { events, nextOffset, advanced: false };
81
+ }, { staleMs: 30_000 });
82
+ }
83
+
84
+ // Read cursor without advancing or locking — for diagnostics.
85
+ function readSnapshot(dir) {
86
+ const logPath = path.join(dir, 'events.jsonl');
87
+ const curPath = _cursorPath(dir);
88
+ let fileSize = 0;
89
+ try { fileSize = fs.statSync(logPath).size; } catch (_) {}
90
+ const cursor = _readCursor(curPath, fileSize);
91
+ return { cursor, fileSize, pending: Math.max(0, fileSize - cursor.offset) };
92
+ }
93
+
94
+ // Manual reset (used by force-rotate path).
95
+ function resetCursorTo(dir, offset) {
96
+ const curPath = _cursorPath(dir);
97
+ const lockPath = _lockPath(dir);
98
+ withMutex(lockPath, () => {
99
+ _writeCursorAtomic(curPath, { version: 1, file: 'events.jsonl', offset });
100
+ }, { staleMs: 30_000 });
101
+ }
102
+
103
+ module.exports = { drain, readSnapshot, resetCursorTo, CURSOR_FILENAME };
@@ -1,9 +1,12 @@
1
- // src/events/event-buffer.js — in-memory FIFO buffer for WS events.
1
+ // src/events/event-buffer.js — in-memory FIFO buffer.
2
2
  //
3
- // Single-consumer model: tools/events.js pulls events with `drain()`, which
4
- // removes them from the buffer. If multiple agents read concurrently they'll
5
- // see partial sets explicitly NOT designed to fan out the same event to N
6
- // consumers, since the MCP server already serializes tool calls.
3
+ // v1.3.9: This is now the **disk-full fallback** only. Normal flow writes
4
+ // to events.jsonl via src/events/event-log.js. The owner falls back to this
5
+ // buffer when fs.appendFileSync fails (ENOSPC etc); get_new_events does NOT
6
+ // read from this buffer in the owner-arbitrated path. Pre-v1.3.8 behaviour
7
+ // is preserved when no logPath is configured.
8
+ //
9
+ // (rest of file unchanged)
7
10
  //
8
11
  // What this owns:
9
12
  // - _events: ordered list of events (oldest first)
@@ -0,0 +1,151 @@
1
+ // src/events/event-log.js — events.jsonl management.
2
+ //
3
+ // Single-writer (owner) appends with `\n`-terminated lines.
4
+ // Multi-reader (any process) seeks from cursor offset to EOF, parses lines,
5
+ // tolerates a trailing partial line.
6
+ //
7
+ // On owner takeover, callers should run repairTail() once before appending
8
+ // to ensure append-only invariant is intact.
9
+
10
+ 'use strict';
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ function ensureLog(logPath) {
16
+ try { fs.mkdirSync(path.dirname(logPath), { recursive: true, mode: 0o700 }); } catch (_) {}
17
+ try { fs.openSync(logPath, 'a'); } catch (_) {}
18
+ }
19
+
20
+ function appendEvent(logPath, eventObj) {
21
+ const line = JSON.stringify(eventObj) + '\n';
22
+ fs.appendFileSync(logPath, line, { encoding: 'utf8' });
23
+ }
24
+
25
+ // Read from `offset` to EOF; return { events: [...], nextOffset }.
26
+ // Tolerates a trailing partial line (no \n) — partial line not consumed,
27
+ // nextOffset stops at the last full \n.
28
+ function readFrom(logPath, offset) {
29
+ let stat;
30
+ try { stat = fs.statSync(logPath); } catch (e) {
31
+ if (e.code === 'ENOENT') return { events: [], nextOffset: 0, fileSize: 0 };
32
+ throw e;
33
+ }
34
+ const fileSize = stat.size;
35
+ if (offset >= fileSize) return { events: [], nextOffset: offset, fileSize };
36
+ if (offset < 0) offset = 0;
37
+
38
+ const fd = fs.openSync(logPath, 'r');
39
+ try {
40
+ const length = fileSize - offset;
41
+ const buf = Buffer.allocUnsafe(length);
42
+ fs.readSync(fd, buf, 0, length, offset);
43
+ const text = buf.toString('utf8');
44
+ // Find last \n; everything after it is partial.
45
+ const lastNl = text.lastIndexOf('\n');
46
+ if (lastNl < 0) {
47
+ // Entire chunk is partial; no events consumed.
48
+ return { events: [], nextOffset: offset, fileSize };
49
+ }
50
+ const fullText = text.slice(0, lastNl + 1); // include the \n
51
+ const events = [];
52
+ for (const line of fullText.split('\n')) {
53
+ if (!line) continue;
54
+ try { events.push(JSON.parse(line)); } catch (_) { /* skip malformed */ }
55
+ }
56
+ return { events, nextOffset: offset + Buffer.byteLength(fullText, 'utf8'), fileSize };
57
+ } finally {
58
+ fs.closeSync(fd);
59
+ }
60
+ }
61
+
62
+ // Repair: scan the tail for last \n; truncate file there if file ends with
63
+ // non-\n bytes (partial line from a crash).
64
+ function repairTail(logPath, scanBytes = 8192) {
65
+ let stat;
66
+ try { stat = fs.statSync(logPath); } catch (e) {
67
+ if (e.code === 'ENOENT') return { repaired: false, sizeBefore: 0, sizeAfter: 0 };
68
+ throw e;
69
+ }
70
+ if (stat.size === 0) return { repaired: false, sizeBefore: 0, sizeAfter: 0 };
71
+
72
+ const fd = fs.openSync(logPath, 'r+');
73
+ try {
74
+ const readLen = Math.min(scanBytes, stat.size);
75
+ const buf = Buffer.allocUnsafe(readLen);
76
+ fs.readSync(fd, buf, 0, readLen, stat.size - readLen);
77
+ // If the file already ends in \n, no repair needed.
78
+ if (buf[buf.length - 1] === 0x0A) return { repaired: false, sizeBefore: stat.size, sizeAfter: stat.size };
79
+ // Find last \n in the scanned tail.
80
+ let i = buf.length - 1;
81
+ while (i >= 0 && buf[i] !== 0x0A) i--;
82
+ if (i < 0) {
83
+ // No \n in last 8 KB — pathological. Don't truncate (might lose data); leave as is.
84
+ return { repaired: false, sizeBefore: stat.size, sizeAfter: stat.size, warning: 'no \\n in scan window' };
85
+ }
86
+ const truncateAt = stat.size - readLen + i + 1; // +1 to keep the \n
87
+ fs.ftruncateSync(fd, truncateAt);
88
+ return { repaired: true, sizeBefore: stat.size, sizeAfter: truncateAt };
89
+ } finally {
90
+ fs.closeSync(fd);
91
+ }
92
+ }
93
+
94
+ // Defer-rotate. Returns true if rotation happened.
95
+ //
96
+ // Conditions: size > sizeThresholdBytes AND (cursorOffset >= size - 4096) — i.e.,
97
+ // consumer is within 4 KB of EOF.
98
+ function maybeRotate(logPath, cursorOffset, sizeThresholdBytes) {
99
+ let stat;
100
+ try { stat = fs.statSync(logPath); } catch (e) {
101
+ if (e.code === 'ENOENT') return { rotated: false };
102
+ throw e;
103
+ }
104
+ if (stat.size <= sizeThresholdBytes) return { rotated: false, sizeBytes: stat.size };
105
+ if (cursorOffset < stat.size - 4096) {
106
+ return { rotated: false, deferred: true, sizeBytes: stat.size, cursorOffset };
107
+ }
108
+ const ts = Math.floor(Date.now() / 1000);
109
+ const droppedPath = logPath + '.dropped-' + ts;
110
+ fs.renameSync(logPath, droppedPath);
111
+ // Recreate empty events.jsonl.
112
+ fs.openSync(logPath, 'a');
113
+ return { rotated: true, droppedPath, sizeBytes: stat.size };
114
+ }
115
+
116
+ // Force rotate (called when log > hardCap and consumer is too far behind).
117
+ // Drops the current log to .dropped-<ts>, writes a synthetic _rotated event
118
+ // to the new log so consumers see the warning.
119
+ function forceRotate(logPath, prevSize) {
120
+ const ts = Math.floor(Date.now() / 1000);
121
+ const droppedPath = logPath + '.dropped-' + ts;
122
+ try { fs.renameSync(logPath, droppedPath); } catch (e) {
123
+ if (e.code !== 'ENOENT') throw e;
124
+ }
125
+ appendEvent(logPath, {
126
+ event_id: '_rotated',
127
+ ts: ts * 1000,
128
+ profile: '_system',
129
+ payload: { warning: 'force_rotated_log', prev_size: prevSize, dropped_file: path.basename(droppedPath) },
130
+ });
131
+ return { droppedPath };
132
+ }
133
+
134
+ // Cleanup of old .dropped-<ts> files. Keep last `keepDays` worth.
135
+ function cleanupDropped(logPath, keepDays = 7) {
136
+ const dir = path.dirname(logPath);
137
+ const base = path.basename(logPath);
138
+ const cutoffMs = Date.now() - keepDays * 86400_000;
139
+ let entries;
140
+ try { entries = fs.readdirSync(dir); } catch (_) { return; }
141
+ for (const name of entries) {
142
+ if (!name.startsWith(base + '.dropped-')) continue;
143
+ const fp = path.join(dir, name);
144
+ try {
145
+ const stat = fs.statSync(fp);
146
+ if (stat.mtimeMs < cutoffMs) fs.unlinkSync(fp);
147
+ } catch (_) {}
148
+ }
149
+ }
150
+
151
+ module.exports = { ensureLog, appendEvent, readFrom, repairTail, maybeRotate, forceRotate, cleanupDropped };
@@ -1,5 +1,12 @@
1
1
  // src/events/index.js — barrel import for the events subsystem.
2
2
  const { EventBuffer, DEFAULT_CAP } = require('./event-buffer');
3
3
  const { createWSServer } = require('./ws-server');
4
+ const owner = require('./owner');
5
+ const cursor = require('./cursor');
6
+ const log = require('./event-log');
7
+ const lockfile = require('./lockfile');
4
8
 
5
- module.exports = { EventBuffer, DEFAULT_CAP, createWSServer };
9
+ module.exports = {
10
+ EventBuffer, DEFAULT_CAP, createWSServer,
11
+ owner, cursor, log, lockfile,
12
+ };
@@ -0,0 +1,126 @@
1
+ // src/events/lockfile.js — generic O_CREAT|O_EXCL advisory lock.
2
+ //
3
+ // Two flavors:
4
+ // - acquireLongLived(path, info) → { release(), heartbeat() } for owner-style
5
+ // locks (one process holds for the duration of its lifetime, mtime = liveness).
6
+ // Steal: rename old → .stale-<pid>, then EXCL create. Returns null if active.
7
+ // - withMutex(path, fn, { staleMs }) → runs fn() while holding a per-operation
8
+ // mutex. Stale lock files (mtime older than staleMs) are reaped.
9
+ //
10
+ // Both reuse the same `fs.openSync(p, 'wx')` pattern v1.3.5 UAT lock established.
11
+ // No new dep.
12
+
13
+ 'use strict';
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+
18
+ function _ensureDir(p) {
19
+ try { fs.mkdirSync(path.dirname(p), { recursive: true, mode: 0o700 }); } catch (_) {}
20
+ }
21
+
22
+ function _writeLockBody(fd, info) {
23
+ const body = JSON.stringify({ version: 1, pid: process.pid, start_time: Math.floor(Date.now() / 1000), ...info });
24
+ fs.writeSync(fd, body);
25
+ }
26
+
27
+ // Long-lived (owner) acquisition.
28
+ //
29
+ // Returns { release(), heartbeat() } on success.
30
+ // Returns null if lock active (mtime within staleMs).
31
+ function acquireLongLived(lockPath, { info = {}, staleMs = 60_000 } = {}) {
32
+ _ensureDir(lockPath);
33
+
34
+ // If lock exists, check staleness.
35
+ let stat;
36
+ try { stat = fs.statSync(lockPath); } catch (e) {
37
+ if (e.code !== 'ENOENT') throw e;
38
+ }
39
+ if (stat) {
40
+ const ageMs = Date.now() - stat.mtimeMs;
41
+ if (ageMs < staleMs) return null;
42
+ // Stale — try to steal. Rename out of the way to make room for EXCL create.
43
+ const stolenPath = lockPath + '.stale-' + process.pid + '-' + Date.now();
44
+ try { fs.renameSync(lockPath, stolenPath); } catch (e) {
45
+ // Race: someone else got there first; try again from scratch.
46
+ if (e.code === 'ENOENT') return acquireLongLived(lockPath, { info, staleMs });
47
+ throw e;
48
+ }
49
+ // Schedule cleanup of the stolen file after a moment to avoid disk litter.
50
+ setTimeout(() => { try { fs.unlinkSync(stolenPath); } catch (_) {} }, 5000).unref();
51
+ }
52
+
53
+ // Atomic EXCL create — only one process wins this race.
54
+ let fd;
55
+ try {
56
+ fd = fs.openSync(lockPath, 'wx');
57
+ } catch (e) {
58
+ if (e.code === 'EEXIST') return null; // someone else won
59
+ throw e;
60
+ }
61
+ try {
62
+ _writeLockBody(fd, info);
63
+ } finally {
64
+ fs.closeSync(fd);
65
+ }
66
+
67
+ return {
68
+ release() {
69
+ try { fs.unlinkSync(lockPath); } catch (_) {}
70
+ },
71
+ heartbeat() {
72
+ try {
73
+ const now = new Date();
74
+ fs.utimesSync(lockPath, now, now);
75
+ } catch (e) {
76
+ // If the lock file was stolen, our heartbeat will fail. Caller must
77
+ // detect this via separate check (e.g., re-stat + compare pid in body).
78
+ return false;
79
+ }
80
+ return true;
81
+ },
82
+ };
83
+ }
84
+
85
+ // Per-operation mutex. Synchronous wrapper for short critical sections.
86
+ function withMutex(lockPath, fn, { staleMs = 30_000, retries = 30, retryDelayMs = 100 } = {}) {
87
+ _ensureDir(lockPath);
88
+
89
+ const start = Date.now();
90
+ while (true) {
91
+ // Stale reap.
92
+ try {
93
+ const stat = fs.statSync(lockPath);
94
+ if (Date.now() - stat.mtimeMs > staleMs) {
95
+ try { fs.unlinkSync(lockPath); } catch (_) {}
96
+ }
97
+ } catch (e) {
98
+ if (e.code !== 'ENOENT') throw e;
99
+ }
100
+
101
+ let fd;
102
+ try {
103
+ fd = fs.openSync(lockPath, 'wx');
104
+ } catch (e) {
105
+ if (e.code !== 'EEXIST') throw e;
106
+ // Wait + retry.
107
+ if (Date.now() - start > staleMs) {
108
+ throw new Error(`withMutex: lock ${lockPath} held longer than ${staleMs}ms`);
109
+ }
110
+ const sleepUntil = Date.now() + retryDelayMs;
111
+ while (Date.now() < sleepUntil) { /* busy wait — short delay */ }
112
+ continue;
113
+ }
114
+
115
+ try {
116
+ fs.writeSync(fd, JSON.stringify({ pid: process.pid, ts: Date.now() }));
117
+ fs.closeSync(fd);
118
+ const result = fn();
119
+ return result;
120
+ } finally {
121
+ try { fs.unlinkSync(lockPath); } catch (_) {}
122
+ }
123
+ }
124
+ }
125
+
126
+ module.exports = { acquireLongLived, withMutex };
@@ -0,0 +1,73 @@
1
+ // src/events/owner.js — ws-owner.lock acquire / heartbeat / takeover.
2
+ //
3
+ // Wraps lockfile.acquireLongLived for the WS-ownership use case.
4
+ // Exposes an EventEmitter-style interface: 'become_owner', 'lose_owner', 'state_change'.
5
+
6
+ 'use strict';
7
+
8
+ const path = require('path');
9
+ const fs = require('fs');
10
+ const { acquireLongLived } = require('./lockfile');
11
+
12
+ const OWNER_LOCK_FILENAME = 'ws-owner.lock';
13
+ const HEARTBEAT_INTERVAL_MS = 15_000;
14
+ const STALE_MS = 60_000;
15
+ const TAKEOVER_POLL_INTERVAL_MS = 30_000;
16
+
17
+ function _ownerLockPath(dir) { return path.join(dir, OWNER_LOCK_FILENAME); }
18
+
19
+ // Try to claim ownership (or steal if stale + force, or just steal if stale).
20
+ // Returns:
21
+ // { isOwner: true, release(), heartbeat() } if successful
22
+ // { isOwner: false, ownerInfo } otherwise
23
+ function tryClaim(dir, { info = {}, force = false } = {}) {
24
+ const lockPath = _ownerLockPath(dir);
25
+
26
+ if (force) {
27
+ // Force takeover: rename existing out of the way.
28
+ try { fs.renameSync(lockPath, lockPath + '.forced-' + Date.now()); } catch (e) {
29
+ if (e.code !== 'ENOENT') throw e;
30
+ }
31
+ }
32
+
33
+ const handle = acquireLongLived(lockPath, { info, staleMs: STALE_MS });
34
+ if (!handle) {
35
+ // Read existing lock body for diagnostics.
36
+ let body = null;
37
+ try { body = JSON.parse(fs.readFileSync(lockPath, 'utf8')); } catch (_) {}
38
+ let mtimeMs = null;
39
+ try { mtimeMs = fs.statSync(lockPath).mtimeMs; } catch (_) {}
40
+ return { isOwner: false, ownerInfo: { ...body, mtimeMs, last_heartbeat_age_seconds: mtimeMs ? Math.floor((Date.now() - mtimeMs) / 1000) : null } };
41
+ }
42
+ return { isOwner: true, ...handle };
43
+ }
44
+
45
+ // Read current owner info without modifying anything.
46
+ function readOwnerInfo(dir) {
47
+ const lockPath = _ownerLockPath(dir);
48
+ let body = null;
49
+ let mtimeMs = null;
50
+ try {
51
+ body = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
52
+ mtimeMs = fs.statSync(lockPath).mtimeMs;
53
+ } catch (_) {}
54
+ if (!body) return { exists: false };
55
+ const ageSec = mtimeMs ? Math.floor((Date.now() - mtimeMs) / 1000) : null;
56
+ return {
57
+ exists: true,
58
+ pid: body.pid,
59
+ start_time: body.start_time,
60
+ mtimeMs,
61
+ last_heartbeat_age_seconds: ageSec,
62
+ alive: ageSec !== null && ageSec * 1000 < STALE_MS,
63
+ };
64
+ }
65
+
66
+ module.exports = {
67
+ tryClaim,
68
+ readOwnerInfo,
69
+ OWNER_LOCK_FILENAME,
70
+ HEARTBEAT_INTERVAL_MS,
71
+ STALE_MS,
72
+ TAKEOVER_POLL_INTERVAL_MS,
73
+ };
@@ -1,61 +1,103 @@
1
- // src/events/ws-server.js — Feishu WebSocket subscription wrapper.
2
- //
3
- // Owns the WSClient + EventDispatcher pair. The MCP main() in server.js calls
4
- // startWS() at boot if APP_ID + APP_SECRET are configured; failures are
5
- // logged-and-tolerated (MCP keeps serving tool calls without realtime).
1
+ // src/events/ws-server.js — rewritten for v1.3.9 owner-arbitrated mode.
6
2
  //
7
3
  // What this owns:
8
- // - createWSServer(opts) { buffer, start(), stop() } factory
9
- // - Default event registrations (im.message.receive_v1)
10
- // - Reconnect via SDK's built-in handling (WSClient does this internally)
4
+ // - createWSServer(opts) factory for WS client + event dispatcher.
5
+ // - v1.3.9: In owner mode (logPath provided), events go to events.jsonl
6
+ // directly instead of the in-memory buffer.
7
+ // - Tracks wsState / wsProfile so downstream can filter dropped events.
11
8
  //
12
9
  // What it does NOT own:
13
- // - The buffer's persistence — it's in-memory only.
14
- // - Multi-profile fan-out — single WS per process, per active profile.
10
+ // - Owner-lock acquisition (src/events/owner.js).
11
+ // - Cursor protocol (src/events/cursor.js).
12
+
13
+ 'use strict';
15
14
 
16
15
  const lark = require('@larksuiteoapi/node-sdk');
17
16
  const { EventBuffer, DEFAULT_CAP } = require('./event-buffer');
18
17
  const { stderrLogger } = require('../logger');
18
+ const { appendEvent } = require('./event-log');
19
19
 
20
- // Wrap an SDK event handler so the payload always lands in the buffer with
21
- // a stable shape. The SDK passes the raw event payload we add metadata
22
- // for downstream filtering / display.
23
- function _bufferEventHandler(buffer, eventType) {
20
+ // Build the handler that writes a normalised event row.
21
+ // In v1.3.9 owner mode, we write to events.jsonl directly with a profile tag.
22
+ // In legacy mode (no logPath given) we fall back to the in-memory buffer.
23
+ function _eventRowHandler({ buffer, eventType, getProfile, getWsState, logPath }) {
24
24
  return async (data) => {
25
+ const wsState = getWsState();
26
+ if (wsState !== 'connected') {
27
+ // Drop events received during 'switching' / 'disconnected' — we don't
28
+ // know which profile they belong to.
29
+ return;
30
+ }
25
31
  const event = {
32
+ event_id: data?.event_id || data?.header?.event_id || 'evt_' + Math.random().toString(36).slice(2),
33
+ ts: Date.now(),
34
+ profile: getProfile(),
26
35
  event_type: eventType,
27
- event_id: data?.event_id || data?.header?.event_id || null,
28
- _received_at: Math.floor(Date.now() / 1000),
29
36
  header: data?.header || null,
30
37
  event: data?.event || data,
31
38
  };
32
- buffer.push(event);
39
+ if (logPath) {
40
+ try {
41
+ appendEvent(logPath, event);
42
+ } catch (e) {
43
+ // Disk-full or similar — fall back to in-memory buffer.
44
+ console.error(`[feishu-user-plugin] events.jsonl append failed: ${e.message}; falling back to in-memory buffer`);
45
+ buffer.push(event);
46
+ }
47
+ } else {
48
+ buffer.push(event);
49
+ }
33
50
  };
34
51
  }
35
52
 
36
- function createWSServer({ appId, appSecret, bufferCap = DEFAULT_CAP, registrations = ['im.message.receive_v1'] } = {}) {
53
+ function createWSServer({
54
+ appId, appSecret,
55
+ bufferCap = DEFAULT_CAP,
56
+ registrations = ['im.message.receive_v1'],
57
+ logPath = null, // NEW: when set, events go to events.jsonl
58
+ initialProfile = 'default', // NEW: profile name to tag events with
59
+ } = {}) {
37
60
  if (!appId || !appSecret) throw new Error('createWSServer: appId + appSecret required');
38
61
 
39
62
  const buffer = new EventBuffer({ cap: bufferCap });
40
63
  let wsClient = null;
41
64
  let started = false;
42
65
  let stopped = false;
66
+ let wsProfile = initialProfile;
67
+ let wsState = 'disconnected'; // disconnected | connected | switching
68
+ let lastReconnectAt = null;
69
+ let reconnectAttempts = 0;
43
70
 
44
71
  const dispatcher = new lark.EventDispatcher({
45
72
  logger: stderrLogger,
46
73
  loggerLevel: lark.LoggerLevel.warn,
47
74
  });
48
75
 
49
- // Register handlers for each requested event type.
50
76
  const handlers = {};
51
77
  for (const t of registrations) {
52
- handlers[t] = _bufferEventHandler(buffer, t);
78
+ handlers[t] = _eventRowHandler({
79
+ buffer, eventType: t,
80
+ getProfile: () => wsProfile,
81
+ getWsState: () => wsState,
82
+ logPath,
83
+ });
84
+ }
85
+ try {
86
+ dispatcher.register(handlers);
87
+ } catch (e) {
88
+ console.error(`[feishu-user-plugin] WS event registration failed: ${e.message}; falling back to im.message.receive_v1 only`);
89
+ dispatcher.register({
90
+ 'im.message.receive_v1': _eventRowHandler({
91
+ buffer, eventType: 'im.message.receive_v1',
92
+ getProfile: () => wsProfile, getWsState: () => wsState, logPath,
93
+ }),
94
+ });
53
95
  }
54
- dispatcher.register(handlers);
55
96
 
56
97
  async function start() {
57
98
  if (started) return;
58
99
  started = true;
100
+ wsState = 'switching';
59
101
  wsClient = new lark.WSClient({
60
102
  appId, appSecret,
61
103
  logger: stderrLogger,
@@ -63,24 +105,52 @@ function createWSServer({ appId, appSecret, bufferCap = DEFAULT_CAP, registratio
63
105
  });
64
106
  try {
65
107
  await wsClient.start({ eventDispatcher: dispatcher });
66
- console.error(`[feishu-user-plugin] WS connected — listening for: ${registrations.join(', ')}`);
108
+ wsState = 'connected';
109
+ lastReconnectAt = Date.now();
110
+ console.error(`[feishu-user-plugin] WS connected (profile=${wsProfile}) — listening for: ${registrations.join(', ')}`);
67
111
  } catch (e) {
112
+ wsState = 'disconnected';
113
+ reconnectAttempts++;
68
114
  console.error(`[feishu-user-plugin] WS start failed: ${e.message}. Continuing without realtime events.`);
69
115
  started = false;
70
116
  wsClient = null;
71
117
  }
72
118
  }
73
119
 
74
- function stop() {
120
+ async function stop() {
75
121
  if (stopped) return;
76
122
  stopped = true;
123
+ wsState = 'disconnected';
77
124
  if (wsClient) {
78
- try { wsClient.close(); } catch (e) { console.error(`[feishu-user-plugin] WS close error: ${e.message}`); }
125
+ try { wsClient.close(); } catch (_) {}
79
126
  wsClient = null;
80
127
  }
81
128
  }
82
129
 
83
- return { buffer, start, stop, get isRunning() { return started && !stopped; } };
130
+ // For switching: stop, then start with a new profile/registrations.
131
+ // The registrations list is currently fixed at construction; full switching
132
+ // requires re-creating the WSClient. This helper just stops + nulls so the
133
+ // caller can construct a new server.
134
+ async function reconfigureProfile(newProfile) {
135
+ wsState = 'switching';
136
+ wsProfile = 'switching'; // tag-irrelevant during transition
137
+ await stop();
138
+ started = false; stopped = false;
139
+ wsProfile = newProfile;
140
+ await start();
141
+ }
142
+
143
+ function getStatus() {
144
+ return {
145
+ state: wsState,
146
+ wsProfile,
147
+ subscribed_events: registrations.slice(),
148
+ lastReconnectAt,
149
+ reconnectAttempts,
150
+ };
151
+ }
152
+
153
+ return { buffer, start, stop, reconfigureProfile, getStatus, get isRunning() { return started && !stopped; } };
84
154
  }
85
155
 
86
156
  module.exports = { createWSServer };