pikiloop 0.4.0

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 (154) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +353 -0
  3. package/README.v2.md +287 -0
  4. package/README.zh-CN.md +352 -0
  5. package/dashboard/dist/assets/AgentTab-UZPIhlkr.js +1 -0
  6. package/dashboard/dist/assets/DirBrowser-Ckcmi-Pi.js +1 -0
  7. package/dashboard/dist/assets/ExtensionsTab-KZhEDrdu.js +1 -0
  8. package/dashboard/dist/assets/IMAccessTab-Bd_IY1GQ.js +1 -0
  9. package/dashboard/dist/assets/Modal-CTeL0y7P.js +1 -0
  10. package/dashboard/dist/assets/Modals-axftHasy.js +1 -0
  11. package/dashboard/dist/assets/Select-C8tOdPhe.js +1 -0
  12. package/dashboard/dist/assets/SessionPanel-C1geSRxw.js +1 -0
  13. package/dashboard/dist/assets/SystemTab-DBDkaPiO.js +1 -0
  14. package/dashboard/dist/assets/anthropic-BAdojD7P.ico +0 -0
  15. package/dashboard/dist/assets/codex-DYadqqp0.png +0 -0
  16. package/dashboard/dist/assets/deepseek-BeYNZEk0.ico +0 -0
  17. package/dashboard/dist/assets/doubao-DloFDuFR.png +0 -0
  18. package/dashboard/dist/assets/feishu-C4OMrjCW.ico +0 -0
  19. package/dashboard/dist/assets/gemini-BYkEpiWr.svg +1 -0
  20. package/dashboard/dist/assets/hermes-BAarh-tH.png +0 -0
  21. package/dashboard/dist/assets/index-CpM4CqZJ.js +23 -0
  22. package/dashboard/dist/assets/index-DXSohzrE.js +3 -0
  23. package/dashboard/dist/assets/index-reSbuley.css +1 -0
  24. package/dashboard/dist/assets/markdown-DxQYQFeH.js +29 -0
  25. package/dashboard/dist/assets/minimax-PuEGTfrF.ico +0 -0
  26. package/dashboard/dist/assets/mlx-DhWwjtMw.png +0 -0
  27. package/dashboard/dist/assets/ollama-Bt9O-2K_.png +0 -0
  28. package/dashboard/dist/assets/openrouter-CsJ_bD5Q.ico +0 -0
  29. package/dashboard/dist/assets/playwright-BldPFZgC.ico +0 -0
  30. package/dashboard/dist/assets/qwen-xykkX0_y.png +0 -0
  31. package/dashboard/dist/assets/react-vendor-C7Sl8SE7.js +9 -0
  32. package/dashboard/dist/assets/router-DHISdpPk.js +3 -0
  33. package/dashboard/dist/assets/shared-BIP_4k4I.js +1 -0
  34. package/dashboard/dist/favicon.svg +28 -0
  35. package/dashboard/dist/index.html +17 -0
  36. package/dist/agent/acp-client.js +261 -0
  37. package/dist/agent/auto-update.js +432 -0
  38. package/dist/agent/await-resume.js +50 -0
  39. package/dist/agent/cli/auth.js +325 -0
  40. package/dist/agent/cli/catalog.js +40 -0
  41. package/dist/agent/cli/detector.js +136 -0
  42. package/dist/agent/cli/index.js +7 -0
  43. package/dist/agent/cli/registry.js +33 -0
  44. package/dist/agent/driver.js +39 -0
  45. package/dist/agent/drivers/claude-tui.js +2297 -0
  46. package/dist/agent/drivers/claude.js +2689 -0
  47. package/dist/agent/drivers/codex.js +2210 -0
  48. package/dist/agent/drivers/gemini.js +1059 -0
  49. package/dist/agent/drivers/hermes.js +795 -0
  50. package/dist/agent/goal.js +274 -0
  51. package/dist/agent/handover.js +130 -0
  52. package/dist/agent/images.js +355 -0
  53. package/dist/agent/index.js +50 -0
  54. package/dist/agent/mcp/bridge.js +791 -0
  55. package/dist/agent/mcp/extensions.js +637 -0
  56. package/dist/agent/mcp/oauth.js +353 -0
  57. package/dist/agent/mcp/registry.js +119 -0
  58. package/dist/agent/mcp/session-server.js +229 -0
  59. package/dist/agent/mcp/tools/ask-user.js +113 -0
  60. package/dist/agent/mcp/tools/await-resume.js +77 -0
  61. package/dist/agent/mcp/tools/goal.js +144 -0
  62. package/dist/agent/mcp/tools/types.js +12 -0
  63. package/dist/agent/mcp/tools/workspace.js +212 -0
  64. package/dist/agent/npm.js +31 -0
  65. package/dist/agent/session.js +1206 -0
  66. package/dist/agent/skill-installer.js +160 -0
  67. package/dist/agent/skills.js +257 -0
  68. package/dist/agent/stream.js +743 -0
  69. package/dist/agent/types.js +13 -0
  70. package/dist/agent/utils.js +687 -0
  71. package/dist/bot/bot.js +2499 -0
  72. package/dist/bot/command-ui.js +633 -0
  73. package/dist/bot/commands.js +513 -0
  74. package/dist/bot/headless-bot.js +36 -0
  75. package/dist/bot/host.js +192 -0
  76. package/dist/bot/human-loop.js +168 -0
  77. package/dist/bot/menu.js +48 -0
  78. package/dist/bot/orchestration.js +79 -0
  79. package/dist/bot/render-shared.js +309 -0
  80. package/dist/bot/session-hub.js +361 -0
  81. package/dist/bot/session-status.js +55 -0
  82. package/dist/bot/streaming.js +309 -0
  83. package/dist/browser-profile.js +579 -0
  84. package/dist/browser-supervisor.js +249 -0
  85. package/dist/catalog/cli-tools.js +421 -0
  86. package/dist/catalog/index.js +21 -0
  87. package/dist/catalog/local-models.js +94 -0
  88. package/dist/catalog/mcp-servers.js +315 -0
  89. package/dist/catalog/skill-repos.js +173 -0
  90. package/dist/channels/base.js +55 -0
  91. package/dist/channels/dingtalk/bot.js +549 -0
  92. package/dist/channels/dingtalk/channel.js +268 -0
  93. package/dist/channels/discord/bot.js +552 -0
  94. package/dist/channels/discord/channel.js +245 -0
  95. package/dist/channels/feishu/bot.js +1275 -0
  96. package/dist/channels/feishu/channel.js +911 -0
  97. package/dist/channels/feishu/markdown.js +91 -0
  98. package/dist/channels/feishu/render.js +619 -0
  99. package/dist/channels/health.js +109 -0
  100. package/dist/channels/slack/bot.js +554 -0
  101. package/dist/channels/slack/channel.js +283 -0
  102. package/dist/channels/states.js +6 -0
  103. package/dist/channels/telegram/bot.js +1310 -0
  104. package/dist/channels/telegram/channel.js +820 -0
  105. package/dist/channels/telegram/directory.js +111 -0
  106. package/dist/channels/telegram/live-preview.js +220 -0
  107. package/dist/channels/telegram/render.js +384 -0
  108. package/dist/channels/wecom/bot.js +558 -0
  109. package/dist/channels/wecom/channel.js +479 -0
  110. package/dist/channels/weixin/api.js +520 -0
  111. package/dist/channels/weixin/bot.js +1000 -0
  112. package/dist/channels/weixin/channel.js +222 -0
  113. package/dist/cli/autostart.js +262 -0
  114. package/dist/cli/channel-supervisor.js +313 -0
  115. package/dist/cli/channels.js +54 -0
  116. package/dist/cli/main.js +726 -0
  117. package/dist/cli/onboarding.js +227 -0
  118. package/dist/cli/run.js +308 -0
  119. package/dist/cli/setup-wizard.js +235 -0
  120. package/dist/core/config/runtime-config.js +201 -0
  121. package/dist/core/config/user-config.js +510 -0
  122. package/dist/core/config/validation.js +521 -0
  123. package/dist/core/constants.js +400 -0
  124. package/dist/core/git.js +145 -0
  125. package/dist/core/legacy-compat.js +60 -0
  126. package/dist/core/logging.js +101 -0
  127. package/dist/core/platform.js +59 -0
  128. package/dist/core/process-control.js +315 -0
  129. package/dist/core/secrets/index.js +42 -0
  130. package/dist/core/secrets/inline-seal.js +60 -0
  131. package/dist/core/secrets/ref.js +33 -0
  132. package/dist/core/secrets/resolver.js +65 -0
  133. package/dist/core/secrets/store.js +63 -0
  134. package/dist/core/utils.js +233 -0
  135. package/dist/core/version.js +15 -0
  136. package/dist/dashboard/platform.js +219 -0
  137. package/dist/dashboard/routes/agents.js +450 -0
  138. package/dist/dashboard/routes/cli.js +174 -0
  139. package/dist/dashboard/routes/config.js +523 -0
  140. package/dist/dashboard/routes/extensions.js +745 -0
  141. package/dist/dashboard/routes/local-models.js +290 -0
  142. package/dist/dashboard/routes/models.js +324 -0
  143. package/dist/dashboard/routes/sessions.js +838 -0
  144. package/dist/dashboard/runtime.js +410 -0
  145. package/dist/dashboard/server.js +237 -0
  146. package/dist/dashboard/session-control.js +347 -0
  147. package/dist/model/catalog.js +104 -0
  148. package/dist/model/index.js +20 -0
  149. package/dist/model/injector.js +272 -0
  150. package/dist/model/provider-models.js +112 -0
  151. package/dist/model/store.js +212 -0
  152. package/dist/model/types.js +13 -0
  153. package/dist/model/validation.js +203 -0
  154. package/package.json +82 -0
