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.
@@ -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
+ }