pikiclaw 0.2.35
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/LICENSE +21 -0
- package/README.md +315 -0
- package/dist/agent-driver.js +24 -0
- package/dist/bot-command-ui.js +299 -0
- package/dist/bot-commands.js +236 -0
- package/dist/bot-feishu-render.js +527 -0
- package/dist/bot-feishu.js +752 -0
- package/dist/bot-handler.js +115 -0
- package/dist/bot-menu.js +44 -0
- package/dist/bot-streaming.js +165 -0
- package/dist/bot-telegram-directory.js +74 -0
- package/dist/bot-telegram-live-preview.js +192 -0
- package/dist/bot-telegram-render.js +369 -0
- package/dist/bot-telegram.js +789 -0
- package/dist/bot.js +897 -0
- package/dist/channel-base.js +46 -0
- package/dist/channel-feishu.js +873 -0
- package/dist/channel-states.js +3 -0
- package/dist/channel-telegram.js +773 -0
- package/dist/cli-channels.js +24 -0
- package/dist/cli.js +484 -0
- package/dist/code-agent.js +1080 -0
- package/dist/config-validation.js +244 -0
- package/dist/dashboard-ui.js +31 -0
- package/dist/dashboard.js +840 -0
- package/dist/driver-claude.js +520 -0
- package/dist/driver-codex.js +1055 -0
- package/dist/driver-gemini.js +230 -0
- package/dist/mcp-bridge.js +192 -0
- package/dist/mcp-session-server.js +321 -0
- package/dist/onboarding.js +138 -0
- package/dist/process-control.js +259 -0
- package/dist/run.js +275 -0
- package/dist/session-status.js +43 -0
- package/dist/setup-wizard.js +231 -0
- package/dist/user-config.js +195 -0
- package/package.json +60 -0
|
@@ -0,0 +1,873 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feishu channel — Feishu/Lark Open Platform transport using official SDK.
|
|
3
|
+
*
|
|
4
|
+
* Uses @larksuiteoapi/node-sdk for:
|
|
5
|
+
* - WSClient + EventDispatcher: WebSocket event receiving with auto-reconnect
|
|
6
|
+
* - Client.im: message send/edit/delete, image/file upload, resource download
|
|
7
|
+
* - Automatic tenant_access_token management
|
|
8
|
+
*
|
|
9
|
+
* CardKit streaming APIs (typewriter effect) use Client.request() directly
|
|
10
|
+
* since the SDK doesn't wrap them yet.
|
|
11
|
+
*/
|
|
12
|
+
import * as lark from '@larksuiteoapi/node-sdk';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { Channel, DEFAULT_CHANNEL_CAPABILITIES, sleep, } from './channel-base.js';
|
|
16
|
+
export { FeishuChannel };
|
|
17
|
+
const FEISHU_CARD_MAX = 28_000; // card markdown budget (card JSON limit ~30KB)
|
|
18
|
+
const PHOTO_EXTS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
|
|
19
|
+
const FEISHU_WS_START_RETRY_MAX_DELAY_MS = 60_000;
|
|
20
|
+
function describeError(err) {
|
|
21
|
+
if (!(err instanceof Error))
|
|
22
|
+
return String(err ?? 'unknown error');
|
|
23
|
+
const parts = [`${err.name}: ${err.message}`];
|
|
24
|
+
for (const key of ['code', 'errno', 'syscall', 'address', 'port', 'host', 'hostname']) {
|
|
25
|
+
const value = err?.[key];
|
|
26
|
+
if (value != null && value !== '')
|
|
27
|
+
parts.push(`${key}=${value}`);
|
|
28
|
+
}
|
|
29
|
+
return parts.join(' | ');
|
|
30
|
+
}
|
|
31
|
+
function isRetryableWsStartError(err) {
|
|
32
|
+
const text = describeError(err).toLowerCase();
|
|
33
|
+
return [
|
|
34
|
+
'socket hang up',
|
|
35
|
+
'econnreset',
|
|
36
|
+
'etimedout',
|
|
37
|
+
'econnrefused',
|
|
38
|
+
'enotfound',
|
|
39
|
+
'eai_again',
|
|
40
|
+
'fetch failed',
|
|
41
|
+
'timeout',
|
|
42
|
+
'bad gateway',
|
|
43
|
+
'service unavailable',
|
|
44
|
+
'gateway timeout',
|
|
45
|
+
].some(token => text.includes(token));
|
|
46
|
+
}
|
|
47
|
+
function isRetryableUploadError(err) {
|
|
48
|
+
const text = describeError(err).toLowerCase();
|
|
49
|
+
return [
|
|
50
|
+
'socket hang up',
|
|
51
|
+
'econnreset',
|
|
52
|
+
'etimedout',
|
|
53
|
+
'econnrefused',
|
|
54
|
+
'enotfound',
|
|
55
|
+
'eai_again',
|
|
56
|
+
'fetch failed',
|
|
57
|
+
'timeout',
|
|
58
|
+
'temporarily unavailable',
|
|
59
|
+
'internal server error',
|
|
60
|
+
'bad gateway',
|
|
61
|
+
'service unavailable',
|
|
62
|
+
'gateway timeout',
|
|
63
|
+
].some(token => text.includes(token));
|
|
64
|
+
}
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Card builder helper
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
function inferActionLayout(actions) {
|
|
69
|
+
if (actions.length >= 3)
|
|
70
|
+
return 'trisection';
|
|
71
|
+
if (actions.length === 2)
|
|
72
|
+
return 'bisected';
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
function chunkActionRows(actions, size = 3) {
|
|
76
|
+
const rows = [];
|
|
77
|
+
for (let i = 0; i < actions.length; i += size) {
|
|
78
|
+
const rowActions = actions.slice(i, i + size).filter(Boolean);
|
|
79
|
+
if (!rowActions.length)
|
|
80
|
+
continue;
|
|
81
|
+
rows.push({ actions: rowActions, layout: inferActionLayout(rowActions) });
|
|
82
|
+
}
|
|
83
|
+
return rows;
|
|
84
|
+
}
|
|
85
|
+
function keyboardToRows(keyboard) {
|
|
86
|
+
const explicitRows = Array.isArray(keyboard?.rows)
|
|
87
|
+
? keyboard.rows
|
|
88
|
+
.filter((row) => Array.isArray(row?.actions) && row.actions.length)
|
|
89
|
+
.map((row) => ({
|
|
90
|
+
actions: row.actions.filter(Boolean),
|
|
91
|
+
layout: row.layout || inferActionLayout(row.actions),
|
|
92
|
+
}))
|
|
93
|
+
: [];
|
|
94
|
+
if (explicitRows.length)
|
|
95
|
+
return explicitRows;
|
|
96
|
+
const actions = Array.isArray(keyboard?.actions)
|
|
97
|
+
? keyboard.actions.filter(Boolean)
|
|
98
|
+
: [];
|
|
99
|
+
return chunkActionRows(actions);
|
|
100
|
+
}
|
|
101
|
+
function buildCardFromView(view) {
|
|
102
|
+
const content = view.markdown.length > FEISHU_CARD_MAX
|
|
103
|
+
? view.markdown.slice(0, FEISHU_CARD_MAX) + '\n\n...(truncated)'
|
|
104
|
+
: view.markdown;
|
|
105
|
+
const card = {
|
|
106
|
+
config: { wide_screen_mode: true, update_multi: true },
|
|
107
|
+
elements: [{ tag: 'markdown', content }],
|
|
108
|
+
};
|
|
109
|
+
if (view.title) {
|
|
110
|
+
card.header = {
|
|
111
|
+
template: view.template || 'blue',
|
|
112
|
+
title: { content: view.title, tag: 'plain_text' },
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
for (const row of view.rows || []) {
|
|
116
|
+
const actions = row.actions.filter(Boolean);
|
|
117
|
+
if (!actions.length)
|
|
118
|
+
continue;
|
|
119
|
+
const element = {
|
|
120
|
+
tag: 'action',
|
|
121
|
+
actions,
|
|
122
|
+
};
|
|
123
|
+
const layout = row.layout || inferActionLayout(actions);
|
|
124
|
+
if (layout)
|
|
125
|
+
element.layout = layout;
|
|
126
|
+
card.elements.push(element);
|
|
127
|
+
}
|
|
128
|
+
return card;
|
|
129
|
+
}
|
|
130
|
+
function buildCard(markdown, opts) {
|
|
131
|
+
return buildCardFromView({
|
|
132
|
+
markdown,
|
|
133
|
+
title: opts?.title,
|
|
134
|
+
template: opts?.template,
|
|
135
|
+
rows: opts?.rows,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// FeishuChannel
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
class FeishuChannel extends Channel {
|
|
142
|
+
capabilities = {
|
|
143
|
+
...DEFAULT_CHANNEL_CAPABILITIES,
|
|
144
|
+
editMessages: true,
|
|
145
|
+
typingIndicators: false,
|
|
146
|
+
commandMenu: true,
|
|
147
|
+
callbackActions: true,
|
|
148
|
+
messageReactions: false,
|
|
149
|
+
fileUpload: true,
|
|
150
|
+
fileDownload: true,
|
|
151
|
+
threads: false,
|
|
152
|
+
};
|
|
153
|
+
appId;
|
|
154
|
+
appSecret;
|
|
155
|
+
domain;
|
|
156
|
+
workdir;
|
|
157
|
+
allowedChatIds;
|
|
158
|
+
client;
|
|
159
|
+
wsClient = null;
|
|
160
|
+
eventDispatcher;
|
|
161
|
+
running = false;
|
|
162
|
+
messageChains = new Map();
|
|
163
|
+
/** Tracks CardKit streaming cards: messageId → { cardId, sequence } */
|
|
164
|
+
cardStates = new Map();
|
|
165
|
+
/** Maps open_id → chat_id for resolving menu event context. */
|
|
166
|
+
_openIdToChat = new Map();
|
|
167
|
+
_hCommand = null;
|
|
168
|
+
_hMessage = null;
|
|
169
|
+
_hCardAction = null;
|
|
170
|
+
_hError = null;
|
|
171
|
+
knownChats = new Set();
|
|
172
|
+
/** Resolves when wsClient.start() settles (used by listen() to block). */
|
|
173
|
+
_listenResolve = null;
|
|
174
|
+
constructor(opts) {
|
|
175
|
+
super();
|
|
176
|
+
this.appId = opts.appId;
|
|
177
|
+
this.appSecret = opts.appSecret;
|
|
178
|
+
this.domain = (opts.domain ?? 'https://open.feishu.cn').replace(/\/+$/, '');
|
|
179
|
+
this.workdir = opts.workdir ?? process.cwd();
|
|
180
|
+
this.allowedChatIds = opts.allowedChatIds ?? new Set();
|
|
181
|
+
// Resolve SDK domain enum or custom string
|
|
182
|
+
const sdkDomain = this.domain.includes('larksuite.com')
|
|
183
|
+
? lark.Domain.Lark
|
|
184
|
+
: this.domain === 'https://open.feishu.cn'
|
|
185
|
+
? lark.Domain.Feishu
|
|
186
|
+
: this.domain;
|
|
187
|
+
this.client = new lark.Client({
|
|
188
|
+
appId: this.appId,
|
|
189
|
+
appSecret: this.appSecret,
|
|
190
|
+
domain: sdkDomain,
|
|
191
|
+
loggerLevel: lark.LoggerLevel.warn,
|
|
192
|
+
});
|
|
193
|
+
this.eventDispatcher = new lark.EventDispatcher({});
|
|
194
|
+
this._registerEvents();
|
|
195
|
+
}
|
|
196
|
+
// ---- Hook registration ---------------------------------------------------
|
|
197
|
+
onCommand(h) { this._hCommand = h; }
|
|
198
|
+
onMessage(h) { this._hMessage = h; }
|
|
199
|
+
onCallback(h) { this._hCardAction = h; }
|
|
200
|
+
onError(h) { this._hError = h; }
|
|
201
|
+
// ========================================================================
|
|
202
|
+
// Lifecycle
|
|
203
|
+
// ========================================================================
|
|
204
|
+
async connect() {
|
|
205
|
+
// Get bot info via raw request (SDK doesn't have a dedicated bot info method)
|
|
206
|
+
try {
|
|
207
|
+
const resp = await this.client.request({
|
|
208
|
+
method: 'GET',
|
|
209
|
+
url: '/open-apis/bot/v3/info',
|
|
210
|
+
data: {},
|
|
211
|
+
});
|
|
212
|
+
const info = resp?.bot;
|
|
213
|
+
this.bot = {
|
|
214
|
+
id: info?.open_id || this.appId,
|
|
215
|
+
username: info?.app_name || 'pikiclaw',
|
|
216
|
+
displayName: info?.app_name || 'pikiclaw',
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
this.bot = { id: this.appId, username: 'pikiclaw', displayName: 'pikiclaw' };
|
|
221
|
+
}
|
|
222
|
+
return this.bot;
|
|
223
|
+
}
|
|
224
|
+
async listen() {
|
|
225
|
+
this.running = true;
|
|
226
|
+
let retryDelayMs = 3_000;
|
|
227
|
+
while (this.running) {
|
|
228
|
+
const sdkDomain = this.domain.includes('larksuite.com')
|
|
229
|
+
? lark.Domain.Lark
|
|
230
|
+
: this.domain === 'https://open.feishu.cn'
|
|
231
|
+
? lark.Domain.Feishu
|
|
232
|
+
: this.domain;
|
|
233
|
+
this.wsClient = new lark.WSClient({
|
|
234
|
+
appId: this.appId,
|
|
235
|
+
appSecret: this.appSecret,
|
|
236
|
+
domain: sdkDomain,
|
|
237
|
+
loggerLevel: lark.LoggerLevel.warn,
|
|
238
|
+
autoReconnect: true,
|
|
239
|
+
});
|
|
240
|
+
this._log('[ws] starting SDK WSClient...');
|
|
241
|
+
try {
|
|
242
|
+
await this.wsClient.start({ eventDispatcher: this.eventDispatcher });
|
|
243
|
+
this._log('[ws] WSClient started, listening for events');
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
try {
|
|
248
|
+
this.wsClient.close({ force: true });
|
|
249
|
+
}
|
|
250
|
+
catch { }
|
|
251
|
+
this.wsClient = null;
|
|
252
|
+
if (!this.running)
|
|
253
|
+
return;
|
|
254
|
+
if (!isRetryableWsStartError(err))
|
|
255
|
+
throw err;
|
|
256
|
+
this._log(`[ws] start failed: ${describeError(err)} — retrying in ${Math.ceil(retryDelayMs / 1000)}s`);
|
|
257
|
+
await sleep(retryDelayMs);
|
|
258
|
+
retryDelayMs = Math.min(retryDelayMs * 2, FEISHU_WS_START_RETRY_MAX_DELAY_MS);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (!this.running || !this.wsClient)
|
|
262
|
+
return;
|
|
263
|
+
// Block until disconnect() is called
|
|
264
|
+
await new Promise(resolve => {
|
|
265
|
+
this._listenResolve = resolve;
|
|
266
|
+
if (!this.running)
|
|
267
|
+
resolve();
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
disconnect() {
|
|
271
|
+
this.running = false;
|
|
272
|
+
if (this.wsClient) {
|
|
273
|
+
try {
|
|
274
|
+
this.wsClient.close({ force: true });
|
|
275
|
+
}
|
|
276
|
+
catch { }
|
|
277
|
+
this.wsClient = null;
|
|
278
|
+
}
|
|
279
|
+
this._listenResolve?.();
|
|
280
|
+
this._listenResolve = null;
|
|
281
|
+
}
|
|
282
|
+
// ========================================================================
|
|
283
|
+
// Event handling (via SDK EventDispatcher)
|
|
284
|
+
// ========================================================================
|
|
285
|
+
_registerEvents() {
|
|
286
|
+
this.eventDispatcher.register({
|
|
287
|
+
'im.message.receive_v1': async (data) => {
|
|
288
|
+
try {
|
|
289
|
+
await this._handleMessageEvent(data);
|
|
290
|
+
}
|
|
291
|
+
catch (e) {
|
|
292
|
+
this._log(`[dispatch] error: ${e}`);
|
|
293
|
+
this._hError?.(e instanceof Error ? e : new Error(String(e)));
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
'card.action.trigger': (data) => {
|
|
297
|
+
void this._dispatchCardAction(data).catch(e => {
|
|
298
|
+
this._log(`[card-action] error: ${e}`);
|
|
299
|
+
this._hError?.(e instanceof Error ? e : new Error(String(e)));
|
|
300
|
+
});
|
|
301
|
+
return {};
|
|
302
|
+
},
|
|
303
|
+
'application.bot.menu_v6': (data) => {
|
|
304
|
+
void this._dispatchMenuEvent(data).catch(e => {
|
|
305
|
+
this._log(`[menu] error: ${e}`);
|
|
306
|
+
this._hError?.(e instanceof Error ? e : new Error(String(e)));
|
|
307
|
+
});
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
async _handleMessageEvent(event) {
|
|
312
|
+
const msg = event?.message;
|
|
313
|
+
if (!msg)
|
|
314
|
+
return;
|
|
315
|
+
const chatId = msg.chat_id;
|
|
316
|
+
const messageId = msg.message_id;
|
|
317
|
+
const chatType = msg.chat_type === 'p2p' ? 'p2p' : 'group';
|
|
318
|
+
const msgType = msg.message_type;
|
|
319
|
+
if (!chatId || !messageId)
|
|
320
|
+
return;
|
|
321
|
+
if (!this._isAllowed(chatId)) {
|
|
322
|
+
this._log(`[recv] blocked: chat=${chatId} not allowed`);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
this.knownChats.add(chatId);
|
|
326
|
+
const sender = event.sender;
|
|
327
|
+
// Skip messages from the bot itself
|
|
328
|
+
if (sender?.sender_type === 'app')
|
|
329
|
+
return;
|
|
330
|
+
const from = {
|
|
331
|
+
openId: sender?.sender_id?.open_id || '',
|
|
332
|
+
userId: sender?.sender_id?.user_id,
|
|
333
|
+
name: '',
|
|
334
|
+
};
|
|
335
|
+
// Track open_id → chat_id for menu event resolution
|
|
336
|
+
if (from.openId)
|
|
337
|
+
this._openIdToChat.set(from.openId, chatId);
|
|
338
|
+
const fromDesc = from.userId || from.openId || '?';
|
|
339
|
+
this._log(`[recv] message chat=${chatId} from=${fromDesc} msg_id=${messageId} type=${msgType}`);
|
|
340
|
+
// Group: require @mention
|
|
341
|
+
if (chatType === 'group' && !this._isBotMentioned(msg)) {
|
|
342
|
+
this._log(`[recv] skipped: not mentioned in group ${chatId}`);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const ctx = this._makeCtx(chatId, messageId, from, chatType, event);
|
|
346
|
+
// Parse message content
|
|
347
|
+
let text = '';
|
|
348
|
+
const files = [];
|
|
349
|
+
try {
|
|
350
|
+
const content = JSON.parse(msg.content || '{}');
|
|
351
|
+
if (msgType === 'text') {
|
|
352
|
+
text = this._cleanMention(content.text || '');
|
|
353
|
+
}
|
|
354
|
+
else if (msgType === 'image') {
|
|
355
|
+
if (content.image_key) {
|
|
356
|
+
try {
|
|
357
|
+
const localPath = await this._downloadResource(messageId, content.image_key, 'image');
|
|
358
|
+
files.push(localPath);
|
|
359
|
+
this._log(`[recv] image saved: ${localPath}`);
|
|
360
|
+
}
|
|
361
|
+
catch (e) {
|
|
362
|
+
this._log(`[recv] image download failed: ${e}`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
else if (msgType === 'file') {
|
|
367
|
+
if (content.file_key) {
|
|
368
|
+
try {
|
|
369
|
+
const localPath = await this._downloadResource(messageId, content.file_key, 'file', content.file_name);
|
|
370
|
+
files.push(localPath);
|
|
371
|
+
this._log(`[recv] file saved: ${localPath}`);
|
|
372
|
+
}
|
|
373
|
+
catch (e) {
|
|
374
|
+
this._log(`[recv] file download failed: ${e}`);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
else if (msgType === 'post') {
|
|
379
|
+
text = this._cleanMention(this._extractPostText(content));
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
text = this._cleanMention(content.text || '');
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
catch (e) {
|
|
386
|
+
this._log(`[recv] content parse error: ${e.message || e}`);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
const trimmedText = text.trim();
|
|
390
|
+
// Queue dispatch per chat to preserve ordering
|
|
391
|
+
const key = chatId;
|
|
392
|
+
const prev = this.messageChains.get(key) || Promise.resolve();
|
|
393
|
+
const current = prev.catch(() => { }).then(async () => {
|
|
394
|
+
// Command dispatch
|
|
395
|
+
if (trimmedText.startsWith('/') && this._hCommand) {
|
|
396
|
+
const spaceIdx = trimmedText.indexOf(' ');
|
|
397
|
+
const cmd = (spaceIdx > 0 ? trimmedText.slice(1, spaceIdx) : trimmedText.slice(1)).toLowerCase();
|
|
398
|
+
const args = spaceIdx > 0 ? trimmedText.slice(spaceIdx + 1).trim() : '';
|
|
399
|
+
this._log(`[recv] command /${cmd} args="${args.slice(0, 80)}" chat=${chatId}`);
|
|
400
|
+
await this._hCommand(cmd, args, ctx);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
// Message dispatch
|
|
404
|
+
if (!this._hMessage)
|
|
405
|
+
return;
|
|
406
|
+
if (!trimmedText && !files.length)
|
|
407
|
+
return;
|
|
408
|
+
this._log(`[dispatch] -> onMessage text="${trimmedText.slice(0, 80)}" files=${files.length} chat=${chatId}`);
|
|
409
|
+
await this._hMessage({ text: trimmedText, files }, ctx);
|
|
410
|
+
});
|
|
411
|
+
const settled = current.catch(e => {
|
|
412
|
+
this._log(`[dispatch] handler error: ${e}`);
|
|
413
|
+
this._hError?.(e instanceof Error ? e : new Error(String(e)));
|
|
414
|
+
}).finally(() => {
|
|
415
|
+
if (this.messageChains.get(key) === settled)
|
|
416
|
+
this.messageChains.delete(key);
|
|
417
|
+
});
|
|
418
|
+
this.messageChains.set(key, settled);
|
|
419
|
+
await settled;
|
|
420
|
+
}
|
|
421
|
+
async _dispatchCardAction(event) {
|
|
422
|
+
const chatId = event.context?.open_chat_id;
|
|
423
|
+
const messageId = event.context?.open_message_id;
|
|
424
|
+
const actionStr = event.action?.value?.action;
|
|
425
|
+
if (!chatId || !actionStr || !this._hCardAction)
|
|
426
|
+
return;
|
|
427
|
+
if (!this._isAllowed(chatId)) {
|
|
428
|
+
this._log(`[card-action] blocked: chat=${chatId}`);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
const from = {
|
|
432
|
+
openId: event.operator?.open_id || '',
|
|
433
|
+
userId: event.operator?.user_id,
|
|
434
|
+
};
|
|
435
|
+
this._log(`[recv] card_action chat=${chatId} msg=${messageId} action="${actionStr}"`);
|
|
436
|
+
await this._hCardAction(actionStr, {
|
|
437
|
+
chatId,
|
|
438
|
+
messageId,
|
|
439
|
+
from,
|
|
440
|
+
editReply: (msgId, text, opts) => this.editMessage(chatId, msgId, text, opts),
|
|
441
|
+
channel: this,
|
|
442
|
+
raw: event,
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
async _dispatchMenuEvent(event) {
|
|
446
|
+
const eventKey = event.event_key;
|
|
447
|
+
const openId = event.operator?.operator_id?.open_id;
|
|
448
|
+
if (!eventKey || !openId || !this._hCommand)
|
|
449
|
+
return;
|
|
450
|
+
// Try: event payload → cache → API resolve
|
|
451
|
+
const chatId = this._openIdToChat.get(openId)
|
|
452
|
+
?? await this._resolveP2pChatId(openId);
|
|
453
|
+
if (!chatId) {
|
|
454
|
+
this._log(`[menu] cannot resolve chat_id for open_id=${openId}, event_key=${eventKey}`);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
if (!this._isAllowed(chatId))
|
|
458
|
+
return;
|
|
459
|
+
this._log(`[recv] menu event_key=${eventKey} open_id=${openId} chat=${chatId}`);
|
|
460
|
+
const from = { openId, userId: event.operator?.operator_id?.user_id };
|
|
461
|
+
const ctx = this._makeCtx(chatId, '', from, 'p2p', event);
|
|
462
|
+
await this._hCommand(eventKey, '', ctx);
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Resolve a p2p chat_id for a given open_id by sending a minimal message
|
|
466
|
+
* via open_id and extracting the chat_id from the API response.
|
|
467
|
+
*/
|
|
468
|
+
async _resolveP2pChatId(openId) {
|
|
469
|
+
try {
|
|
470
|
+
const resp = await this.client.im.message.create({
|
|
471
|
+
params: { receive_id_type: 'open_id' },
|
|
472
|
+
data: {
|
|
473
|
+
receive_id: openId,
|
|
474
|
+
msg_type: 'text',
|
|
475
|
+
content: JSON.stringify({ text: '...' }),
|
|
476
|
+
},
|
|
477
|
+
});
|
|
478
|
+
const chatId = resp?.data?.chat_id ?? null;
|
|
479
|
+
const msgId = resp?.data?.message_id;
|
|
480
|
+
// Clean up the placeholder message
|
|
481
|
+
if (msgId) {
|
|
482
|
+
try {
|
|
483
|
+
await this.client.im.message.delete({ path: { message_id: msgId } });
|
|
484
|
+
}
|
|
485
|
+
catch { }
|
|
486
|
+
}
|
|
487
|
+
if (chatId) {
|
|
488
|
+
this._openIdToChat.set(openId, chatId);
|
|
489
|
+
this.knownChats.add(chatId);
|
|
490
|
+
this._log(`[menu] resolved chat_id=${chatId} for open_id=${openId}`);
|
|
491
|
+
}
|
|
492
|
+
return chatId;
|
|
493
|
+
}
|
|
494
|
+
catch (e) {
|
|
495
|
+
this._log(`[menu] resolve chat_id failed for open_id=${openId}: ${e?.message || e}`);
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
// ========================================================================
|
|
500
|
+
// Outgoing primitives (Channel interface)
|
|
501
|
+
// ========================================================================
|
|
502
|
+
async setMenu(commands) {
|
|
503
|
+
this._log(`[menu] ${commands.length} commands. Configure in Feishu Developer Console → Bot → Custom Menu:`);
|
|
504
|
+
for (const c of commands) {
|
|
505
|
+
this._log(`[menu] event_key="${c.command}" name="${c.description}"`);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
async clearMenu() {
|
|
509
|
+
this._log(`[menu] cleared (remove items in Feishu Developer Console)`);
|
|
510
|
+
}
|
|
511
|
+
async sendCard(chatId, view) {
|
|
512
|
+
const card = buildCardFromView(view);
|
|
513
|
+
this._logOutgoing('send', `chat=${chatId} chars=${view.markdown.length} rows=${view.rows?.length || 0}`);
|
|
514
|
+
const resp = await this.client.im.message.create({
|
|
515
|
+
params: { receive_id_type: 'chat_id' },
|
|
516
|
+
data: {
|
|
517
|
+
receive_id: String(chatId),
|
|
518
|
+
msg_type: 'interactive',
|
|
519
|
+
content: JSON.stringify(card),
|
|
520
|
+
},
|
|
521
|
+
});
|
|
522
|
+
return resp?.data?.message_id ?? null;
|
|
523
|
+
}
|
|
524
|
+
async send(chatId, text, opts = {}) {
|
|
525
|
+
const rows = keyboardToRows(opts.keyboard);
|
|
526
|
+
const view = { markdown: text.trim() || '(empty)', rows };
|
|
527
|
+
// Reply to a specific message if replyTo is set
|
|
528
|
+
if (opts.replyTo) {
|
|
529
|
+
return await this.replyCard(String(opts.replyTo), view);
|
|
530
|
+
}
|
|
531
|
+
return await this.sendCard(chatId, view);
|
|
532
|
+
}
|
|
533
|
+
async replyCard(replyToMsgId, view) {
|
|
534
|
+
const card = buildCardFromView(view);
|
|
535
|
+
this._logOutgoing('reply', `reply_to=${replyToMsgId} chars=${view.markdown.length} rows=${view.rows?.length || 0}`);
|
|
536
|
+
const resp = await this.client.im.message.reply({
|
|
537
|
+
path: { message_id: replyToMsgId },
|
|
538
|
+
data: {
|
|
539
|
+
msg_type: 'interactive',
|
|
540
|
+
content: JSON.stringify(card),
|
|
541
|
+
},
|
|
542
|
+
});
|
|
543
|
+
return resp?.data?.message_id ?? null;
|
|
544
|
+
}
|
|
545
|
+
async editCard(chatId, msgId, view) {
|
|
546
|
+
if (!view.markdown.trim())
|
|
547
|
+
return;
|
|
548
|
+
const cardState = this.cardStates.get(String(msgId));
|
|
549
|
+
if (cardState) {
|
|
550
|
+
await this.editMessage(chatId, msgId, view.markdown, { keyboard: { rows: view.rows || [] } });
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
const card = buildCardFromView(view);
|
|
554
|
+
this._logOutgoing('edit', `chat=${chatId} msg_id=${msgId} chars=${view.markdown.length} rows=${view.rows?.length || 0}`);
|
|
555
|
+
try {
|
|
556
|
+
await this.client.im.message.patch({
|
|
557
|
+
path: { message_id: String(msgId) },
|
|
558
|
+
data: { content: JSON.stringify(card) },
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
catch (e) {
|
|
562
|
+
const msg = String(e?.message || e).toLowerCase();
|
|
563
|
+
if (msg.includes('not modified') || msg.includes('edit is not allowed'))
|
|
564
|
+
return;
|
|
565
|
+
throw e;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
async editMessage(chatId, msgId, text, opts = {}) {
|
|
569
|
+
if (!text.trim())
|
|
570
|
+
return;
|
|
571
|
+
// If this message has a CardKit streaming card, push content via CardKit API
|
|
572
|
+
const cardState = this.cardStates.get(String(msgId));
|
|
573
|
+
if (cardState) {
|
|
574
|
+
cardState.sequence++;
|
|
575
|
+
const content = text.length > FEISHU_CARD_MAX ? text.slice(-FEISHU_CARD_MAX) : text;
|
|
576
|
+
this._logOutgoing('stream-push', `card=${cardState.cardId} seq=${cardState.sequence} chars=${content.length}`);
|
|
577
|
+
try {
|
|
578
|
+
await this.client.request({
|
|
579
|
+
method: 'PUT',
|
|
580
|
+
url: `/open-apis/cardkit/v1/cards/${cardState.cardId}/elements/content/content`,
|
|
581
|
+
data: { content, sequence: cardState.sequence },
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
catch (e) {
|
|
585
|
+
this._log(`[edit] CardKit push error: ${e?.message || e}`);
|
|
586
|
+
}
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
// Fallback: regular PATCH for non-streaming cards
|
|
590
|
+
const rows = keyboardToRows(opts.keyboard);
|
|
591
|
+
await this.editCard(chatId, msgId, {
|
|
592
|
+
markdown: text,
|
|
593
|
+
rows,
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
async deleteMessage(_chatId, msgId) {
|
|
597
|
+
try {
|
|
598
|
+
await this.client.im.message.delete({
|
|
599
|
+
path: { message_id: String(msgId) },
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
catch { }
|
|
603
|
+
}
|
|
604
|
+
async sendTyping(_chatId) {
|
|
605
|
+
// Feishu has no typing indicator API — no-op
|
|
606
|
+
}
|
|
607
|
+
// ========================================================================
|
|
608
|
+
// Streaming cards (CardKit v1) — typewriter effect
|
|
609
|
+
// ========================================================================
|
|
610
|
+
/**
|
|
611
|
+
* Create a streaming card entity and send it as a message.
|
|
612
|
+
* Returns the messageId (for session tracking) or null on failure.
|
|
613
|
+
*
|
|
614
|
+
* While streaming is active, `editMessage()` transparently pushes content
|
|
615
|
+
* via the CardKit API instead of PATCH. Call `endStreaming()` to finalize.
|
|
616
|
+
*/
|
|
617
|
+
async sendStreamingCard(chatId, initialContent, opts) {
|
|
618
|
+
const cardData = {
|
|
619
|
+
schema: '2.0',
|
|
620
|
+
config: {
|
|
621
|
+
streaming_mode: true,
|
|
622
|
+
summary: { content: '[Generating...]' },
|
|
623
|
+
streaming_config: {
|
|
624
|
+
print_frequency_ms: { default: 30 },
|
|
625
|
+
print_step: { default: 3 },
|
|
626
|
+
},
|
|
627
|
+
},
|
|
628
|
+
body: {
|
|
629
|
+
elements: [
|
|
630
|
+
{ tag: 'markdown', content: initialContent || 'Thinking...', element_id: 'content' },
|
|
631
|
+
],
|
|
632
|
+
},
|
|
633
|
+
};
|
|
634
|
+
// Step 1: Create card entity via CardKit
|
|
635
|
+
let cardId;
|
|
636
|
+
try {
|
|
637
|
+
const createResp = await this.client.request({
|
|
638
|
+
method: 'POST',
|
|
639
|
+
url: '/open-apis/cardkit/v1/cards',
|
|
640
|
+
data: {
|
|
641
|
+
type: 'card_json',
|
|
642
|
+
data: JSON.stringify(cardData),
|
|
643
|
+
},
|
|
644
|
+
});
|
|
645
|
+
cardId = createResp?.data?.card_id;
|
|
646
|
+
if (!cardId)
|
|
647
|
+
throw new Error('no card_id returned');
|
|
648
|
+
}
|
|
649
|
+
catch (e) {
|
|
650
|
+
this._log(`[streaming] CardKit create failed: ${e?.message || e}, falling back to regular card`);
|
|
651
|
+
return this.send(chatId, initialContent);
|
|
652
|
+
}
|
|
653
|
+
// Step 2: Send card as message (reply to user's message if replyTo is set)
|
|
654
|
+
const cardContent = JSON.stringify({ type: 'card', data: { card_id: cardId } });
|
|
655
|
+
try {
|
|
656
|
+
this._logOutgoing('sendStreamingCard', `chat=${chatId} card=${cardId}${opts?.replyTo ? ` reply_to=${opts.replyTo}` : ''}`);
|
|
657
|
+
let sendResp;
|
|
658
|
+
if (opts?.replyTo) {
|
|
659
|
+
sendResp = await this.client.im.message.reply({
|
|
660
|
+
path: { message_id: opts.replyTo },
|
|
661
|
+
data: { msg_type: 'interactive', content: cardContent },
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
else {
|
|
665
|
+
sendResp = await this.client.im.message.create({
|
|
666
|
+
params: { receive_id_type: 'chat_id' },
|
|
667
|
+
data: { receive_id: chatId, msg_type: 'interactive', content: cardContent },
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
const messageId = sendResp?.data?.message_id;
|
|
671
|
+
if (!messageId)
|
|
672
|
+
throw new Error('no message_id returned');
|
|
673
|
+
// Track streaming state — editMessage() will use CardKit for this messageId
|
|
674
|
+
this.cardStates.set(messageId, { cardId, sequence: 1 });
|
|
675
|
+
return messageId;
|
|
676
|
+
}
|
|
677
|
+
catch (e) {
|
|
678
|
+
this._log(`[streaming] send card message failed: ${e?.message || e}`);
|
|
679
|
+
return this.send(chatId, initialContent);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* End streaming mode on a card and finalize it.
|
|
684
|
+
* After this, `editMessage()` falls through to the regular PATCH path.
|
|
685
|
+
*/
|
|
686
|
+
async endStreaming(messageId, summary) {
|
|
687
|
+
const state = this.cardStates.get(messageId);
|
|
688
|
+
if (!state)
|
|
689
|
+
return;
|
|
690
|
+
state.sequence++;
|
|
691
|
+
const settings = {
|
|
692
|
+
config: {
|
|
693
|
+
streaming_mode: false,
|
|
694
|
+
summary: { content: summary || 'Response complete.' },
|
|
695
|
+
},
|
|
696
|
+
};
|
|
697
|
+
this._logOutgoing('endStreaming', `card=${state.cardId} seq=${state.sequence}`);
|
|
698
|
+
try {
|
|
699
|
+
await this.client.request({
|
|
700
|
+
method: 'PATCH',
|
|
701
|
+
url: `/open-apis/cardkit/v1/cards/${state.cardId}/settings`,
|
|
702
|
+
data: {
|
|
703
|
+
settings: JSON.stringify(settings),
|
|
704
|
+
sequence: state.sequence,
|
|
705
|
+
},
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
catch (e) {
|
|
709
|
+
this._log(`[streaming] end streaming error: ${e?.message || e}`);
|
|
710
|
+
}
|
|
711
|
+
// Remove tracking — subsequent editMessage calls use regular PATCH
|
|
712
|
+
this.cardStates.delete(messageId);
|
|
713
|
+
}
|
|
714
|
+
// ========================================================================
|
|
715
|
+
// Feishu-specific outgoing
|
|
716
|
+
// ========================================================================
|
|
717
|
+
/** Send a text message (not card). For simple notifications. */
|
|
718
|
+
async sendText(chatId, text) {
|
|
719
|
+
const resp = await this.client.im.message.create({
|
|
720
|
+
params: { receive_id_type: 'chat_id' },
|
|
721
|
+
data: {
|
|
722
|
+
receive_id: chatId,
|
|
723
|
+
msg_type: 'text',
|
|
724
|
+
content: JSON.stringify({ text }),
|
|
725
|
+
},
|
|
726
|
+
});
|
|
727
|
+
return resp?.data?.message_id ?? null;
|
|
728
|
+
}
|
|
729
|
+
/** Upload an image and return the image_key. */
|
|
730
|
+
async uploadImage(imageBuffer) {
|
|
731
|
+
this._logOutgoing('uploadImage', `bytes=${imageBuffer.byteLength}`);
|
|
732
|
+
const resp = await this.client.im.image.create({
|
|
733
|
+
data: {
|
|
734
|
+
image_type: 'message',
|
|
735
|
+
image: imageBuffer,
|
|
736
|
+
},
|
|
737
|
+
});
|
|
738
|
+
const imageKey = resp?.image_key ?? resp?.data?.image_key;
|
|
739
|
+
if (!imageKey)
|
|
740
|
+
throw new Error('Image upload failed: no image_key returned');
|
|
741
|
+
return imageKey;
|
|
742
|
+
}
|
|
743
|
+
/** Upload a file and return the file_key. */
|
|
744
|
+
async uploadFile(fileBuffer, fileName) {
|
|
745
|
+
const ext = path.extname(fileName).toLowerCase().slice(1);
|
|
746
|
+
const fileType = (['pdf', 'doc', 'xls', 'ppt'].includes(ext) ? ext : 'stream');
|
|
747
|
+
this._logOutgoing('uploadFile', `file=${fileName} bytes=${fileBuffer.byteLength}`);
|
|
748
|
+
const resp = await this.client.im.file.create({
|
|
749
|
+
data: {
|
|
750
|
+
file_type: fileType,
|
|
751
|
+
file_name: fileName,
|
|
752
|
+
file: fileBuffer,
|
|
753
|
+
},
|
|
754
|
+
});
|
|
755
|
+
const fileKey = resp?.file_key ?? resp?.data?.file_key;
|
|
756
|
+
if (!fileKey)
|
|
757
|
+
throw new Error('File upload failed: no file_key returned');
|
|
758
|
+
return fileKey;
|
|
759
|
+
}
|
|
760
|
+
/** Upload and send a local file. */
|
|
761
|
+
async sendFile(chatId, filePath, opts = {}) {
|
|
762
|
+
const content = fs.readFileSync(filePath);
|
|
763
|
+
const filename = path.basename(filePath);
|
|
764
|
+
const isPhoto = opts.asPhoto ?? PHOTO_EXTS.has(path.extname(filename).toLowerCase());
|
|
765
|
+
if (isPhoto) {
|
|
766
|
+
try {
|
|
767
|
+
const imageKey = await this.uploadImage(content);
|
|
768
|
+
const resp = await this.client.im.message.create({
|
|
769
|
+
params: { receive_id_type: 'chat_id' },
|
|
770
|
+
data: {
|
|
771
|
+
receive_id: String(chatId),
|
|
772
|
+
msg_type: 'image',
|
|
773
|
+
content: JSON.stringify({ image_key: imageKey }),
|
|
774
|
+
},
|
|
775
|
+
});
|
|
776
|
+
return resp?.data?.message_id ?? null;
|
|
777
|
+
}
|
|
778
|
+
catch (err) {
|
|
779
|
+
if (isRetryableUploadError(err))
|
|
780
|
+
throw err;
|
|
781
|
+
this._log(`[send] image upload rejected file=${filename}: ${describeError(err)}; retrying as file`);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
const fileKey = await this.uploadFile(content, filename);
|
|
785
|
+
const resp = await this.client.im.message.create({
|
|
786
|
+
params: { receive_id_type: 'chat_id' },
|
|
787
|
+
data: {
|
|
788
|
+
receive_id: String(chatId),
|
|
789
|
+
msg_type: 'file',
|
|
790
|
+
content: JSON.stringify({ file_key: fileKey }),
|
|
791
|
+
},
|
|
792
|
+
});
|
|
793
|
+
return resp?.data?.message_id ?? null;
|
|
794
|
+
}
|
|
795
|
+
// ========================================================================
|
|
796
|
+
// Download resources from received messages
|
|
797
|
+
// ========================================================================
|
|
798
|
+
async _downloadResource(messageId, fileKey, type, filename) {
|
|
799
|
+
const resp = await this.client.im.messageResource.get({
|
|
800
|
+
path: { message_id: messageId, file_key: fileKey },
|
|
801
|
+
params: { type },
|
|
802
|
+
});
|
|
803
|
+
const ext = type === 'image' ? '.jpg' : (filename ? path.extname(filename) : '.bin');
|
|
804
|
+
const name = filename || `feishu_${fileKey.slice(-8)}${ext}`;
|
|
805
|
+
const localPath = path.join(this.workdir, `_feishu_${name}`);
|
|
806
|
+
fs.mkdirSync(this.workdir, { recursive: true });
|
|
807
|
+
await resp.writeFile(localPath);
|
|
808
|
+
return localPath;
|
|
809
|
+
}
|
|
810
|
+
// ========================================================================
|
|
811
|
+
// Internal helpers
|
|
812
|
+
// ========================================================================
|
|
813
|
+
_makeCtx(chatId, messageId, from, chatType, raw) {
|
|
814
|
+
return {
|
|
815
|
+
chatId,
|
|
816
|
+
messageId,
|
|
817
|
+
from,
|
|
818
|
+
chatType,
|
|
819
|
+
reply: (text, opts) => this.send(chatId, text, { ...opts, replyTo: messageId || opts?.replyTo }),
|
|
820
|
+
editReply: (msgId, text, opts) => this.editMessage(chatId, msgId, text, opts),
|
|
821
|
+
channel: this,
|
|
822
|
+
raw,
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
_isAllowed(chatId) {
|
|
826
|
+
return this.allowedChatIds.size === 0 || this.allowedChatIds.has(chatId);
|
|
827
|
+
}
|
|
828
|
+
_isBotMentioned(msg) {
|
|
829
|
+
const mentions = msg.mentions || [];
|
|
830
|
+
if (!this.bot)
|
|
831
|
+
return mentions.length > 0;
|
|
832
|
+
return mentions.some((m) => {
|
|
833
|
+
const mentionId = m.id?.open_id || m.id?.app_id || '';
|
|
834
|
+
return mentionId === this.bot.id || m.name === this.bot.displayName;
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
_cleanMention(text) {
|
|
838
|
+
return text.replace(/@_user_\d+/g, '').trim();
|
|
839
|
+
}
|
|
840
|
+
/** Extract plain text from a rich text (post) message content. */
|
|
841
|
+
_extractPostText(content) {
|
|
842
|
+
const post = content.zh_cn || content.en_us || content;
|
|
843
|
+
const parts = [];
|
|
844
|
+
if (post.title)
|
|
845
|
+
parts.push(post.title);
|
|
846
|
+
const paragraphs = post.content || [];
|
|
847
|
+
for (const paragraph of paragraphs) {
|
|
848
|
+
if (!Array.isArray(paragraph))
|
|
849
|
+
continue;
|
|
850
|
+
const line = paragraph
|
|
851
|
+
.map((elem) => {
|
|
852
|
+
if (elem.tag === 'text')
|
|
853
|
+
return elem.text || '';
|
|
854
|
+
if (elem.tag === 'a')
|
|
855
|
+
return elem.text || elem.href || '';
|
|
856
|
+
if (elem.tag === 'at')
|
|
857
|
+
return '';
|
|
858
|
+
return '';
|
|
859
|
+
})
|
|
860
|
+
.join('');
|
|
861
|
+
if (line.trim())
|
|
862
|
+
parts.push(line);
|
|
863
|
+
}
|
|
864
|
+
return parts.join('\n');
|
|
865
|
+
}
|
|
866
|
+
_log(msg) {
|
|
867
|
+
const ts = new Date().toTimeString().slice(0, 8);
|
|
868
|
+
process.stdout.write(`[feishu ${ts}] ${msg}\n`);
|
|
869
|
+
}
|
|
870
|
+
_logOutgoing(action, meta) {
|
|
871
|
+
this._log(`[send] ${action} ${meta}`);
|
|
872
|
+
}
|
|
873
|
+
}
|