feishu-user-plugin 1.3.6 → 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.
Files changed (71) hide show
  1. package/.claude-plugin/plugin.json +2 -2
  2. package/CHANGELOG.md +71 -0
  3. package/README.md +72 -41
  4. package/package.json +10 -3
  5. package/scripts/capture-feishu-protobuf.js +86 -0
  6. package/scripts/check-changelog.js +31 -0
  7. package/scripts/check-docs-sync.js +41 -0
  8. package/scripts/check-tool-count.js +40 -0
  9. package/scripts/check-version.js +40 -0
  10. package/scripts/decode-feishu-protobuf.js +115 -0
  11. package/scripts/smoke.js +224 -0
  12. package/scripts/sync-claude-md.sh +12 -0
  13. package/scripts/sync-server-json.js +71 -0
  14. package/scripts/sync-team-skills.sh +22 -0
  15. package/scripts/test-all-tools.js +158 -0
  16. package/scripts/test-wiki-attach-fallback.js +71 -0
  17. package/scripts/test-ws-events.js +84 -0
  18. package/skills/feishu-user-plugin/SKILL.md +5 -5
  19. package/skills/feishu-user-plugin/references/CLAUDE.md +248 -318
  20. package/skills/feishu-user-plugin/references/table.md +18 -9
  21. package/src/auth/cookie.js +30 -0
  22. package/src/auth/credentials.js +399 -0
  23. package/src/auth/profile-router.js +248 -0
  24. package/src/auth/uat.js +231 -0
  25. package/src/cli.js +45 -13
  26. package/src/clients/official/base.js +188 -0
  27. package/src/clients/official/bitable.js +269 -0
  28. package/src/clients/official/calendar.js +176 -0
  29. package/src/clients/official/contacts.js +54 -0
  30. package/src/clients/official/docs.js +301 -0
  31. package/src/clients/official/drive.js +77 -0
  32. package/src/clients/official/groups.js +68 -0
  33. package/src/clients/official/im.js +414 -0
  34. package/src/clients/official/index.js +30 -0
  35. package/src/clients/official/okr.js +127 -0
  36. package/src/clients/official/tasks.js +142 -0
  37. package/src/clients/official/uploads.js +260 -0
  38. package/src/clients/official/wiki.js +207 -0
  39. package/src/{client.js → clients/user.js} +25 -33
  40. package/src/config.js +13 -8
  41. package/src/events/event-buffer.js +100 -0
  42. package/src/events/index.js +5 -0
  43. package/src/events/ws-server.js +86 -0
  44. package/src/index.js +4 -1977
  45. package/src/logger.js +20 -0
  46. package/src/oauth.js +5 -1
  47. package/src/official.js +5 -1944
  48. package/src/prompts/_registry.js +69 -0
  49. package/src/prompts/index.js +54 -0
  50. package/src/server.js +305 -0
  51. package/src/setup.js +16 -1
  52. package/src/test-all.js +2 -2
  53. package/src/test-comprehensive.js +3 -3
  54. package/src/test-send.js +1 -1
  55. package/src/tools/_registry.js +31 -0
  56. package/src/tools/bitable.js +246 -0
  57. package/src/tools/calendar.js +207 -0
  58. package/src/tools/contacts.js +66 -0
  59. package/src/tools/diagnostics.js +172 -0
  60. package/src/tools/docs.js +158 -0
  61. package/src/tools/drive.js +111 -0
  62. package/src/tools/events.js +64 -0
  63. package/src/tools/groups.js +81 -0
  64. package/src/tools/im-read.js +259 -0
  65. package/src/tools/messaging-bot.js +151 -0
  66. package/src/tools/messaging-user.js +292 -0
  67. package/src/tools/okr.js +159 -0
  68. package/src/tools/profile.js +74 -0
  69. package/src/tools/tasks.js +168 -0
  70. package/src/tools/uploads.js +63 -0
  71. package/src/tools/wiki.js +191 -0
