Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,8 @@ function ecosystemConfig(): string {
// ad-hoc (e.g. `BOTMUX_MEMORY_DIAG_INTERVAL_MS=5000`) when chasing an
// RSS regression — turned off in master so logs stay quiet.
BOTMUX_MEMORY_DIAG_INTERVAL_MS: process.env.BOTMUX_MEMORY_DIAG_INTERVAL_MS ?? '0',
...(process.env.WEB_TERMINAL_PASSWORD ? { WEB_TERMINAL_PASSWORD: process.env.WEB_TERMINAL_PASSWORD } : {}),
...(process.env.BOTMUX_CARD_EXPORT_ENABLED ? { BOTMUX_CARD_EXPORT_ENABLED: process.env.BOTMUX_CARD_EXPORT_ENABLED } : {}),
},
}));

Expand Down Expand Up @@ -2811,6 +2813,7 @@ function argValues(args: string[], ...flags: string[]): string[] {
// keeps using `buildCardBodyElements` from there.
import { buildMentionedPendingResponseCard } from './im/lark/card-builder.js';
import { buildCardBodyElements, brandFooterSegment } from './im/lark/md-card.js';
import { storeReplyContent } from './im/lark/reply-content-cache.js';
import { COMPLETED_REACTION_EMOJI_TYPE, claimPendingResponseCard, isPendingResponseCardOpen, markPendingResponseCardPatchedIfCurrent, mergePendingResponseState, shouldMarkPendingAsMentionedSend, shouldPatchPendingOnExplicitSend } from './core/pending-response.js';
import { resolveBrandLabel } from './bot-registry.js';
import { config } from './config.js';
Expand Down Expand Up @@ -3398,6 +3401,33 @@ async function cmdSend(rest: string[]): Promise<void> {
}
}

// Reply action buttons: export to Feishu doc + send raw markdown
// Controlled by BOTMUX_CARD_EXPORT_ENABLED (default off) — requires lark-cli.
if (config.send.cardExportEnabled) {
const contentKey = storeReplyContent(text);
const actionRootId = (isChatScope ? s.chatId : s.rootMessageId) ?? s.chatId;
elements.push({ tag: 'hr' });
const rpcButton = (label: string, action: string) => ({
tag: 'button',
text: { tag: 'plain_text', content: label },
type: 'default',
width: 'fill',
behaviors: [{
type: 'callback',
value: { action, content_key: contentKey, root_id: actionRootId, session_id: sid },
}],
});
elements.push({
tag: 'column_set',
flex_mode: 'none',
horizontal_spacing: 'default',
columns: [
{ tag: 'column', width: 'weighted', weight: 1, vertical_align: 'center', elements: [rpcButton('📄 导出飞书文档', 'export_to_doc')] },
{ tag: 'column', width: 'weighted', weight: 1, vertical_align: 'center', elements: [rpcButton('📝 原始 Markdown', 'send_raw_md')] },
],
});
}

