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.
- package/.claude-plugin/plugin.json +2 -2
- package/CHANGELOG.md +71 -0
- package/README.md +72 -41
- package/package.json +10 -3
- package/scripts/capture-feishu-protobuf.js +86 -0
- package/scripts/check-changelog.js +31 -0
- package/scripts/check-docs-sync.js +41 -0
- package/scripts/check-tool-count.js +40 -0
- package/scripts/check-version.js +40 -0
- package/scripts/decode-feishu-protobuf.js +115 -0
- package/scripts/smoke.js +224 -0
- package/scripts/sync-claude-md.sh +12 -0
- package/scripts/sync-server-json.js +71 -0
- package/scripts/sync-team-skills.sh +22 -0
- package/scripts/test-all-tools.js +158 -0
- package/scripts/test-wiki-attach-fallback.js +71 -0
- package/scripts/test-ws-events.js +84 -0
- package/skills/feishu-user-plugin/SKILL.md +5 -5
- package/skills/feishu-user-plugin/references/CLAUDE.md +248 -318
- package/skills/feishu-user-plugin/references/table.md +18 -9
- package/src/auth/cookie.js +30 -0
- package/src/auth/credentials.js +399 -0
- package/src/auth/profile-router.js +248 -0
- package/src/auth/uat.js +231 -0
- package/src/cli.js +45 -13
- package/src/clients/official/base.js +188 -0
- package/src/clients/official/bitable.js +269 -0
- package/src/clients/official/calendar.js +176 -0
- package/src/clients/official/contacts.js +54 -0
- package/src/clients/official/docs.js +301 -0
- package/src/clients/official/drive.js +77 -0
- package/src/clients/official/groups.js +68 -0
- package/src/clients/official/im.js +414 -0
- package/src/clients/official/index.js +30 -0
- package/src/clients/official/okr.js +127 -0
- package/src/clients/official/tasks.js +142 -0
- package/src/clients/official/uploads.js +260 -0
- package/src/clients/official/wiki.js +207 -0
- package/src/{client.js → clients/user.js} +25 -33
- package/src/config.js +13 -8
- package/src/events/event-buffer.js +100 -0
- package/src/events/index.js +5 -0
- package/src/events/ws-server.js +86 -0
- package/src/index.js +4 -1977
- package/src/logger.js +20 -0
- package/src/oauth.js +5 -1
- package/src/official.js +5 -1944
- package/src/prompts/_registry.js +69 -0
- package/src/prompts/index.js +54 -0
- package/src/server.js +305 -0
- package/src/setup.js +16 -1
- package/src/test-all.js +2 -2
- package/src/test-comprehensive.js +3 -3
- package/src/test-send.js +1 -1
- package/src/tools/_registry.js +31 -0
- package/src/tools/bitable.js +246 -0
- package/src/tools/calendar.js +207 -0
- package/src/tools/contacts.js +66 -0
- package/src/tools/diagnostics.js +172 -0
- package/src/tools/docs.js +158 -0
- package/src/tools/drive.js +111 -0
- package/src/tools/events.js +64 -0
- package/src/tools/groups.js +81 -0
- package/src/tools/im-read.js +259 -0
- package/src/tools/messaging-bot.js +151 -0
- package/src/tools/messaging-user.js +292 -0
- package/src/tools/okr.js +159 -0
- package/src/tools/profile.js +74 -0
- package/src/tools/tasks.js +168 -0
- package/src/tools/uploads.js +63 -0
- 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('
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,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 };
|