@@ -1,6 +1,7 @@
1
1
  const path = require('path');
2
2
  const protobuf = require('protobufjs');
3
- const { generateRequestId, generateCid, parseCookie, formatCookie, fetchWithTimeout } = require('./utils');
3
+ const { generateRequestId, generateCid, parseCookie, formatCookie, fetchWithTimeout } = require('../utils');
4
+ const cookieHeartbeat = require('../auth/cookie');
4
5
 
5
6
  const GATEWAY_URL = 'https://internal-api-lark-api.feishu.cn/im/gateway/';
6
7
  const CSRF_URL = 'https://internal-api-lark-api.feishu.cn/accounts/csrf';
@@ -34,7 +35,10 @@ class LarkUserClient {
34
35
  }
35
36
 
36
37
  async init() {
37
- this.proto = await protobuf.load(path.join(__dirname, '..', 'proto', 'lark.proto'));
38
+ // Path: clients/user.js ../../proto/lark.proto. Phase A refactor moved
39
+ // the file from src/client.js to src/clients/user.js but didn't deepen the
40
+ // relative path, so cookie init would ENOENT. Fixed here as part of B2.
41
+ this.proto = await protobuf.load(path.join(__dirname, '..', '..', 'proto', 'lark.proto'));
38
42
  await this._getCsrfToken();
39
43
  await this._getUserInfo();
40
44
  if (!this.userId) {
@@ -83,24 +87,9 @@ class LarkUserClient {
83
87
  }
84
88
 
85
89
  // --- Cookie Heartbeat ---
86
-
87
- _startHeartbeat() {
88
- // Refresh CSRF token every 4 hours to keep session alive
89
- // Feishu sl_session has 12h max-age; CSRF refresh also refreshes sl_session
90
- this._heartbeatTimer = setInterval(async () => {
91
- try {
92
- await this._getCsrfToken();
93
- // Lazy require to avoid circular dependency at module load time
94
- const { persistToConfig } = require('./config');
95
- persistToConfig({ LARK_COOKIE: this.cookieStr });
96
- console.error('[feishu-user-plugin] Cookie heartbeat: session refreshed and persisted');
97
- } catch (e) {
98
- console.error('[feishu-user-plugin] Cookie heartbeat failed:', e.message);
99
- }
100
- }, 4 * 60 * 60 * 1000); // 4 hours
101
- // Don't keep the process alive just for heartbeat
102
- if (this._heartbeatTimer.unref) this._heartbeatTimer.unref();
103
- }
90
+ // Body extracted to src/auth/cookie.js (v1.3.8 D.2). Timer state stays on
91
+ // this instance; auth/cookie.js mutates this._heartbeatTimer.
92
+ _startHeartbeat() { cookieHeartbeat.startHeartbeat(this); }
104
93
 
105
94
  async checkSession() {
106
95
  try {
@@ -195,7 +184,22 @@ class LarkUserClient {
195
184
  if (rootId) req.rootId = rootId;
196
185
  if (parentId) req.parentId = parentId;
197
186
  const { packet, ok } = await this._gateway(5, 'PutMessageRequest', req, '5.7.0');
198
- return { success: ok && (packet.status === 0 || packet.status == null), status: packet.status };
187
+ if (!ok) {
188
+ // The cookie protobuf gateway returns HTTP 400 when our wire format is
189
+ // missing required fields. Verified for IMAGE (v1.3.7 testing): the
190
+ // simple {imageKey} content payload is rejected — Feishu Web encodes
191
+ // images with extra metadata (image dimensions, mime type, etc.) that
192
+ // we don't have in proto/lark.proto. v1.3.8 shipped the capture/decode
193
+ // tooling (scripts/decode-feishu-protobuf.js + capture-feishu-protobuf.js
194
+ // + docs/COOKIE-PROTOBUF-CAPTURES.md). Actual reverse-engineering moved
195
+ // to v1.3.9. Surface a clear error routing the user to
196
+ // send_message_as_bot, which works.
197
+ if (type === MsgType.IMAGE) {
198
+ throw new Error('send_image_as_user: Feishu cookie protobuf gateway rejected the IMAGE wire format (HTTP 400). User-identity image sends are not yet supported — wire format reverse-engineering is deferred to v1.3.9 (v1.3.8 shipped the capture/decode tooling at scripts/decode-feishu-protobuf.js). Workaround: use send_message_as_bot(chat_id, msg_type="image", payload={image_key:"..."}).');
199
+ }
200
+ throw new Error(`_sendMsg: cookie protobuf gateway returned non-2xx for type=${type}. The wire format likely doesn't match what Feishu expects.`);
201
+ }
202
+ return { success: true, status: packet.status };
199
203
  }
200
204
 
201
205
  // --- Send Text Message ---
@@ -275,18 +279,6 @@ class LarkUserClient {
275
279
  return this._sendMsg(MsgType.FILE, chatId, { fileKey, fileName }, opts);
276
280
  }
277
281
 
278
- // --- Send Audio ---
279
-
280
- async sendAudio(chatId, audioKey, opts = {}) {
281
- return this._sendMsg(MsgType.AUDIO, chatId, { audioKey }, opts);
282
- }
283
-
284
- // --- Send Sticker ---
285
-
286
- async sendSticker(chatId, stickerId, stickerSetId, opts = {}) {
287
- return this._sendMsg(MsgType.STICKER, chatId, { stickerId, stickerSetId }, opts);
288
- }
289
-
290
282
  // --- Send Rich Text / POST ---
291
283
 
292
284
  async sendPost(chatId, title, paragraphs, opts = {}) {
package/src/config.js CHANGED
@@ -274,23 +274,23 @@ function persistToConfig(updates) {
274
274
  * client: 'claude' (default) | 'codex' | 'both'
275
275
  * @returns {{ configPath: string, codexConfigPath?: string }}
276
276
  */
277
- function writeNewConfig(env, configPath, projectPath, client) {
277
+ function writeNewConfig(env, configPath, projectPath, client, options = {}) {
278
278
  const results = {};
279
279
 
280
280
  // --- Claude Code (JSON) ---
281
281
  if (client !== 'codex') {
282
- results.configPath = _writeClaudeConfig(env, configPath, projectPath);
282
+ results.configPath = _writeClaudeConfig(env, configPath, projectPath, options);
283
283
  }
284
284
 
285
285
  // --- Codex (TOML) ---
286
286
  if (client === 'codex' || client === 'both') {
287
- results.codexConfigPath = _writeCodexConfig(env);
287
+ results.codexConfigPath = _writeCodexConfig(env, options);
288
288
  }
289
289
 
290
290
  return results;
291
291
  }
292
292
 
293
- function _writeClaudeConfig(env, configPath, projectPath) {
293
+ function _writeClaudeConfig(env, configPath, projectPath, options = {}) {
294
294
  if (!configPath) {
295
295
  configPath = path.join(process.env.HOME || '', '.claude.json');
296
296
  }
@@ -312,7 +312,9 @@ function _writeClaudeConfig(env, configPath, projectPath) {
312
312
  const serverEntry = {
313
313
  command: 'npx',
314
314
  args: ['-y', 'feishu-user-plugin'],
315
- env,
315
+ env: options.pointerOnly
316
+ ? { FEISHU_PLUGIN_PROFILE: env.FEISHU_PLUGIN_PROFILE || 'default' }
317
+ : env,
316
318
  };
317
319
 
318
320
  if (projectPath && config.projects?.[projectPath]) {
@@ -334,7 +336,7 @@ function _writeClaudeConfig(env, configPath, projectPath) {
334
336
  return configPath;
335
337
  }
336
338
 
337
- function _writeCodexConfig(env) {
339
+ function _writeCodexConfig(env, options = {}) {
338
340
  const home = process.env.HOME || '';
339
341
  const codexDir = path.join(home, '.codex');
340
342
  const configPath = path.join(codexDir, 'config.toml');
@@ -352,8 +354,11 @@ function _writeCodexConfig(env) {
352
354
  content = _removeTomlServer(content, name);
353
355
  }
354
356
 
355
- // Append new entry
356
- content = content.trimEnd() + '\n\n' + _generateTomlServerEntry('feishu-user-plugin', env);
357
+ // Append new entry — pointer-only writes only FEISHU_PLUGIN_PROFILE
358
+ const envToWrite = options.pointerOnly
359
+ ? { FEISHU_PLUGIN_PROFILE: env.FEISHU_PLUGIN_PROFILE || 'default' }
360
+ : env;
361
+ content = content.trimEnd() + '\n\n' + _generateTomlServerEntry('feishu-user-plugin', envToWrite);
357
362
 
358
363
  _atomicWrite(configPath, content);
359
364
  console.error(`[feishu-user-plugin] Codex config written to ${configPath}`);
@@ -0,0 +1,100 @@
1
+ // src/events/event-buffer.js — in-memory FIFO buffer for WS events.
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.
7
+ //
8
+ // What this owns:
9
+ // - _events: ordered list of events (oldest first)
10
+ // - cap: max retained; oldest dropped when full
11
+ // - push(event): append + trim
12
+ // - drain(filter?): remove and return matching events
13
+ // - peek(filter?): return matching events without removing
14
+ // - size, cap accessors
15
+
16
+ const DEFAULT_CAP = 1000;
17
+
18
+ class EventBuffer {
19
+ constructor({ cap = DEFAULT_CAP } = {}) {
20
+ this._events = [];
21
+ this._cap = Math.max(1, cap | 0);
22
+ this._totalSeen = 0;
23
+ this._totalDropped = 0;
24
+ }
25
+
26
+ push(event) {
27
+ if (!event || typeof event !== 'object') return;
28
+ if (!event._received_at) event._received_at = Math.floor(Date.now() / 1000);
29
+ this._events.push(event);
30
+ this._totalSeen++;
31
+ while (this._events.length > this._cap) {
32
+ this._events.shift();
33
+ this._totalDropped++;
34
+ }
35
+ }
36
+
37
+ drain(filter) {
38
+ if (!filter) {
39
+ const out = this._events;
40
+ this._events = [];
41
+ return out;
42
+ }
43
+ const fn = this._compileFilter(filter);
44
+ const kept = [];
45
+ const drained = [];
46
+ for (const e of this._events) {
47
+ if (fn(e)) drained.push(e);
48
+ else kept.push(e);
49
+ }
50
+ this._events = kept;
51
+ return drained;
52
+ }
53
+
54
+ peek(filter) {
55
+ if (!filter) return [...this._events];
56
+ const fn = this._compileFilter(filter);
57
+ return this._events.filter(fn);
58
+ }
59
+
60
+ size() { return this._events.length; }
61
+ cap() { return this._cap; }
62
+ stats() {
63
+ return {
64
+ size: this._events.length,
65
+ cap: this._cap,
66
+ totalSeen: this._totalSeen,
67
+ totalDropped: this._totalDropped,
68
+ };
69
+ }
70
+
71
+ // Filter language (intentionally narrow — extend on demand):
72
+ // { event_type: "im.message.receive_v1" } — exact match on type
73
+ // { chat_id: "oc_zzz" } — extract from event payload
74
+ // { since_seconds: 60 } — only events received in last N sec
75
+ // { event_types: ["a", "b"] } — any of these types
76
+ // Multiple keys = AND.
77
+ _compileFilter(filter) {
78
+ return (e) => {
79
+ if (filter.event_type && e.event_type !== filter.event_type) return false;
80
+ if (filter.event_types && !filter.event_types.includes(e.event_type)) return false;
81
+ if (filter.chat_id) {
82
+ const chatId = this._extractChatId(e);
83
+ if (chatId !== filter.chat_id) return false;
84
+ }
85
+ if (filter.since_seconds) {
86
+ const cutoff = Math.floor(Date.now() / 1000) - filter.since_seconds;
87
+ if ((e._received_at || 0) < cutoff) return false;
88
+ }
89
+ return true;
90
+ };
91
+ }
92
+
93
+ _extractChatId(e) {
94
+ return e?.event?.message?.chat_id
95
+ || e?.event?.chat_id
96
+ || null;
97
+ }
98
+ }
99
+
100
+ module.exports = { EventBuffer, DEFAULT_CAP };
@@ -0,0 +1,5 @@
1
+ // src/events/index.js — barrel import for the events subsystem.
2
+ const { EventBuffer, DEFAULT_CAP } = require('./event-buffer');
3
+ const { createWSServer } = require('./ws-server');
4
+
5
+ module.exports = { EventBuffer, DEFAULT_CAP, createWSServer };
@@ -0,0 +1,86 @@
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).
6
+ //
7
+ // 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)
11
+ //
12
+ // 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.
15
+
16
+ const lark = require('@larksuiteoapi/node-sdk');
17
+ const { EventBuffer, DEFAULT_CAP } = require('./event-buffer');
18
+ const { stderrLogger } = require('../logger');
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) {
24
+ return async (data) => {
25
+ const event = {
26
+ event_type: eventType,
27
+ event_id: data?.event_id || data?.header?.event_id || null,
28
+ _received_at: Math.floor(Date.now() / 1000),
29
+ header: data?.header || null,
30
+ event: data?.event || data,
31
+ };
32
+ buffer.push(event);
33
+ };
34
+ }
35
+
36
+ function createWSServer({ appId, appSecret, bufferCap = DEFAULT_CAP, registrations = ['im.message.receive_v1'] } = {}) {
37
+ if (!appId || !appSecret) throw new Error('createWSServer: appId + appSecret required');
38
+
39
+ const buffer = new EventBuffer({ cap: bufferCap });
40
+ let wsClient = null;
41
+ let started = false;
42
+ let stopped = false;
43
+
44
+ const dispatcher = new lark.EventDispatcher({
45
+ logger: stderrLogger,
46
+ loggerLevel: lark.LoggerLevel.warn,
47
+ });
48
+
49
+ // Register handlers for each requested event type.
50
+ const handlers = {};
51
+ for (const t of registrations) {
52
+ handlers[t] = _bufferEventHandler(buffer, t);
53
+ }
54
+ dispatcher.register(handlers);
55
+
56
+ async function start() {
57
+ if (started) return;
58
+ started = true;
59
+ wsClient = new lark.WSClient({
60
+ appId, appSecret,
61
+ logger: stderrLogger,
62
+ loggerLevel: lark.LoggerLevel.warn,
63
+ });
64
+ try {
65
+ await wsClient.start({ eventDispatcher: dispatcher });
66
+ console.error(`[feishu-user-plugin] WS connected — listening for: ${registrations.join(', ')}`);
67
+ } catch (e) {
68
+ console.error(`[feishu-user-plugin] WS start failed: ${e.message}. Continuing without realtime events.`);
69
+ started = false;
70
+ wsClient = null;
71
+ }
72
+ }
73
+
74
+ function stop() {
75
+ if (stopped) return;
76
+ stopped = true;
77
+ if (wsClient) {
78
+ try { wsClient.close(); } catch (e) { console.error(`[feishu-user-plugin] WS close error: ${e.message}`); }
79
+ wsClient = null;
80
+ }
81
+ }
82
+
83
+ return { buffer, start, stop, get isRunning() { return started && !stopped; } };
84
+ }
85
+
86
+ module.exports = { createWSServer };