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,752 @@
1
+ /**
2
+ * bot-feishu.ts — Feishu bot orchestration: commands, streaming, artifacts, lifecycle.
3
+ *
4
+ * Follows the same pattern as bot-telegram.ts:
5
+ * - Commands use shared data layer (bot-commands.ts) + Feishu renderer
6
+ * - Messages flow through the streaming pipeline
7
+ * - LivePreview provides real-time streaming updates via card edits
8
+ */
9
+ import os from 'node:os';
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { Bot, VERSION, normalizeAgent, fmtTokens, buildPrompt, parseAllowedChatIds, } from './bot.js';
13
+ import { stageSessionFiles, } from './code-agent.js';
14
+ import { shutdownAllDrivers } from './agent-driver.js';
15
+ import { buildDefaultMenuCommands, SKILL_CMD_PREFIX, } from './bot-menu.js';
16
+ import { getStartData, getSessionsPageData, getModelsListData, getSessionTurnPreviewData, getStatusDataAsync, getHostDataSync, resolveSkillPrompt, } from './bot-commands.js';
17
+ import { buildAgentsCommandView, buildModelsCommandView, buildSessionsCommandView, buildSkillsCommandView, decodeCommandAction, executeCommandAction, } from './bot-command-ui.js';
18
+ import { LivePreview } from './bot-telegram-live-preview.js';
19
+ import { formatActiveTaskRestartError, getActiveTaskCount, registerProcessRuntime, requestProcessRestart, } from './process-control.js';
20
+ import { feishuPreviewRenderer, buildInitialPreviewMarkdown, buildFinalReplyRender, renderCommandNotice, renderCommandSelectionCard, renderSessionTurnMarkdown, renderStart, renderStatus, renderHost, buildSwitchWorkdirCard, resolveFeishuRegisteredPath, } from './bot-feishu-render.js';
21
+ import { FeishuChannel } from './channel-feishu.js';
22
+ import { splitText, supportsChannelCapability } from './channel-base.js';
23
+ import { getActiveUserConfig } from './user-config.js';
24
+ const SHUTDOWN_EXIT_CODE = {
25
+ SIGINT: 130,
26
+ SIGTERM: 143,
27
+ };
28
+ const SHUTDOWN_FORCE_EXIT_MS = 3_000;
29
+ function describeError(err) {
30
+ if (!(err instanceof Error))
31
+ return String(err ?? 'unknown error');
32
+ const parts = [`${err.name}: ${err.message}`];
33
+ for (const key of ['code', 'errno', 'syscall', 'address', 'port', 'host', 'hostname', 'path']) {
34
+ const value = err?.[key];
35
+ if (value != null && value !== '')
36
+ parts.push(`${key}=${value}`);
37
+ }
38
+ const cause = err?.cause;
39
+ if (cause && cause !== err)
40
+ parts.push(`cause=${describeError(cause)}`);
41
+ return parts.join(' | ');
42
+ }
43
+ // ---------------------------------------------------------------------------
44
+ // FeishuBot
45
+ // ---------------------------------------------------------------------------
46
+ export class FeishuBot extends Bot {
47
+ appId;
48
+ appSecret;
49
+ domain;
50
+ channel;
51
+ /** Maps chatId → (messageId → sessionKey) for reply-chain session tracking. */
52
+ sessionMessages = new Map();
53
+ nextTaskId = 1;
54
+ shutdownInFlight = false;
55
+ shutdownExitCode = null;
56
+ shutdownForceExitTimer = null;
57
+ signalHandlers = {};
58
+ processRuntimeCleanup = null;
59
+ constructor() {
60
+ super();
61
+ const config = getActiveUserConfig();
62
+ // Merge Feishu-specific allowed IDs into base
63
+ if (process.env.FEISHU_ALLOWED_CHAT_IDS) {
64
+ for (const id of parseAllowedChatIds(process.env.FEISHU_ALLOWED_CHAT_IDS))
65
+ this.allowedChatIds.add(id);
66
+ }
67
+ this.appId = String(config.feishuAppId || process.env.FEISHU_APP_ID || '').trim();
68
+ this.appSecret = String(config.feishuAppSecret || process.env.FEISHU_APP_SECRET || '').trim();
69
+ this.domain = (process.env.FEISHU_DOMAIN || 'https://open.feishu.cn').trim();
70
+ if (!this.appId || !this.appSecret) {
71
+ throw new Error('Missing Feishu credentials. Set FEISHU_APP_ID and FEISHU_APP_SECRET');
72
+ }
73
+ }
74
+ onManagedConfigChange(config, opts = {}) {
75
+ const nextAppId = String(config.feishuAppId || process.env.FEISHU_APP_ID || '').trim();
76
+ const nextAppSecret = String(config.feishuAppSecret || process.env.FEISHU_APP_SECRET || '').trim();
77
+ if (nextAppId && nextAppId !== this.appId) {
78
+ this.appId = nextAppId;
79
+ if (!opts.initial)
80
+ this.log('feishu appId reloaded from setting.json');
81
+ }
82
+ if (nextAppSecret && nextAppSecret !== this.appSecret) {
83
+ this.appSecret = nextAppSecret;
84
+ if (!opts.initial)
85
+ this.log('feishu appSecret reloaded from setting.json');
86
+ }
87
+ }
88
+ static SKILL_CMD_PREFIX = SKILL_CMD_PREFIX;
89
+ async setupMenu() {
90
+ if (!supportsChannelCapability(this.channel, 'commandMenu'))
91
+ return;
92
+ const res = this.fetchAgents();
93
+ const installedCount = res.agents.filter(a => a.installed).length;
94
+ const skillRes = this.fetchSkills();
95
+ const commands = buildDefaultMenuCommands(installedCount, skillRes.skills);
96
+ await this.channel.setMenu(commands);
97
+ this.log(`menu: ${commands.length} commands (${skillRes.skills.length} skills)`);
98
+ }
99
+ afterSwitchWorkdir(_oldPath, _newPath) {
100
+ this.sessionMessages.clear();
101
+ if (!this.channel)
102
+ return;
103
+ void this.setupMenu().catch(err => this.log(`menu refresh failed: ${err}`));
104
+ }
105
+ // ---- signal handling ------------------------------------------------------
106
+ installSignalHandlers() {
107
+ this.removeSignalHandlers();
108
+ const onSigint = () => this.beginShutdown('SIGINT');
109
+ const onSigterm = () => this.beginShutdown('SIGTERM');
110
+ this.signalHandlers = { SIGINT: onSigint, SIGTERM: onSigterm };
111
+ process.once('SIGINT', onSigint);
112
+ process.once('SIGTERM', onSigterm);
113
+ }
114
+ removeSignalHandlers() {
115
+ for (const sig of Object.keys(this.signalHandlers)) {
116
+ const handler = this.signalHandlers[sig];
117
+ if (handler)
118
+ process.off(sig, handler);
119
+ }
120
+ this.signalHandlers = {};
121
+ }
122
+ beginShutdown(sig) {
123
+ if (this.shutdownInFlight)
124
+ return;
125
+ this.shutdownInFlight = true;
126
+ this.shutdownExitCode = SHUTDOWN_EXIT_CODE[sig];
127
+ this.log(`${sig}, shutting down...`);
128
+ this.cleanupRuntimeForExit();
129
+ if (this.shutdownForceExitTimer)
130
+ clearTimeout(this.shutdownForceExitTimer);
131
+ this.shutdownForceExitTimer = setTimeout(() => {
132
+ this.log(`shutdown still pending after ${Math.floor(SHUTDOWN_FORCE_EXIT_MS / 1000)}s, forcing exit`);
133
+ process.exit(this.shutdownExitCode ?? 1);
134
+ }, SHUTDOWN_FORCE_EXIT_MS);
135
+ this.shutdownForceExitTimer.unref?.();
136
+ }
137
+ cleanupRuntimeForExit() {
138
+ try {
139
+ this.channel.disconnect();
140
+ }
141
+ catch { }
142
+ this.stopKeepAlive();
143
+ shutdownAllDrivers();
144
+ }
145
+ buildRestartEnv() {
146
+ const knownIds = new Set(this.allowedChatIds);
147
+ for (const cid of this.channel.knownChats)
148
+ knownIds.add(cid);
149
+ return knownIds.size ? { FEISHU_ALLOWED_CHAT_IDS: [...knownIds].join(',') } : {};
150
+ }
151
+ // ---- session tracking -----------------------------------------------------
152
+ createTaskId(session) {
153
+ const seq = this.nextTaskId++;
154
+ return `${session.key}:${Date.now().toString(36)}:${seq.toString(36)}`;
155
+ }
156
+ registerSessionMessage(chatId, messageId, session) {
157
+ if (session.workdir !== this.workdir)
158
+ return;
159
+ if (!messageId)
160
+ return;
161
+ let messages = this.sessionMessages.get(chatId);
162
+ if (!messages) {
163
+ messages = new Map();
164
+ this.sessionMessages.set(chatId, messages);
165
+ }
166
+ messages.set(messageId, session.key);
167
+ // Cap size
168
+ while (messages.size > 1024) {
169
+ const oldest = messages.keys().next();
170
+ if (oldest.done)
171
+ break;
172
+ messages.delete(oldest.value);
173
+ }
174
+ }
175
+ registerSessionMessages(chatId, messageIds, session) {
176
+ for (const messageId of messageIds)
177
+ this.registerSessionMessage(chatId, messageId, session);
178
+ }
179
+ sessionFromMessage(chatId, messageId) {
180
+ if (!messageId)
181
+ return null;
182
+ const sessionKey = this.sessionMessages.get(chatId)?.get(messageId) || null;
183
+ return this.getSessionRuntimeByKey(sessionKey);
184
+ }
185
+ ensureSession(chatId, title, files) {
186
+ const cs = this.chat(chatId);
187
+ const selected = this.getSelectedSession(cs);
188
+ if (selected)
189
+ return selected;
190
+ const staged = stageSessionFiles({
191
+ agent: cs.agent,
192
+ workdir: this.workdir,
193
+ files: [],
194
+ sessionId: null,
195
+ title: title || files[0] || 'New session',
196
+ });
197
+ const runtime = this.upsertSessionRuntime({
198
+ agent: cs.agent,
199
+ sessionId: staged.sessionId,
200
+ workspacePath: staged.workspacePath,
201
+ modelId: this.modelForAgent(cs.agent),
202
+ });
203
+ this.applySessionSelection(cs, runtime);
204
+ return runtime;
205
+ }
206
+ resolveIncomingSession(ctx, text, files) {
207
+ const cs = this.chat(ctx.chatId);
208
+ // TODO: Feishu doesn't expose reply_to in the event easily; for now use active session
209
+ const selected = this.getSelectedSession(cs);
210
+ if (selected)
211
+ return selected;
212
+ return this.ensureSession(ctx.chatId, text, files);
213
+ }
214
+ // ---- commands -------------------------------------------------------------
215
+ async cmdStart(ctx) {
216
+ const d = getStartData(this, ctx.chatId);
217
+ await ctx.reply(renderStart(d));
218
+ }
219
+ async cmdSkills(ctx) {
220
+ await this.sendCommandView(ctx, buildSkillsCommandView(this, ctx.chatId));
221
+ }
222
+ async sendCommandView(ctx, view) {
223
+ await ctx.channel.sendCard(ctx.chatId, renderCommandSelectionCard(view));
224
+ }
225
+ async replyCommandResult(ctx, result) {
226
+ if (result.kind === 'view') {
227
+ await this.sendCommandView(ctx, result.view);
228
+ return;
229
+ }
230
+ if (result.kind === 'skill') {
231
+ await this.handleMessage({ text: result.prompt, files: [] }, ctx);
232
+ return;
233
+ }
234
+ if (result.kind === 'notice') {
235
+ const sent = await ctx.reply(renderCommandNotice(result.notice));
236
+ if (result.session && sent)
237
+ this.registerSessionMessage(ctx.chatId, sent, result.session);
238
+ if (result.previewSession) {
239
+ await this.previewCurrentSessionTurn(ctx.chatId, result.previewSession.agent, result.previewSession.sessionId);
240
+ }
241
+ return;
242
+ }
243
+ await ctx.reply(result.message);
244
+ }
245
+ async applyCommandCallbackResult(ctx, result) {
246
+ if (result.kind === 'noop')
247
+ return;
248
+ if (result.kind === 'view') {
249
+ await ctx.channel.editCard(ctx.chatId, ctx.messageId, renderCommandSelectionCard(result.view));
250
+ return;
251
+ }
252
+ if (result.kind === 'skill') {
253
+ await this.handleMessage({ text: result.prompt, files: [] }, this.callbackToMessageContext(ctx));
254
+ return;
255
+ }
256
+ await ctx.editReply(ctx.messageId, renderCommandNotice(result.notice));
257
+ if (result.session)
258
+ this.registerSessionMessage(ctx.chatId, ctx.messageId, result.session);
259
+ if (result.previewSession) {
260
+ await this.previewCurrentSessionTurn(ctx.chatId, result.previewSession.agent, result.previewSession.sessionId);
261
+ }
262
+ }
263
+ sessionsPageSize = 5;
264
+ async cmdSessions(ctx, args) {
265
+ const arg = args.trim().toLowerCase();
266
+ if (arg === 'new') {
267
+ await this.replyCommandResult(ctx, await executeCommandAction(this, ctx.chatId, { kind: 'session.new' }, { sessionsPageSize: this.sessionsPageSize }));
268
+ return;
269
+ }
270
+ const pageMatch = arg.match(/^p(\d+)$/);
271
+ if (pageMatch) {
272
+ await this.replyCommandResult(ctx, await executeCommandAction(this, ctx.chatId, { kind: 'sessions.page', page: parseInt(pageMatch[1], 10) - 1 }, { sessionsPageSize: this.sessionsPageSize }));
273
+ return;
274
+ }
275
+ const idx = parseInt(arg, 10);
276
+ if (!isNaN(idx) && idx >= 1) {
277
+ const d = await getSessionsPageData(this, ctx.chatId, 0, 100);
278
+ const target = d.sessions[idx - 1];
279
+ if (target) {
280
+ await this.replyCommandResult(ctx, await executeCommandAction(this, ctx.chatId, { kind: 'session.switch', sessionId: target.key }));
281
+ return;
282
+ }
283
+ await ctx.reply(`Session #${idx} not found.`);
284
+ return;
285
+ }
286
+ await this.sendCommandView(ctx, await buildSessionsCommandView(this, ctx.chatId, 0, this.sessionsPageSize));
287
+ }
288
+ async cmdStatus(ctx) {
289
+ const d = await getStatusDataAsync(this, ctx.chatId);
290
+ await ctx.reply(renderStatus(d));
291
+ }
292
+ async cmdHost(ctx) {
293
+ const d = getHostDataSync(this);
294
+ await ctx.reply(renderHost(d));
295
+ }
296
+ async cmdAgents(ctx, args) {
297
+ const arg = args.trim().toLowerCase();
298
+ if (arg) {
299
+ try {
300
+ const agent = normalizeAgent(arg);
301
+ await this.replyCommandResult(ctx, await executeCommandAction(this, ctx.chatId, { kind: 'agent.switch', agent }));
302
+ return;
303
+ }
304
+ catch {
305
+ // Not a valid agent name — show list
306
+ }
307
+ }
308
+ await this.sendCommandView(ctx, buildAgentsCommandView(this, ctx.chatId));
309
+ }
310
+ async cmdModels(ctx, args) {
311
+ const arg = args.trim();
312
+ if (arg) {
313
+ const d = await getModelsListData(this, ctx.chatId);
314
+ const idx = parseInt(arg, 10);
315
+ let modelId = null;
316
+ if (!isNaN(idx) && idx >= 1 && idx <= d.models.length) {
317
+ modelId = d.models[idx - 1].id;
318
+ }
319
+ else {
320
+ const match = d.models.find(m => m.id === arg || m.alias === arg);
321
+ if (match)
322
+ modelId = match.id;
323
+ }
324
+ if (modelId) {
325
+ await this.replyCommandResult(ctx, await executeCommandAction(this, ctx.chatId, { kind: 'model.switch', modelId }));
326
+ return;
327
+ }
328
+ }
329
+ await this.sendCommandView(ctx, await buildModelsCommandView(this, ctx.chatId));
330
+ }
331
+ async cmdSwitch(ctx, args) {
332
+ const arg = args.trim();
333
+ if (arg) {
334
+ const resolvedPath = path.resolve(arg.replace(/^~/, process.env.HOME || ''));
335
+ if (!fs.existsSync(resolvedPath) || !fs.statSync(resolvedPath).isDirectory()) {
336
+ await ctx.reply(`Not a valid directory: \`${resolvedPath}\``);
337
+ return;
338
+ }
339
+ const oldPath = this.switchWorkdir(resolvedPath);
340
+ await ctx.reply(`**Workdir switched**\n\n\`${oldPath}\`\n↓\n\`${resolvedPath}\``);
341
+ return;
342
+ }
343
+ const browsePath = path.dirname(this.workdir);
344
+ const view = buildSwitchWorkdirCard(this.workdir, browsePath);
345
+ await ctx.channel.sendCard(ctx.chatId, view);
346
+ }
347
+ async cmdRestart(ctx) {
348
+ const activeTasks = getActiveTaskCount();
349
+ if (activeTasks > 0) {
350
+ await ctx.reply(`⚠ ${formatActiveTaskRestartError(activeTasks)}`);
351
+ return;
352
+ }
353
+ await ctx.reply('**Restarting pikiclaw...**\n\nPulling latest version. The bot will be back shortly.');
354
+ void requestProcessRestart({ log: msg => this.log(msg) });
355
+ }
356
+ // ---- streaming bridge -----------------------------------------------------
357
+ async handleMessage(msg, ctx) {
358
+ const text = msg.text.trim();
359
+ if (!text && !msg.files.length)
360
+ return;
361
+ const session = this.resolveIncomingSession(ctx, text, msg.files);
362
+ const cs = this.chat(ctx.chatId);
363
+ this.applySessionSelection(cs, session);
364
+ // File-only message: stage files
365
+ if (!text && msg.files.length) {
366
+ const hadPendingWork = this.sessionHasPendingWork(session);
367
+ const stageTask = this.queueSessionTask(session, async () => {
368
+ try {
369
+ const staged = stageSessionFiles({
370
+ agent: session.agent,
371
+ workdir: this.workdir,
372
+ files: msg.files,
373
+ sessionId: session.sessionId,
374
+ title: msg.files[0],
375
+ });
376
+ session.workspacePath = staged.workspacePath;
377
+ this.syncSelectedChats(session);
378
+ if (!staged.importedFiles.length)
379
+ throw new Error('no files persisted');
380
+ this.log(`[handleMessage] staged files chat=${ctx.chatId} session=${staged.sessionId} files=${staged.importedFiles.length}`);
381
+ this.registerSessionMessage(ctx.chatId, ctx.messageId, session);
382
+ }
383
+ catch (e) {
384
+ this.log(`[handleMessage] stage files failed: ${e?.message || e}`);
385
+ }
386
+ });
387
+ if (hadPendingWork) {
388
+ void stageTask.catch(e => this.log(`[handleMessage] stage queue failed: ${e}`));
389
+ }
390
+ else {
391
+ await stageTask.catch(e => this.log(`[handleMessage] stage queue failed: ${e}`));
392
+ }
393
+ return;
394
+ }
395
+ const files = msg.files;
396
+ const prompt = buildPrompt(text, files);
397
+ const start = Date.now();
398
+ this.log(`[handleMessage] queued chat=${ctx.chatId} agent=${session.agent} session=${session.sessionId || '(new)'} prompt="${prompt.slice(0, 100)}" files=${files.length}`);
399
+ // Send streaming card (CardKit typewriter effect) or fall back to regular card
400
+ const placeholderId = await this.channel.sendStreamingCard(ctx.chatId, buildInitialPreviewMarkdown(session.agent), { replyTo: ctx.messageId || undefined });
401
+ if (placeholderId) {
402
+ this.registerSessionMessage(ctx.chatId, placeholderId, session);
403
+ this.log(`[handleMessage] streaming card sent msg_id=${placeholderId}`);
404
+ }
405
+ const taskId = this.createTaskId(session);
406
+ this.beginTask({
407
+ taskId,
408
+ chatId: ctx.chatId,
409
+ agent: session.agent,
410
+ sessionKey: session.key,
411
+ prompt,
412
+ startedAt: start,
413
+ sourceMessageId: ctx.messageId,
414
+ });
415
+ void this.queueSessionTask(session, async () => {
416
+ let livePreview = null;
417
+ try {
418
+ if (placeholderId) {
419
+ livePreview = new LivePreview({
420
+ agent: session.agent,
421
+ chatId: ctx.chatId,
422
+ placeholderMessageId: placeholderId,
423
+ channel: this.channel,
424
+ renderer: feishuPreviewRenderer,
425
+ streamEditIntervalMs: 300, // CardKit streaming cards handle frequent updates well
426
+ startTimeMs: start,
427
+ canEditMessages: supportsChannelCapability(this.channel, 'editMessages'),
428
+ canSendTyping: false,
429
+ parseMode: 'Markdown',
430
+ log: (message) => this.log(message),
431
+ });
432
+ livePreview.start();
433
+ }
434
+ // MCP sendFile callback: sends files to IM in real-time during the stream
435
+ const mcpSendFile = this.createMcpSendFileCallback(ctx);
436
+ const result = await this.runStream(prompt, session, files, (nextText, nextThinking, nextActivity = '', meta, plan) => {
437
+ livePreview?.update(nextText, nextThinking, nextActivity, meta, plan);
438
+ }, undefined, mcpSendFile);
439
+ await livePreview?.settle();
440
+ // End streaming mode — finalize the card before sending final reply
441
+ if (placeholderId) {
442
+ const summary = result.message.slice(0, 80).replace(/\s+/g, ' ').trim() || 'Response complete.';
443
+ await this.channel.endStreaming(placeholderId, summary);
444
+ }
445
+ this.log(`[handleMessage] done agent=${session.agent} ok=${result.ok} elapsed=${result.elapsedS.toFixed(1)}s ` +
446
+ `tokens=in:${fmtTokens(result.inputTokens)}/out:${fmtTokens(result.outputTokens)}`);
447
+ const finalReplyIds = await this.sendFinalReply(ctx, placeholderId, session.agent, result);
448
+ this.registerSessionMessages(ctx.chatId, finalReplyIds, session);
449
+ this.log(`[handleMessage] final reply sent to chat=${ctx.chatId}`);
450
+ }
451
+ catch (e) {
452
+ const msgText = String(e?.message || e || 'Unknown error');
453
+ this.log(`[handleMessage] task failed chat=${ctx.chatId} error=${msgText}`);
454
+ const errorText = `**Error**\n\n\`${msgText.slice(0, 500)}\``;
455
+ if (placeholderId) {
456
+ try {
457
+ await this.channel.editMessage(ctx.chatId, placeholderId, errorText);
458
+ }
459
+ catch {
460
+ await this.channel.send(ctx.chatId, errorText).catch(() => null);
461
+ }
462
+ }
463
+ else {
464
+ await this.channel.send(ctx.chatId, errorText).catch(() => null);
465
+ }
466
+ }
467
+ finally {
468
+ livePreview?.dispose();
469
+ this.finishTask(taskId);
470
+ this.syncSelectedChats(session);
471
+ }
472
+ }).catch(e => {
473
+ this.log(`[handleMessage] queue execution failed: ${e}`);
474
+ this.finishTask(taskId);
475
+ });
476
+ }
477
+ async sendFinalReply(ctx, placeholderId, agent, result) {
478
+ const rendered = buildFinalReplyRender(agent, result);
479
+ const messageIds = [];
480
+ const MAX_CARD = 25_000;
481
+ if (rendered.fullText.length <= MAX_CARD) {
482
+ // Fits in one card — edit the placeholder
483
+ if (placeholderId) {
484
+ try {
485
+ await this.channel.editMessage(ctx.chatId, placeholderId, rendered.fullText);
486
+ messageIds.push(placeholderId);
487
+ return messageIds;
488
+ }
489
+ catch { }
490
+ }
491
+ const sent = await this.channel.send(ctx.chatId, rendered.fullText);
492
+ if (sent)
493
+ messageIds.push(sent);
494
+ }
495
+ else {
496
+ // Split: first card has header + truncated body + footer, continuation as separate cards
497
+ const maxFirst = MAX_CARD - rendered.headerText.length - rendered.footerText.length;
498
+ let firstBody;
499
+ let remaining;
500
+ if (maxFirst > 200) {
501
+ let cut = rendered.bodyText.lastIndexOf('\n', maxFirst);
502
+ if (cut < maxFirst * 0.3)
503
+ cut = maxFirst;
504
+ firstBody = rendered.bodyText.slice(0, cut);
505
+ remaining = rendered.bodyText.slice(cut);
506
+ }
507
+ else {
508
+ firstBody = '';
509
+ remaining = rendered.bodyText;
510
+ }
511
+ const firstText = `${rendered.headerText}${firstBody}${rendered.footerText}`;
512
+ if (placeholderId) {
513
+ try {
514
+ await this.channel.editMessage(ctx.chatId, placeholderId, firstText);
515
+ messageIds.push(placeholderId);
516
+ }
517
+ catch {
518
+ const sent = await this.channel.send(ctx.chatId, firstText);
519
+ if (sent)
520
+ messageIds.push(sent);
521
+ }
522
+ }
523
+ else {
524
+ const sent = await this.channel.send(ctx.chatId, firstText);
525
+ if (sent)
526
+ messageIds.push(sent);
527
+ }
528
+ if (remaining.trim()) {
529
+ const chunks = splitText(remaining, MAX_CARD);
530
+ for (const chunk of chunks) {
531
+ const sent = await this.channel.send(ctx.chatId, chunk);
532
+ if (sent)
533
+ messageIds.push(sent);
534
+ }
535
+ }
536
+ }
537
+ return messageIds;
538
+ }
539
+ /** Create an MCP sendFile callback bound to a Feishu chat context. */
540
+ createMcpSendFileCallback(ctx) {
541
+ return async (filePath, opts) => {
542
+ try {
543
+ await this.channel.sendFile(ctx.chatId, filePath, {
544
+ caption: opts?.caption,
545
+ asPhoto: opts?.kind === 'photo',
546
+ });
547
+ return { ok: true };
548
+ }
549
+ catch (e) {
550
+ this.log(`[mcp] sendFile failed: ${filePath} error=${e?.message || e}`);
551
+ return { ok: false, error: e?.message || 'send failed' };
552
+ }
553
+ };
554
+ }
555
+ // ---- command router -------------------------------------------------------
556
+ async handleCommand(cmd, args, ctx) {
557
+ try {
558
+ switch (cmd) {
559
+ case 'start':
560
+ await this.cmdStart(ctx);
561
+ return;
562
+ case 'sessions':
563
+ await this.cmdSessions(ctx, args);
564
+ return;
565
+ case 'agents':
566
+ await this.cmdAgents(ctx, args);
567
+ return;
568
+ case 'models':
569
+ await this.cmdModels(ctx, args);
570
+ return;
571
+ case 'skills':
572
+ await this.cmdSkills(ctx);
573
+ return;
574
+ case 'status':
575
+ await this.cmdStatus(ctx);
576
+ return;
577
+ case 'host':
578
+ await this.cmdHost(ctx);
579
+ return;
580
+ case 'switch':
581
+ await this.cmdSwitch(ctx, args);
582
+ return;
583
+ case 'restart':
584
+ await this.cmdRestart(ctx);
585
+ return;
586
+ default:
587
+ // Skill commands
588
+ if (cmd.startsWith(FeishuBot.SKILL_CMD_PREFIX)) {
589
+ await this.cmdSkill(cmd, args, ctx);
590
+ return;
591
+ }
592
+ // Unknown command — treat as message
593
+ await this.handleMessage({ text: `/${cmd}${args ? ' ' + args : ''}`, files: [] }, ctx);
594
+ }
595
+ }
596
+ catch (e) {
597
+ this.log(`cmd error: ${e}`);
598
+ await ctx.reply(`Error: ${String(e).slice(0, 200)}`);
599
+ }
600
+ }
601
+ async cmdSkill(cmd, args, ctx) {
602
+ const resolved = resolveSkillPrompt(this, ctx.chatId, cmd, args);
603
+ if (!resolved) {
604
+ await ctx.reply(`Skill not found for command /${cmd} in:\n\`${this.workdir}\``);
605
+ return;
606
+ }
607
+ this.log(`skill: ${resolved.skillName} agent=${this.chat(ctx.chatId).agent}${args.trim() ? ` args="${args.trim()}"` : ''}`);
608
+ await this.handleMessage({ text: resolved.prompt, files: [] }, ctx);
609
+ }
610
+ callbackToMessageContext(ctx) {
611
+ return {
612
+ chatId: ctx.chatId,
613
+ messageId: ctx.messageId,
614
+ from: ctx.from,
615
+ chatType: 'p2p',
616
+ reply: (text, opts) => ctx.channel.send(ctx.chatId, text, opts),
617
+ editReply: (msgId, text, opts) => ctx.channel.editMessage(ctx.chatId, msgId, text, opts),
618
+ channel: ctx.channel,
619
+ raw: ctx.raw,
620
+ };
621
+ }
622
+ // ---- callback handlers ----------------------------------------------------
623
+ async handleCallback(data, ctx) {
624
+ try {
625
+ if (await this.handleSwitchNavigateCallback(data, ctx))
626
+ return;
627
+ if (await this.handleSwitchSelectCallback(data, ctx))
628
+ return;
629
+ const action = decodeCommandAction(data);
630
+ if (!action)
631
+ return;
632
+ const result = await executeCommandAction(this, ctx.chatId, action, {
633
+ sessionsPageSize: this.sessionsPageSize,
634
+ });
635
+ await this.applyCommandCallbackResult(ctx, result);
636
+ }
637
+ catch (e) {
638
+ this.log(`callback error: ${e}`);
639
+ }
640
+ }
641
+ async handleSwitchNavigateCallback(data, ctx) {
642
+ if (!data.startsWith('sw:n:'))
643
+ return false;
644
+ const [pathId, pageRaw] = data.slice(5).split(':');
645
+ const browsePath = resolveFeishuRegisteredPath(parseInt(pathId, 10));
646
+ if (!browsePath)
647
+ return true;
648
+ const view = buildSwitchWorkdirCard(this.workdir, browsePath, parseInt(pageRaw, 10) || 0);
649
+ await ctx.channel.editCard(ctx.chatId, ctx.messageId, view);
650
+ return true;
651
+ }
652
+ async handleSwitchSelectCallback(data, ctx) {
653
+ if (!data.startsWith('sw:s:'))
654
+ return false;
655
+ const dirPath = resolveFeishuRegisteredPath(parseInt(data.slice(5), 10));
656
+ if (!dirPath || !fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory())
657
+ return true;
658
+ const oldPath = this.switchWorkdir(dirPath);
659
+ await ctx.editReply(ctx.messageId, `**Workdir**\n● \`${oldPath}\`\n→ \`${dirPath}\``);
660
+ return true;
661
+ }
662
+ async previewCurrentSessionTurn(chatId, agent, sessionId) {
663
+ try {
664
+ const preview = await getSessionTurnPreviewData(this, agent, sessionId, 50);
665
+ if (!preview)
666
+ return;
667
+ const previewMarkdown = renderSessionTurnMarkdown(preview.userText, preview.assistantText);
668
+ if (!previewMarkdown)
669
+ return;
670
+ const sent = await this.channel.send(chatId, previewMarkdown);
671
+ if (sessionId) {
672
+ const runtime = this.getSessionRuntimeByKey(this.sessionKey(agent, sessionId));
673
+ if (runtime && sent)
674
+ this.registerSessionMessage(chatId, sent, runtime);
675
+ }
676
+ }
677
+ catch {
678
+ // non-critical
679
+ }
680
+ }
681
+ // ---- lifecycle ------------------------------------------------------------
682
+ async run() {
683
+ const tmpDir = path.join(os.tmpdir(), 'pikiclaw');
684
+ fs.mkdirSync(tmpDir, { recursive: true });
685
+ this.channel = new FeishuChannel({
686
+ appId: this.appId,
687
+ appSecret: this.appSecret,
688
+ domain: this.domain,
689
+ workdir: tmpDir,
690
+ allowedChatIds: this.allowedChatIds.size
691
+ ? this.allowedChatIds
692
+ : undefined,
693
+ });
694
+ this.processRuntimeCleanup?.();
695
+ this.processRuntimeCleanup = registerProcessRuntime({
696
+ label: 'feishu',
697
+ getActiveTaskCount: () => this.activeTasks.size,
698
+ prepareForRestart: () => this.cleanupRuntimeForExit(),
699
+ buildRestartEnv: () => this.buildRestartEnv(),
700
+ });
701
+ this.installSignalHandlers();
702
+ try {
703
+ const bot = await this.channel.connect();
704
+ this.connected = true;
705
+ this.log(`bot: ${bot.displayName} (id=${bot.id})`);
706
+ for (const ag of this.fetchAgents().agents) {
707
+ this.log(`agent ${ag.agent}: ${ag.path || 'NOT FOUND'}`);
708
+ }
709
+ this.log(`config: agent=${this.defaultAgent} workdir=${this.workdir} timeout=${this.runTimeout}s`);
710
+ this.channel.onCommand((cmd, args, ctx) => this.handleCommand(cmd, args, ctx));
711
+ this.channel.onMessage((msg, ctx) => this.handleMessage(msg, ctx));
712
+ this.channel.onCallback((data, ctx) => this.handleCallback(data, ctx));
713
+ this.channel.onError(err => this.log(`error: ${err}`));
714
+ this.startKeepAlive();
715
+ void this.setupMenu().catch(err => this.log(`menu setup failed: ${err}`));
716
+ void this.sendStartupNotice().catch(err => this.log(`startup notice failed: ${err}`));
717
+ this.log('✓ Feishu connected, WebSocket listening — ready to receive messages');
718
+ await this.channel.listen();
719
+ this.stopKeepAlive();
720
+ this.log('stopped');
721
+ }
722
+ finally {
723
+ this.stopKeepAlive();
724
+ if (this.shutdownForceExitTimer)
725
+ clearTimeout(this.shutdownForceExitTimer);
726
+ this.removeSignalHandlers();
727
+ this.processRuntimeCleanup?.();
728
+ this.processRuntimeCleanup = null;
729
+ if (this.shutdownInFlight)
730
+ process.exit(this.shutdownExitCode ?? 1);
731
+ }
732
+ }
733
+ async sendStartupNotice() {
734
+ const targets = new Set(this.allowedChatIds);
735
+ for (const cid of this.channel.knownChats)
736
+ targets.add(cid);
737
+ if (!targets.size) {
738
+ this.log('no known chats for startup notice');
739
+ return;
740
+ }
741
+ const text = `**${VERSION}** pikiclaw is online.\nSend /start to get started.`;
742
+ for (const cid of targets) {
743
+ try {
744
+ await this.channel.send(cid, text);
745
+ this.log(`startup notice sent to chat=${cid}`);
746
+ }
747
+ catch (e) {
748
+ this.log(`startup notice failed for chat=${cid}: ${e}`);
749
+ }
750
+ }
751
+ }
752
+ }