const cardJson = JSON.stringify({
schema: '2.0',
config: { update_multi: true },
Expand Down
5 changes: 5 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ export const config = {
* BOTMUX_REQUIRE_MENTION_DECISION=false to disable (kill-switch if the
* gate misfires in production). */
requireMentionDecision: (process.env.BOTMUX_REQUIRE_MENTION_DECISION ?? 'true').toLowerCase() !== 'false',
/** Show "Export to Doc" / "Raw Markdown" buttons on send cards.
* Requires lark-cli to be installed on the host. Off by default so
* users without lark-cli don't see buttons that would always fail.
* Set BOTMUX_CARD_EXPORT_ENABLED=true to enable. */
cardExportEnabled: (process.env.BOTMUX_CARD_EXPORT_ENABLED ?? '').toLowerCase() === 'true',
},
daemon: {
cliId: (process.env.CLI_ID ?? 'claude-code') as import('./adapters/cli/types.js').CliId,
Expand Down
1 change: 1 addition & 0 deletions src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,7 @@ export const messages: Record<string, string> = {
'card.action.takeover_retired': '⚠️ The old "Take Over" button is retired. In bridge mode, botmux bridges the original CLI so replies still come back to Lark — no takeover needed. Full takeover (`/adopt --takeover`) is on the roadmap.',
'card.action.terminal_not_ready': '⚠️ Terminal is not ready yet, please try again shortly.',
'card.action.no_output': '(no output yet)',
'card.action.content_expired': 'Content expired, unable to retrieve original text.',
'card.action.tui_select_title': 'Select options',
'card.action.tui_custom_input': 'Custom input',
'card.action.tui_done': 'Done',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,7 @@ export const messages: Record<string, string> = {
'card.action.takeover_retired': '⚠️ 旧版"接管"按钮已停用。bridge 模式下原 CLI 由 botmux 桥接,无需接管即可在飞书中收到回答。如需 /resume 完整接管能力,请等待 /adopt --takeover 命令上线。',
'card.action.terminal_not_ready': '⚠️ 终端尚未就绪,请稍后再试。',
'card.action.no_output': '(当前无输出内容)',
'card.action.content_expired': '内容已过期,无法获取原文。',
'card.action.tui_select_title': 'Select options',
'card.action.tui_custom_input': 'Custom input',
'card.action.tui_done': 'Done',
Expand Down
47 changes: 46 additions & 1 deletion src/im/lark/card-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -572,7 +572,7 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe
return { toast: { type: 'success', content: t('card.relay.toast_success', undefined, loc) } };
}

const isSensitive = value?.action && ['restart', 'close', 'resume', 'skip_repo', 'retry_last_task', 'get_write_link', 'toggle_stream', 'toggle_display', 'export_text', 'term_action', 'refresh_screenshot', 'takeover', 'disconnect', 'tui_keys', 'tui_text_input', 'wf_approve', 'wf_reject', 'wf_cancel'].includes(value.action);
const isSensitive = value?.action && ['restart', 'close', 'resume', 'skip_repo', 'retry_last_task', 'get_write_link', 'toggle_stream', 'toggle_display', 'export_text', 'term_action', 'refresh_screenshot', 'takeover', 'disconnect', 'tui_keys', 'tui_text_input', 'wf_approve', 'wf_reject', 'wf_cancel', 'export_to_doc', 'send_raw_md'].includes(value.action);
if (isSensitive) {
const rootId = value?.root_id;
// activeSessions is keyed by sessionKey(anchor, larkAppId) — `${anchor}::${larkAppId}`
Expand Down Expand Up @@ -1154,6 +1154,51 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe
return;
}

// Export AI reply as a Feishu Docx document. Runs async in background so
// the Feishu callback doesn't time out (lark-cli takes ~3-6s).
if (actionType === 'export_to_doc' && value?.content_key) {
const locDs = localeForBot(ds?.larkAppId ?? larkAppId);
const appId = larkAppId;
if (!appId) {
return { toast: { type: 'error', content: '无法确定 Lark 应用' } };
}
const { getReplyContent, deriveTitleFromMarkdown } = await import('../../im/lark/reply-content-cache.js');
const md = getReplyContent(value.content_key);
if (!md) {
return { toast: { type: 'error', content: t('card.action.content_expired', undefined, locDs) } };
}
const title = deriveTitleFromMarkdown(md);
// Fire-and-forget: return toast immediately so Feishu doesn't time out,
// then do the import asynchronously in the background.
const replyRootId = rootId;
Promise.resolve().then(async () => {
const { importMarkdownAsDoc } = await import('../../services/doc-import.js');
const result = await importMarkdownAsDoc(appId, md, title);
if (result.ok && result.docUrl) {
await sessionReply(replyRootId, `📄 已导出飞书文档:[${result.docTitle ?? title}](${result.docUrl})`);
} else {
await sessionReply(replyRootId, `❌ 导出失败:${result.error ?? '未知错误'}`);
}
logger.info(`[${tag(ds ?? { session: { sessionId: value.session_id } } as any)}] export_to_doc: ${result.ok ? 'ok' : 'failed'} (${md.length} chars)`);
}).catch((err: any) => {
logger.error(`[export_to_doc] background import failed: ${err?.message ?? err}`);
});
return { toast: { type: 'info', content: '正在导出飞书文档…' } };
}

// Send the raw markdown content as a plain text reply.
if (actionType === 'send_raw_md' && value?.content_key) {
const locDs = localeForBot(ds?.larkAppId ?? larkAppId);
const { getReplyContent } = await import('../../im/lark/reply-content-cache.js');
const md = getReplyContent(value.content_key);
if (!md) {
return { toast: { type: 'error', content: t('card.action.content_expired', undefined, locDs) } };
}
await sessionReply(rootId, md);
logger.info(`[${tag(ds ?? { session: { sessionId: value.session_id } } as any)}] send_raw_md: ${md.length} chars`);
return;
}

// Manual screenshot refresh — force immediate capture bypassing 10s interval + hash dedup.
if (actionType === 'refresh_screenshot' && ds) {
if (ds.worker) {
Expand Down
85 changes: 85 additions & 0 deletions src/im/lark/reply-content-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* File-based cache for AI reply content, keyed by a short unique string.
* Card action buttons carry this key instead of the full content (which
* can be 10k+ chars and would exceed Feishu's value size limit).
*
* Uses the filesystem instead of an in-memory Map because `botmux send`
* runs as a short-lived CLI process while the card callback is handled by
* the long-lived daemon — they are different processes with separate
* memory spaces.
*
* TTL + max-count bounded so a leak doesn't grow unbounded across a
* long-running daemon.
*/

import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';

const TTL_MS = 60 * 60 * 1000; // 1 hour
const MAX_FILES = 2000;

// Use os.tmpdir() so botmux send (short-lived CLI) and the daemon
// (long-lived pm2 process) share the same cache — an in-memory Map
// would be per-process and invisible to the other side.
function cacheDir(): string {
const dir = join(tmpdir(), 'botmux-reply-cache');
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
return dir;
}

/** Store reply content and return a lookup key for card buttons. */
export function storeReplyContent(content: string): string {
const dir = cacheDir();

// Lazy eviction: if over max, delete expired files
let files: string[] = [];
try { files = readdirSync(dir); } catch { /* ignore */ }
if (files.length >= MAX_FILES) {
const now = Date.now();
for (const f of files) {
if (f === '.' || f === '..') continue;
try {
const st = statSync(join(dir, f));
if (now - st.mtimeMs > TTL_MS) unlinkSync(join(dir, f));
} catch { /* ignore */ }
}
}

const key = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
writeFileSync(join(dir, `${key}.txt`), content, 'utf-8');
return key;
}

/** Retrieve cached reply content. Returns undefined if expired or missing. */
export function getReplyContent(key: string): string | undefined {
// Basic sanitisation: disallow path traversal
if (key.includes('/') || key.includes('\\') || key.includes('..')) return undefined;
const filePath = join(cacheDir(), `${key}.txt`);
try {
const st = statSync(filePath);
if (Date.now() - st.mtimeMs > TTL_MS) {
try { unlinkSync(filePath); } catch { /* ignore */ }
return undefined;
}
return readFileSync(filePath, 'utf-8');
} catch {
return undefined;
}
}

/** Derive a human-readable doc title from markdown: first non-empty,
* non-code-fence line, truncated to 50 chars. */
export function deriveTitleFromMarkdown(md: string): string {
const lines = md.split('\n');
let inFence = false;
for (const line of lines) {
const trimmed = line.trim();
if (/^```/.test(trimmed)) { inFence = !inFence; continue; }
if (inFence) continue;
// Skip headings markers, blockquotes, list markers for the title
const cleaned = trimmed.replace(/^[#>*-]+ /, '').trim();
if (cleaned) return cleaned.length > 50 ? cleaned.slice(0, 47) + '...' : cleaned;
}
return 'AI 回复';
}
104 changes: 104 additions & 0 deletions src/services/doc-import.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* Import a markdown string as a Feishu Docx document via lark-cli.
*
* Uses `lark-cli drive +import` which handles the multipart upload +
* import task + polling in a single CLI invocation. Much simpler than
* the SDK/curl approach.
*/

import { writeFileSync, unlinkSync, existsSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { randomBytes } from 'node:crypto';
import { exec } from 'node:child_process';
import { logger } from '../utils/logger.js';
import { config } from '../config.js';

const MAX_FILE_BYTES = 20 * 1024 * 1024; // 20 MB limit for .md import

interface ImportResult {
ok: boolean;
docUrl?: string;
docTitle?: string;
error?: string;
}

/** Check whether `lark-cli` is available in PATH. Cached so we only probe once. */
let larkCliAvailable: boolean | null = null;
function checkLarkCli(): boolean {
if (larkCliAvailable !== null) return larkCliAvailable;
try {
const { execSync } = require('node:child_process');
execSync('lark-cli --version', { stdio: 'ignore', timeout: 5000 });
larkCliAvailable = true;
} catch {
larkCliAvailable = false;
}
return larkCliAvailable;
}

/** Run lark-cli asynchronously so the event loop stays unblocked. */
function execLarkCli(cmd: string): Promise<string> {
return new Promise((resolve, reject) => {
exec(cmd, { maxBuffer: 10 * 1024 * 1024, timeout: 120_000 }, (err, stdout, stderr) => {
if (err) {
reject(new Error(stderr?.trim() || err.message));
} else {
resolve(stdout);
}
});
});
}

/**
* Import markdown content as a Feishu Docx document.
* Returns the document URL on success.
*/
export async function importMarkdownAsDoc(
_larkAppId: string,
markdown: string,
title: string,
): Promise<ImportResult> {
if (!checkLarkCli()) {
return { ok: false, error: 'lark-cli 未安装,无法导入飞书文档。\n安装:npx @larksuite/cli@latest install\n详见:https://github.com/larksuite/cli' };
}

const buf = Buffer.from(markdown, 'utf-8');
if (buf.length > MAX_FILE_BYTES) {
return { ok: false, error: '内容超过 20 MB,无法导入为飞书文档' };
}

const tmpName = `botmux-import-${randomBytes(6).toString('hex')}.md`;
const tmpPath = join(config.session.dataDir, 'tmp', tmpName);
const tmpDir = join(config.session.dataDir, 'tmp');
if (!existsSync(tmpDir)) mkdirSync(tmpDir, { recursive: true });

try {
writeFileSync(tmpPath, markdown, 'utf-8');

const result = await execLarkCli(
`cd '${tmpDir}' && lark-cli drive +import --file './${tmpName}' --type docx --name '${title.replace(/'/g, "'\\''")}' --json`,
);

const json: any = JSON.parse(result.trim());
if (!json?.ok) {
const msg = json?.error?.message ?? 'unknown error';
logger.error(`[doc-import] lark-cli import failed: ${msg}`);
return { ok: false, error: msg };
}

const docUrl: string | undefined = json?.data?.url;
const docTitle: string | undefined = json?.data?.job_error_msg === 'success' ? title : undefined;
if (!docUrl) {
logger.error(`[doc-import] lark-cli import returned no url: ${JSON.stringify(json?.data)}`);
return { ok: false, error: '导入成功但未返回文档链接' };
}

logger.info(`[doc-import] Imported markdown → ${docUrl} (title: ${docTitle ?? title})`);
return { ok: true, docUrl, docTitle: docTitle ?? title };
} catch (err: any) {
logger.error(`[doc-import] Import failed: ${err?.message ?? err}`);
return { ok: false, error: err?.message ?? '导入失败' };
} finally {
try { unlinkSync(tmpPath); } catch { /* ignore */ }
}
}