@@ -0,0 +1,911 @@
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
+ * All messages are sent as regular interactive cards (no CardKit streaming).
10
+ */
11
+ import * as lark from '@larksuiteoapi/node-sdk';
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { Channel, DEFAULT_CHANNEL_CAPABILITIES, sleep, } from '../base.js';
15
+ import { FEISHU_LIMITS } from '../../core/constants.js';
16
+ import { ChannelHealth } from '../health.js';
17
+ import { adaptMarkdownForFeishu } from './markdown.js';
18
+ import { writeScopedLog, shouldLog } from '../../core/logging.js';
19
+ import { recordKnownChatId } from '../../core/config/user-config.js';
20
+ export { FeishuChannel };
21
+ const FEISHU_CARD_MAX = FEISHU_LIMITS.cardMax;
22
+ const FILE_MAX_BYTES = FEISHU_LIMITS.fileMaxBytes;
23
+ const PHOTO_EXTS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
24
+ const FEISHU_WS_START_RETRY_MAX_DELAY_MS = FEISHU_LIMITS.wsStartRetryMaxDelay;
25
+ function describeError(err) {
26
+ if (!(err instanceof Error))
27
+ return String(err ?? 'unknown error');
28
+ const parts = [`${err.name}: ${err.message}`];
29
+ for (const key of ['code', 'errno', 'syscall', 'address', 'port', 'host', 'hostname']) {
30
+ const value = err?.[key];
31
+ if (value != null && value !== '')
32
+ parts.push(`${key}=${value}`);
33
+ }
34
+ return parts.join(' | ');
35
+ }
36
+ function isRetryableWsStartError(err) {
37
+ const text = describeError(err).toLowerCase();
38
+ return [
39
+ 'socket hang up',
40
+ 'econnreset',
41
+ 'etimedout',
42
+ 'econnrefused',
43
+ 'enotfound',
44
+ 'eai_again',
45
+ 'fetch failed',
46
+ 'timeout',
47
+ 'bad gateway',
48
+ 'service unavailable',
49
+ 'gateway timeout',
50
+ ].some(token => text.includes(token));
51
+ }
52
+ function isRetryableUploadError(err) {
53
+ const text = describeError(err).toLowerCase();
54
+ return [
55
+ 'socket hang up',
56
+ 'econnreset',
57
+ 'etimedout',
58
+ 'econnrefused',
59
+ 'enotfound',
60
+ 'eai_again',
61
+ 'fetch failed',
62
+ 'timeout',
63
+ 'temporarily unavailable',
64
+ 'internal server error',
65
+ 'bad gateway',
66
+ 'service unavailable',
67
+ 'gateway timeout',
68
+ ].some(token => text.includes(token));
69
+ }
70
+ function requireMessageId(resp, action) {
71
+ const messageId = resp?.data?.message_id;
72
+ if (messageId)
73
+ return String(messageId);
74
+ const code = resp?.code;
75
+ const msg = resp?.msg || resp?.message || 'no message_id returned';
76
+ throw new Error(`${action} failed: code=${code ?? '?'} msg=${msg}`);
77
+ }
78
+ /** Treat "card content didn't change" responses as a successful no-op edit. */
79
+ function isFeishuNotModifiedMessage(msg) {
80
+ if (!msg)
81
+ return false;
82
+ const lower = msg.toLowerCase();
83
+ return lower.includes('not modified')
84
+ || lower.includes('not modify')
85
+ || lower.includes('content not change')
86
+ || lower.includes('same content')
87
+ || lower.includes('same as before')
88
+ || lower.includes('no change');
89
+ }
90
+ function buildPostContent(paragraphs, title = '') {
91
+ return JSON.stringify({
92
+ zh_cn: {
93
+ title,
94
+ content: paragraphs,
95
+ },
96
+ });
97
+ }
98
+ // ---------------------------------------------------------------------------
99
+ // Card builder helper
100
+ // ---------------------------------------------------------------------------
101
+ function inferActionLayout(actions) {
102
+ if (actions.length >= 3)
103
+ return 'trisection';
104
+ if (actions.length === 2)
105
+ return 'bisected';
106
+ return undefined;
107
+ }
108
+ function chunkActionRows(actions, size = 3) {
109
+ const rows = [];
110
+ for (let i = 0; i < actions.length; i += size) {
111
+ const rowActions = actions.slice(i, i + size).filter(Boolean);
112
+ if (!rowActions.length)
113
+ continue;
114
+ rows.push({ actions: rowActions, layout: inferActionLayout(rowActions) });
115
+ }
116
+ return rows;
117
+ }
118
+ function keyboardToRows(keyboard) {
119
+ const explicitRows = Array.isArray(keyboard?.rows)
120
+ ? keyboard.rows
121
+ .filter((row) => Array.isArray(row?.actions) && row.actions.length)
122
+ .map((row) => ({
123
+ actions: row.actions.filter(Boolean),
124
+ layout: row.layout || inferActionLayout(row.actions),
125
+ }))
126
+ : [];
127
+ if (explicitRows.length)
128
+ return explicitRows;
129
+ const actions = Array.isArray(keyboard?.actions)
130
+ ? keyboard.actions.filter(Boolean)
131
+ : [];
132
+ return chunkActionRows(actions);
133
+ }
134
+ function buildCardFromView(view) {
135
+ const adapted = adaptMarkdownForFeishu(view.markdown);
136
+ const content = adapted.length > FEISHU_CARD_MAX
137
+ ? adapted.slice(0, FEISHU_CARD_MAX) + '\n\n...(truncated)'
138
+ : adapted;
139
+ const actionElements = [];
140
+ for (const row of view.rows || []) {
141
+ const actions = row.actions.filter(Boolean);
142
+ if (!actions.length)
143
+ continue;
144
+ const element = { tag: 'action', actions };
145
+ const layout = row.layout || inferActionLayout(actions);
146
+ if (layout)
147
+ element.layout = layout;
148
+ actionElements.push(element);
149
+ }
150
+ // Card JSON 2.0 supports tables in markdown but dropped `tag: action`.
151
+ // Use v2 for content-only cards; fall back to v1 when buttons are needed.
152
+ if (actionElements.length) {
153
+ const card = {
154
+ config: { wide_screen_mode: true, update_multi: true },
155
+ elements: [{ tag: 'markdown', content }, ...actionElements],
156
+ };
157
+ if (view.title) {
158
+ card.header = {
159
+ template: view.template || 'blue',
160
+ title: { content: view.title, tag: 'plain_text' },
161
+ };
162
+ }
163
+ return card;
164
+ }
165
+ const card = {
166
+ schema: '2.0',
167
+ config: { update_multi: true },
168
+ body: { elements: [{ tag: 'markdown', content }] },
169
+ };
170
+ if (view.title) {
171
+ card.header = {
172
+ template: view.template || 'blue',
173
+ title: { content: view.title, tag: 'plain_text' },
174
+ };
175
+ }
176
+ return card;
177
+ }
178
+ // ---------------------------------------------------------------------------
179
+ // FeishuChannel
180
+ // ---------------------------------------------------------------------------
181
+ class FeishuChannel extends Channel {
182
+ capabilities = {
183
+ ...DEFAULT_CHANNEL_CAPABILITIES,
184
+ editMessages: true,
185
+ commandMenu: true,
186
+ messageReactions: true,
187
+ sendImage: true,
188
+ };
189
+ /** Implementation of Channel.sendImage — uploads the buffer as an image
190
+ * message. When a caption is supplied, falls back to a post with both an
191
+ * img tag and a text line (Feishu's `msg_type:'image'` doesn't have a
192
+ * native caption field). */
193
+ async sendImage(chatId, bytes, opts) {
194
+ const caption = opts.caption?.trim() || '';
195
+ const replyTo = opts.replyTo ? String(opts.replyTo) : undefined;
196
+ const imageKey = await this.uploadImage(bytes);
197
+ if (caption) {
198
+ return await this.sendPost(String(chatId), buildPostContent([
199
+ [{ tag: 'img', image_key: imageKey }],
200
+ [{ tag: 'text', text: caption }],
201
+ ]), { replyTo });
202
+ }
203
+ const msgContent = JSON.stringify({ image_key: imageKey });
204
+ this._logOutgoing('sendImage', `${replyTo ? `reply_to=${replyTo}` : `chat=${chatId}`} bytes=${bytes.byteLength}`);
205
+ const resp = replyTo
206
+ ? await this.client.im.message.reply({ path: { message_id: replyTo }, data: { msg_type: 'image', content: msgContent } })
207
+ : await this.client.im.message.create({ params: { receive_id_type: 'chat_id' }, data: { receive_id: String(chatId), msg_type: 'image', content: msgContent } });
208
+ return requireMessageId(resp, 'send image');
209
+ }
210
+ appId;
211
+ appSecret;
212
+ domain;
213
+ workdir;
214
+ allowedChatIds;
215
+ client;
216
+ wsClient = null;
217
+ eventDispatcher;
218
+ running = false;
219
+ messageChains = new Map();
220
+ /** Recently processed message IDs — guards against Feishu server retries. */
221
+ _seenMessageIds = new Set();
222
+ _seenMessageIdQueue = [];
223
+ static SEEN_MESSAGE_CAP = 256;
224
+ /** Maps open_id → chat_id for resolving menu event context. */
225
+ _openIdToChat = new Map();
226
+ _hCommand = null;
227
+ _hMessage = null;
228
+ _hCardAction = null;
229
+ _hRecall = null;
230
+ _hError = null;
231
+ knownChats = new Set();
232
+ /** Resolves when wsClient.start() settles (used by listen() to block). */
233
+ _listenResolve = null;
234
+ constructor(opts) {
235
+ super();
236
+ this.appId = opts.appId;
237
+ this.appSecret = opts.appSecret;
238
+ this.domain = (opts.domain ?? 'https://open.feishu.cn').replace(/\/+$/, '');
239
+ this.workdir = opts.workdir ?? process.cwd();
240
+ this.allowedChatIds = opts.allowedChatIds ?? new Set();
241
+ // Resolve SDK domain enum or custom string
242
+ const sdkDomain = this.domain.includes('larksuite.com')
243
+ ? lark.Domain.Lark
244
+ : this.domain === 'https://open.feishu.cn'
245
+ ? lark.Domain.Feishu
246
+ : this.domain;
247
+ this.client = new lark.Client({
248
+ appId: this.appId,
249
+ appSecret: this.appSecret,
250
+ domain: sdkDomain,
251
+ loggerLevel: lark.LoggerLevel.warn,
252
+ });
253
+ this.eventDispatcher = new lark.EventDispatcher({});
254
+ this._registerEvents();
255
+ }
256
+ // ---- Hook registration ---------------------------------------------------
257
+ onCommand(h) { this._hCommand = h; }
258
+ onMessage(h) { this._hMessage = h; }
259
+ onCallback(h) { this._hCardAction = h; }
260
+ onMessageRecalled(h) { this._hRecall = h; }
261
+ onError(h) { this._hError = h; }
262
+ // ========================================================================
263
+ // Lifecycle
264
+ // ========================================================================
265
+ async connect() {
266
+ // Get bot info via raw request (SDK doesn't have a dedicated bot info method)
267
+ try {
268
+ const resp = await this.client.request({
269
+ method: 'GET',
270
+ url: '/open-apis/bot/v3/info',
271
+ data: {},
272
+ });
273
+ this._debug(`[connect] bot info resp: ${JSON.stringify(resp)}`);
274
+ const info = resp?.bot;
275
+ this.bot = {
276
+ id: info?.open_id || this.appId,
277
+ username: info?.app_name || 'pikiloop',
278
+ displayName: info?.app_name || 'pikiloop',
279
+ };
280
+ }
281
+ catch (e) {
282
+ this._log(`[connect] bot info failed: ${e?.message || e}`, 'warn');
283
+ this.bot = { id: this.appId, username: 'pikiloop', displayName: 'pikiloop' };
284
+ }
285
+ return this.bot;
286
+ }
287
+ async listen() {
288
+ this.running = true;
289
+ const health = new ChannelHealth({
290
+ label: 'Feishu',
291
+ opAction: 'WS start',
292
+ initialDelayMs: FEISHU_LIMITS.wsStartRetryInitialDelay,
293
+ maxDelayMs: FEISHU_WS_START_RETRY_MAX_DELAY_MS,
294
+ sustainedFailureHint: 'verify feishuAppId / feishuAppSecret in setting.json',
295
+ log: (msg, level) => this._log(msg, level),
296
+ });
297
+ while (this.running) {
298
+ const sdkDomain = this.domain.includes('larksuite.com')
299
+ ? lark.Domain.Lark
300
+ : this.domain === 'https://open.feishu.cn'
301
+ ? lark.Domain.Feishu
302
+ : this.domain;
303
+ this.wsClient = new lark.WSClient({
304
+ appId: this.appId,
305
+ appSecret: this.appSecret,
306
+ domain: sdkDomain,
307
+ loggerLevel: lark.LoggerLevel.warn,
308
+ autoReconnect: true,
309
+ });
310
+ this._debug('[ws] starting SDK WSClient...');
311
+ try {
312
+ await this.wsClient.start({ eventDispatcher: this.eventDispatcher });
313
+ this._debug('[ws] WSClient started, listening for events');
314
+ health.recordSuccess();
315
+ break;
316
+ }
317
+ catch (err) {
318
+ try {
319
+ this.wsClient.close({ force: true });
320
+ }
321
+ catch { }
322
+ this.wsClient = null;
323
+ if (!this.running)
324
+ return;
325
+ if (!isRetryableWsStartError(err))
326
+ throw err;
327
+ await sleep(health.recordFailure(err));
328
+ }
329
+ }
330
+ if (!this.running || !this.wsClient)
331
+ return;
332
+ // Block until disconnect() is called
333
+ await new Promise(resolve => {
334
+ this._listenResolve = resolve;
335
+ if (!this.running)
336
+ resolve();
337
+ });
338
+ }
339
+ disconnect() {
340
+ this.running = false;
341
+ if (this.wsClient) {
342
+ try {
343
+ this.wsClient.close({ force: true });
344
+ }
345
+ catch { }
346
+ this.wsClient = null;
347
+ }
348
+ this._listenResolve?.();
349
+ this._listenResolve = null;
350
+ }
351
+ // ========================================================================
352
+ // Event handling (via SDK EventDispatcher)
353
+ // ========================================================================
354
+ _registerEvents() {
355
+ this.eventDispatcher.register({
356
+ 'im.message.receive_v1': (data) => {
357
+ void this._handleMessageEvent(data).catch(e => {
358
+ this._log(`[dispatch] error: ${e}`, 'warn');
359
+ this._hError?.(e instanceof Error ? e : new Error(String(e)));
360
+ });
361
+ },
362
+ 'card.action.trigger': (data) => {
363
+ void this._dispatchCardAction(data).catch(e => {
364
+ this._log(`[card-action] error: ${e}`, 'warn');
365
+ this._hError?.(e instanceof Error ? e : new Error(String(e)));
366
+ });
367
+ return {};
368
+ },
369
+ 'application.bot.menu_v6': (data) => {
370
+ void this._dispatchMenuEvent(data).catch(e => {
371
+ this._log(`[menu] error: ${e}`, 'warn');
372
+ this._hError?.(e instanceof Error ? e : new Error(String(e)));
373
+ });
374
+ },
375
+ 'im.message.recalled_v1': (data) => {
376
+ void this._dispatchMessageRecalled(data).catch(e => {
377
+ this._log(`[message-recalled] error: ${e}`, 'warn');
378
+ this._hError?.(e instanceof Error ? e : new Error(String(e)));
379
+ });
380
+ },
381
+ });
382
+ }
383
+ async _handleMessageEvent(event) {
384
+ if (shouldLog('debug'))
385
+ this._debug(`[recv] raw event received: ${JSON.stringify(event)}`);
386
+ const msg = event?.message;
387
+ if (!msg) {
388
+ this._debug(`[recv] event has no message object`);
389
+ return;
390
+ }
391
+ const chatId = msg.chat_id;
392
+ const messageId = msg.message_id;
393
+ const chatType = msg.chat_type === 'p2p' ? 'p2p' : 'group';
394
+ const msgType = msg.message_type;
395
+ if (!chatId || !messageId)
396
+ return;
397
+ // Dedup: Feishu server may retry events when the ack is slow
398
+ if (this._seenMessageIds.has(messageId)) {
399
+ this._debug(`[recv] dedup: message=${messageId} already processed, skipping`);
400
+ return;
401
+ }
402
+ this._seenMessageIds.add(messageId);
403
+ this._seenMessageIdQueue.push(messageId);
404
+ while (this._seenMessageIdQueue.length > FeishuChannel.SEEN_MESSAGE_CAP) {
405
+ this._seenMessageIds.delete(this._seenMessageIdQueue.shift());
406
+ }
407
+ if (!this._isAllowed(chatId)) {
408
+ this._log(`[recv] blocked: chat=${chatId} not allowed`, 'warn');
409
+ return;
410
+ }
411
+ this._trackChat(chatId);
412
+ const sender = event.sender;
413
+ // Skip messages from the bot itself
414
+ if (sender?.sender_type === 'app')
415
+ return;
416
+ const from = {
417
+ openId: sender?.sender_id?.open_id || '',
418
+ userId: sender?.sender_id?.user_id,
419
+ name: '',
420
+ };
421
+ // Track open_id → chat_id for menu event resolution
422
+ if (from.openId)
423
+ this._openIdToChat.set(from.openId, chatId);
424
+ // Group: require @mention
425
+ if (chatType === 'group') {
426
+ if (shouldLog('debug'))
427
+ this._debug(`[recv] group check mention: bot=${JSON.stringify(this.bot)}, mentions=${JSON.stringify(msg.mentions)}`);
428
+ if (!this._isBotMentioned(msg)) {
429
+ this._debug(`[recv] skipped: not mentioned in group ${chatId}`);
430
+ return;
431
+ }
432
+ }
433
+ const parentId = typeof msg.parent_id === 'string' && msg.parent_id ? msg.parent_id : null;
434
+ const ctx = this._makeCtx(chatId, messageId, from, chatType, event, parentId);
435
+ // Parse message content
436
+ let text = '';
437
+ const files = [];
438
+ try {
439
+ const content = JSON.parse(msg.content || '{}');
440
+ if (msgType === 'text') {
441
+ text = this._cleanMention(content.text || '');
442
+ }
443
+ else if (msgType === 'image') {
444
+ if (content.image_key) {
445
+ try {
446
+ const localPath = await this._downloadResource(messageId, content.image_key, 'image');
447
+ files.push(localPath);
448
+ }
449
+ catch (e) {
450
+ this._log(`[recv] image download failed: ${e}`, 'warn');
451
+ }
452
+ }
453
+ }
454
+ else if (msgType === 'file') {
455
+ if (content.file_key) {
456
+ try {
457
+ const localPath = await this._downloadResource(messageId, content.file_key, 'file', content.file_name);
458
+ files.push(localPath);
459
+ }
460
+ catch (e) {
461
+ this._log(`[recv] file download failed: ${e}`, 'warn');
462
+ }
463
+ }
464
+ }
465
+ else if (msgType === 'post') {
466
+ text = this._cleanMention(this._extractPostText(content));
467
+ }
468
+ else {
469
+ text = this._cleanMention(content.text || '');
470
+ }
471
+ }
472
+ catch (e) {
473
+ this._log(`[recv] content parse error: ${e.message || e}`, 'warn');
474
+ return;
475
+ }
476
+ const trimmedText = text.trim();
477
+ // Queue dispatch per chat to preserve ordering
478
+ const key = chatId;
479
+ const prev = this.messageChains.get(key) || Promise.resolve();
480
+ const current = prev.catch(() => { }).then(async () => {
481
+ // Command dispatch
482
+ if (trimmedText.startsWith('/') && this._hCommand) {
483
+ const spaceIdx = trimmedText.indexOf(' ');
484
+ const cmd = (spaceIdx > 0 ? trimmedText.slice(1, spaceIdx) : trimmedText.slice(1)).toLowerCase();
485
+ const args = spaceIdx > 0 ? trimmedText.slice(spaceIdx + 1).trim() : '';
486
+ await this._hCommand(cmd, args, ctx);
487
+ return;
488
+ }
489
+ // Message dispatch
490
+ if (!this._hMessage)
491
+ return;
492
+ if (!trimmedText && !files.length)
493
+ return;
494
+ await this._hMessage({ text: trimmedText, files }, ctx);
495
+ });
496
+ const settled = current.catch(e => {
497
+ this._log(`[dispatch] handler error: ${e}`, 'warn');
498
+ this._hError?.(e instanceof Error ? e : new Error(String(e)));
499
+ }).finally(() => {
500
+ if (this.messageChains.get(key) === settled)
501
+ this.messageChains.delete(key);
502
+ });
503
+ this.messageChains.set(key, settled);
504
+ await settled;
505
+ }
506
+ async _dispatchCardAction(event) {
507
+ const chatId = event.context?.open_chat_id;
508
+ const messageId = event.context?.open_message_id;
509
+ const actionStr = event.action?.value?.action;
510
+ if (!chatId || !actionStr || !this._hCardAction)
511
+ return;
512
+ if (!this._isAllowed(chatId)) {
513
+ this._debug(`[card-action] blocked: chat=${chatId}`);
514
+ return;
515
+ }
516
+ const from = {
517
+ openId: event.operator?.open_id || '',
518
+ userId: event.operator?.user_id,
519
+ };
520
+ this._debug(`[recv] card_action chat=${chatId} msg=${messageId} action="${actionStr}"`);
521
+ await this._hCardAction(actionStr, {
522
+ chatId,
523
+ messageId,
524
+ from,
525
+ editReply: (msgId, text, opts) => this.editMessage(chatId, msgId, text, opts),
526
+ channel: this,
527
+ raw: event,
528
+ });
529
+ }
530
+ async _dispatchMenuEvent(event) {
531
+ const eventKey = event.event_key;
532
+ const openId = event.operator?.operator_id?.open_id;
533
+ if (!eventKey || !openId || !this._hCommand)
534
+ return;
535
+ // Try: event payload → cache → API resolve
536
+ const chatId = this._openIdToChat.get(openId)
537
+ ?? await this._resolveP2pChatId(openId);
538
+ if (!chatId) {
539
+ this._log(`[menu] cannot resolve chat_id for open_id=${openId}, event_key=${eventKey}`, 'warn');
540
+ return;
541
+ }
542
+ if (!this._isAllowed(chatId))
543
+ return;
544
+ this._debug(`[recv] menu event_key=${eventKey} open_id=${openId} chat=${chatId}`);
545
+ const from = { openId, userId: event.operator?.operator_id?.user_id };
546
+ const ctx = this._makeCtx(chatId, '', from, 'p2p', event);
547
+ await this._hCommand(eventKey, '', ctx);
548
+ }
549
+ async _dispatchMessageRecalled(event) {
550
+ const chatId = String(event?.chat_id || '').trim();
551
+ const messageId = String(event?.message_id || '').trim();
552
+ if (!chatId || !messageId || !this._hRecall)
553
+ return;
554
+ if (!this._isAllowed(chatId)) {
555
+ this._debug(`[message-recalled] blocked: chat=${chatId}`);
556
+ return;
557
+ }
558
+ this._trackChat(chatId);
559
+ this._debug(`[recv] message_recalled chat=${chatId} msg=${messageId}`);
560
+ await this._hRecall(messageId, chatId, event);
561
+ }
562
+ /**
563
+ * Resolve a p2p chat_id for a given open_id by sending a minimal message
564
+ * via open_id and extracting the chat_id from the API response.
565
+ */
566
+ async _resolveP2pChatId(openId) {
567
+ try {
568
+ const resp = await this.client.im.message.create({
569
+ params: { receive_id_type: 'open_id' },
570
+ data: {
571
+ receive_id: openId,
572
+ msg_type: 'text',
573
+ content: JSON.stringify({ text: '...' }),
574
+ },
575
+ });
576
+ const chatId = resp?.data?.chat_id ?? null;
577
+ const msgId = resp?.data?.message_id;
578
+ // Clean up the placeholder message
579
+ if (msgId) {
580
+ try {
581
+ await this.client.im.message.delete({ path: { message_id: msgId } });
582
+ }
583
+ catch { }
584
+ }
585
+ if (chatId) {
586
+ this._openIdToChat.set(openId, chatId);
587
+ this._trackChat(chatId);
588
+ this._debug(`[menu] resolved chat_id=${chatId} for open_id=${openId}`);
589
+ }
590
+ return chatId;
591
+ }
592
+ catch (e) {
593
+ this._log(`[menu] resolve chat_id failed for open_id=${openId}: ${e?.message || e}`, 'warn');
594
+ return null;
595
+ }
596
+ }
597
+ // ========================================================================
598
+ // Outgoing primitives (Channel interface)
599
+ // ========================================================================
600
+ async setMenu(commands) {
601
+ this._debug(`[menu] ${commands.length} commands. Configure in Feishu Developer Console → Bot → Custom Menu:`);
602
+ for (const c of commands) {
603
+ this._debug(`[menu] event_key="${c.command}" name="${c.description}"`);
604
+ }
605
+ }
606
+ async clearMenu() {
607
+ this._debug('[menu] cleared (remove items in Feishu Developer Console)');
608
+ }
609
+ async sendCard(chatId, view) {
610
+ const card = buildCardFromView(view);
611
+ this._logOutgoing('send', `chat=${chatId} chars=${view.markdown.length} rows=${view.rows?.length || 0}`);
612
+ const resp = await this.client.im.message.create({
613
+ params: { receive_id_type: 'chat_id' },
614
+ data: {
615
+ receive_id: String(chatId),
616
+ msg_type: 'interactive',
617
+ content: JSON.stringify(card),
618
+ },
619
+ });
620
+ return requireMessageId(resp, 'send interactive card');
621
+ }
622
+ async send(chatId, text, opts = {}) {
623
+ const rows = keyboardToRows(opts.keyboard);
624
+ const view = { markdown: text.trim() || '(empty)', rows };
625
+ // Reply to a specific message if replyTo is set
626
+ if (opts.replyTo) {
627
+ return await this.replyCard(String(opts.replyTo), view);
628
+ }
629
+ return await this.sendCard(chatId, view);
630
+ }
631
+ async replyCard(replyToMsgId, view) {
632
+ const card = buildCardFromView(view);
633
+ this._logOutgoing('reply', `reply_to=${replyToMsgId} chars=${view.markdown.length} rows=${view.rows?.length || 0}`);
634
+ const resp = await this.client.im.message.reply({
635
+ path: { message_id: replyToMsgId },
636
+ data: {
637
+ msg_type: 'interactive',
638
+ content: JSON.stringify(card),
639
+ },
640
+ });
641
+ return requireMessageId(resp, 'reply interactive card');
642
+ }
643
+ async editCard(chatId, msgId, view) {
644
+ if (!view.markdown.trim())
645
+ return;
646
+ const card = buildCardFromView(view);
647
+ this._logOutgoing('edit', `chat=${chatId} msg_id=${msgId} chars=${view.markdown.length} rows=${view.rows?.length || 0}`);
648
+ // The Lark SDK's response interceptor returns the JSON body for any HTTP 2xx, so
649
+ // Feishu application errors (`code != 0`) never throw — they look like success here.
650
+ // Inspect the response code ourselves so callers can fall back to a fresh send.
651
+ let resp;
652
+ try {
653
+ resp = await this.client.im.message.patch({
654
+ path: { message_id: String(msgId) },
655
+ data: { content: JSON.stringify(card) },
656
+ });
657
+ }
658
+ catch (e) {
659
+ const msg = String(e?.message || e).toLowerCase();
660
+ if (isFeishuNotModifiedMessage(msg))
661
+ return;
662
+ const err = e instanceof Error ? e : new Error(String(e ?? 'edit card failed'));
663
+ err.feishuEditFailed = true;
664
+ throw err;
665
+ }
666
+ const code = resp?.code;
667
+ if (code != null && code !== 0) {
668
+ const msg = String(resp?.msg ?? resp?.message ?? '').trim();
669
+ if (isFeishuNotModifiedMessage(msg))
670
+ return;
671
+ const err = new Error(`edit card failed: code=${code} msg=${msg || '(no message)'}`);
672
+ err.feishuCode = code;
673
+ err.feishuEditFailed = true;
674
+ throw err;
675
+ }
676
+ }
677
+ async editMessage(chatId, msgId, text, opts = {}) {
678
+ if (!text.trim())
679
+ return;
680
+ const rows = keyboardToRows(opts.keyboard);
681
+ await this.editCard(chatId, msgId, {
682
+ markdown: text,
683
+ rows,
684
+ });
685
+ }
686
+ async deleteMessage(_chatId, msgId) {
687
+ try {
688
+ await this.client.im.message.delete({
689
+ path: { message_id: String(msgId) },
690
+ });
691
+ }
692
+ catch { }
693
+ }
694
+ async sendTyping(_chatId) {
695
+ // Feishu has no typing indicator API — no-op
696
+ }
697
+ async setMessageReaction(_chatId, msgId, reactions) {
698
+ const messageId = String(msgId || '').trim();
699
+ const emojiTypes = [...new Set(reactions.map(reaction => String(reaction || '').trim()).filter(Boolean))];
700
+ if (!messageId || !emojiTypes.length)
701
+ return;
702
+ this._logOutgoing('setReaction', `msg_id=${messageId} reactions=${emojiTypes.join(',')}`);
703
+ for (const emojiType of emojiTypes) {
704
+ await this.client.im.messageReaction.create({
705
+ path: { message_id: messageId },
706
+ data: { reaction_type: { emoji_type: emojiType } },
707
+ }).catch(() => { });
708
+ }
709
+ }
710
+ // ========================================================================
711
+ // Feishu-specific outgoing
712
+ // ========================================================================
713
+ /** Send a text message (not card). For simple notifications. */
714
+ async sendText(chatId, text) {
715
+ const resp = await this.client.im.message.create({
716
+ params: { receive_id_type: 'chat_id' },
717
+ data: {
718
+ receive_id: chatId,
719
+ msg_type: 'text',
720
+ content: JSON.stringify({ text }),
721
+ },
722
+ });
723
+ return requireMessageId(resp, 'send text');
724
+ }
725
+ async sendPost(chatId, content, opts = {}) {
726
+ const replyTo = opts.replyTo ? String(opts.replyTo) : undefined;
727
+ this._logOutgoing('sendPost', `${replyTo ? `reply_to=${replyTo}` : `chat=${chatId}`} chars=${content.length}`);
728
+ const resp = replyTo
729
+ ? await this.client.im.message.reply({
730
+ path: { message_id: replyTo },
731
+ data: { msg_type: 'post', content },
732
+ })
733
+ : await this.client.im.message.create({
734
+ params: { receive_id_type: 'chat_id' },
735
+ data: { receive_id: chatId, msg_type: 'post', content },
736
+ });
737
+ return requireMessageId(resp, 'send post');
738
+ }
739
+ /** Upload an image and return the image_key. */
740
+ async uploadImage(imageBuffer) {
741
+ this._logOutgoing('uploadImage', `bytes=${imageBuffer.byteLength}`);
742
+ const resp = await this.client.im.image.create({
743
+ data: {
744
+ image_type: 'message',
745
+ image: imageBuffer,
746
+ },
747
+ });
748
+ const imageKey = resp?.image_key ?? resp?.data?.image_key;
749
+ if (!imageKey)
750
+ throw new Error('Image upload failed: no image_key returned');
751
+ return imageKey;
752
+ }
753
+ /** Upload a file and return the file_key. */
754
+ async uploadFile(fileBuffer, fileName) {
755
+ const ext = path.extname(fileName).toLowerCase().slice(1);
756
+ const fileType = (['pdf', 'doc', 'xls', 'ppt'].includes(ext) ? ext : 'stream');
757
+ this._logOutgoing('uploadFile', `file=${fileName} bytes=${fileBuffer.byteLength}`);
758
+ const resp = await this.client.im.file.create({
759
+ data: {
760
+ file_type: fileType,
761
+ file_name: fileName,
762
+ file: fileBuffer,
763
+ },
764
+ });
765
+ const fileKey = resp?.file_key ?? resp?.data?.file_key;
766
+ if (!fileKey)
767
+ throw new Error('File upload failed: no file_key returned');
768
+ return fileKey;
769
+ }
770
+ /** Upload and send a local file. */
771
+ async sendFile(chatId, filePath, opts = {}) {
772
+ const stat = fs.statSync(filePath);
773
+ if (stat.size > FILE_MAX_BYTES) {
774
+ throw new Error(`file too large (${(stat.size / 1024 / 1024).toFixed(1)}MB, max ${FILE_MAX_BYTES / 1024 / 1024}MB)`);
775
+ }
776
+ const content = fs.readFileSync(filePath);
777
+ const filename = path.basename(filePath);
778
+ const isPhoto = opts.asPhoto ?? PHOTO_EXTS.has(path.extname(filename).toLowerCase());
779
+ const caption = typeof opts.caption === 'string' ? opts.caption.trim() : '';
780
+ const replyTo = opts.replyTo ? String(opts.replyTo) : undefined;
781
+ if (isPhoto) {
782
+ try {
783
+ const imageKey = await this.uploadImage(content);
784
+ if (caption) {
785
+ return await this.sendPost(String(chatId), buildPostContent([
786
+ [{ tag: 'img', image_key: imageKey }],
787
+ [{ tag: 'text', text: caption }],
788
+ ]), { replyTo });
789
+ }
790
+ const msgContent = JSON.stringify({ image_key: imageKey });
791
+ this._logOutgoing('sendImage', `${replyTo ? `reply_to=${replyTo}` : `chat=${chatId}`} file=${filename}`);
792
+ const resp = replyTo
793
+ ? await this.client.im.message.reply({ path: { message_id: replyTo }, data: { msg_type: 'image', content: msgContent } })
794
+ : await this.client.im.message.create({ params: { receive_id_type: 'chat_id' }, data: { receive_id: String(chatId), msg_type: 'image', content: msgContent } });
795
+ return requireMessageId(resp, 'send image');
796
+ }
797
+ catch (err) {
798
+ if (isRetryableUploadError(err))
799
+ throw err;
800
+ }
801
+ }
802
+ const fileKey = await this.uploadFile(content, filename);
803
+ const msgContent = JSON.stringify({ file_key: fileKey });
804
+ this._logOutgoing('sendFile', `${replyTo ? `reply_to=${replyTo}` : `chat=${chatId}`} file=${filename}`);
805
+ const resp = replyTo
806
+ ? await this.client.im.message.reply({ path: { message_id: replyTo }, data: { msg_type: 'file', content: msgContent } })
807
+ : await this.client.im.message.create({ params: { receive_id_type: 'chat_id' }, data: { receive_id: String(chatId), msg_type: 'file', content: msgContent } });
808
+ return requireMessageId(resp, 'send file');
809
+ }
810
+ // ========================================================================
811
+ // Download resources from received messages
812
+ // ========================================================================
813
+ async _downloadResource(messageId, fileKey, type, filename) {
814
+ const resp = await this.client.im.messageResource.get({
815
+ path: { message_id: messageId, file_key: fileKey },
816
+ params: { type },
817
+ });
818
+ const ext = type === 'image' ? '.jpg' : (filename ? path.extname(filename) : '.bin');
819
+ const name = filename || `feishu_${fileKey.slice(-8)}${ext}`;
820
+ const localPath = path.join(this.workdir, `_feishu_${name}`);
821
+ fs.mkdirSync(this.workdir, { recursive: true });
822
+ await resp.writeFile(localPath);
823
+ // Check downloaded file size
824
+ const stat = fs.statSync(localPath);
825
+ if (stat.size > FILE_MAX_BYTES) {
826
+ fs.rmSync(localPath, { force: true });
827
+ throw new Error(`file too large (${(stat.size / 1024 / 1024).toFixed(1)}MB, max ${FILE_MAX_BYTES / 1024 / 1024}MB)`);
828
+ }
829
+ return localPath;
830
+ }
831
+ // ========================================================================
832
+ // Internal helpers
833
+ // ========================================================================
834
+ _makeCtx(chatId, messageId, from, chatType, raw, replyToMessageId) {
835
+ return {
836
+ chatId,
837
+ messageId,
838
+ from,
839
+ chatType,
840
+ replyToMessageId: replyToMessageId || null,
841
+ reply: (text, opts) => this.send(chatId, text, { ...opts, replyTo: messageId || opts?.replyTo }),
842
+ editReply: (msgId, text, opts) => this.editMessage(chatId, msgId, text, opts),
843
+ channel: this,
844
+ raw,
845
+ };
846
+ }
847
+ _isAllowed(chatId) {
848
+ return this.allowedChatIds.size === 0 || this.allowedChatIds.has(chatId);
849
+ }
850
+ /**
851
+ * Add a chat to the in-memory `knownChats` set and persist it to setting.json
852
+ * so a future cold start (e.g. crash-respawn) can still address it. The
853
+ * persistence call is fire-and-forget — disk errors must not break receive.
854
+ */
855
+ _trackChat(chatId) {
856
+ if (this.knownChats.has(chatId))
857
+ return;
858
+ this.knownChats.add(chatId);
859
+ try {
860
+ recordKnownChatId('feishu', chatId);
861
+ }
862
+ catch { }
863
+ }
864
+ _isBotMentioned(msg) {
865
+ const mentions = msg.mentions || [];
866
+ if (!this.bot)
867
+ return mentions.length > 0;
868
+ return mentions.some((m) => {
869
+ const mentionId = m.id?.open_id || m.id?.app_id || '';
870
+ return mentionId === this.bot.id || m.name === this.bot.displayName;
871
+ });
872
+ }
873
+ _cleanMention(text) {
874
+ return text.replace(/@_user_\d+/g, '').trim();
875
+ }
876
+ /** Extract plain text from a rich text (post) message content. */
877
+ _extractPostText(content) {
878
+ const post = content.zh_cn || content.en_us || content;
879
+ const parts = [];
880
+ if (post.title)
881
+ parts.push(post.title);
882
+ const paragraphs = post.content || [];
883
+ for (const paragraph of paragraphs) {
884
+ if (!Array.isArray(paragraph))
885
+ continue;
886
+ const line = paragraph
887
+ .map((elem) => {
888
+ if (elem.tag === 'text')
889
+ return elem.text || '';
890
+ if (elem.tag === 'a')
891
+ return elem.text || elem.href || '';
892
+ if (elem.tag === 'at')
893
+ return '';
894
+ return '';
895
+ })
896
+ .join('');
897
+ if (line.trim())
898
+ parts.push(line);
899
+ }
900
+ return parts.join('\n');
901
+ }
902
+ _debug(msg) {
903
+ this._log(msg, 'debug');
904
+ }
905
+ _log(msg, level = 'info') {
906
+ writeScopedLog('feishu', msg, { level });
907
+ }
908
+ _logOutgoing(action, meta) {
909
+ this._debug(`[send] ${action} ${meta}`);
910
+ }
911
+ }