codexmobile-relay / server /lark-cli.js
Codex
deploy: CodexMobile Relay
90f0300
Raw
History Blame Contribute Delete
8.4 kB
import { spawn } from 'node:child_process';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { buildFeishuSkillInstruction } from './feishu-skills.js';
import { AUTH_DOMAINS } from './lark-cli-definitions.js';
import { CODEXMOBILE_LARK_AGENT_DIR, CODEXMOBILE_LARK_GUARD_DIR, CODEXMOBILE_STATE_DIR, ROOT_DIR } from './runtime-paths.js';
import {
LARK_CLI,
larkCliEnvironment,
larkCliSpawnOptions,
larkError,
prependPathEntry,
redacted,
resolveLarkCliCommand,
runLarkCli
} from './lark-cli-runner.js';
import {
envValue,
getLarkDocsStatusState,
larkCliVersion,
larkConfigStatus,
resetLarkDocsStatusCache
} from './lark-cli-status.js';
let authRun = null;
let agentConfigPreparedAt = 0;
export { larkCliEnvironment };
async function ensureAgentLarkConfigDir() {
const sourceRoot = path.join(os.homedir(), '.lark-cli');
const sourceProfile = path.join(sourceRoot, 'openclaw');
const targetRoot = CODEXMOBILE_LARK_AGENT_DIR;
const targetProfile = path.join(targetRoot, 'openclaw');
const now = Date.now();
if (now - agentConfigPreparedAt < 5000) {
return targetRoot;
}
await fs.mkdir(targetProfile, { recursive: true });
await fs.cp(sourceProfile, targetProfile, {
recursive: true,
force: true,
filter: (source) => {
const name = path.basename(source).toLowerCase();
return !['locks', 'cache', 'logs'].includes(name);
}
});
await Promise.all([
fs.mkdir(path.join(targetProfile, 'locks'), { recursive: true }),
fs.mkdir(path.join(targetProfile, 'cache'), { recursive: true }),
fs.mkdir(path.join(targetProfile, 'logs'), { recursive: true })
]);
agentConfigPreparedAt = now;
return targetRoot;
}
async function ensureLarkCliGuardDir() {
const guardDir = CODEXMOBILE_LARK_GUARD_DIR;
const guardScript = path.join(ROOT_DIR, 'scripts', 'lark-cli-guard.mjs');
const cmdPath = path.join(guardDir, 'lark-cli.cmd');
const nodePath = process.execPath;
await fs.mkdir(guardDir, { recursive: true });
await fs.writeFile(
cmdPath,
[
'@echo off',
`"${nodePath}" "${guardScript}" %*`
].join('\r\n'),
'utf8'
);
return guardDir;
}
function publicPendingAuth() {
if (!authRun) {
return null;
}
return {
verificationUrl: authRun.verificationUrl,
userCode: authRun.userCode,
expiresAt: authRun.expiresAt,
status: authRun.status,
error: authRun.error || ''
};
}
export async function getLarkDocsStatus(options = {}) {
return getLarkDocsStatusState(options, publicPendingAuth);
}
async function ensureLarkConfigured() {
const config = await larkConfigStatus();
if (config.configReady) {
return;
}
const appId = envValue('LARK_APP_ID', 'CODEXMOBILE_FEISHU_APP_ID');
const appSecret = envValue('LARK_APP_SECRET', 'CODEXMOBILE_FEISHU_APP_SECRET');
if (!appId || !appSecret) {
throw larkError('缺少飞书 App ID 或 App Secret,请先配置 CODEXMOBILE_FEISHU_APP_ID / CODEXMOBILE_FEISHU_APP_SECRET。', {
statusCode: 400
});
}
const init = await runLarkCli(
['config', 'init', '--app-id', appId, '--app-secret-stdin', '--brand', 'feishu'],
{ input: `${appSecret}\n`, timeoutMs: 30000 }
);
if (!init.ok) {
throw larkError(init.error || 'lark-cli 配置失败', { statusCode: 502 });
}
}
async function setDefaultAsUser() {
const result = await runLarkCli(['config', 'default-as', 'user'], { timeoutMs: 10000 });
if (!result.ok) {
console.warn('[lark-cli] failed to set default identity:', result.error || result.stderr || result.stdout);
}
}
function extractUserCode(verificationUrl) {
try {
return new URL(verificationUrl).searchParams.get('user_code') || '';
} catch {
return '';
}
}
async function startDevicePoll(deviceCode) {
let child = null;
try {
const command = await resolveLarkCliCommand();
const spawnOptions = larkCliSpawnOptions(command, ['auth', 'login', '--device-code', deviceCode]);
child = spawn(spawnOptions.command, spawnOptions.args, {
env: larkCliEnvironment(),
windowsHide: true,
windowsVerbatimArguments: spawnOptions.windowsVerbatimArguments
});
} catch (error) {
authRun.status = 'failed';
authRun.error = redacted(error.message);
return;
}
authRun.process = child;
authRun.status = 'polling';
let stdout = '';
let stderr = '';
const finish = async (status, error = '') => {
if (!authRun) {
return;
}
authRun.status = status;
authRun.error = error;
authRun.process = null;
resetLarkDocsStatusCache();
if (status === 'connected') {
await setDefaultAsUser();
}
};
child.stdout?.on('data', (chunk) => {
stdout += chunk.toString('utf8');
});
child.stderr?.on('data', (chunk) => {
stderr += chunk.toString('utf8');
});
child.on('error', (error) => {
finish('failed', redacted(error.message));
});
child.on('close', (code) => {
if (code === 0) {
finish('connected');
return;
}
finish('failed', redacted(stderr || stdout || `lark-cli auth login exited with code ${code}`));
});
}
export async function startLarkCliAuth() {
const cli = await larkCliVersion();
if (!cli.installed) {
throw larkError('未安装 lark-cli,请先安装 @larksuite/cli。', { statusCode: 400 });
}
await ensureLarkConfigured();
await setDefaultAsUser();
if (authRun?.status === 'polling' && Date.now() < authRun.expiresAt) {
return {
verificationUrl: authRun.verificationUrl,
userCode: authRun.userCode,
expiresAt: authRun.expiresAt,
status: authRun.status
};
}
const args = ['auth', 'login', '--recommend', '--no-wait', '--json'];
for (const domain of AUTH_DOMAINS) {
args.push('--domain', domain);
}
const result = await runLarkCli(args, { timeoutMs: 30000 });
if (!result.ok || !result.json?.device_code || !result.json?.verification_url) {
throw larkError(result.error || '获取飞书授权地址失败', { statusCode: 502 });
}
authRun = {
deviceCode: result.json.device_code,
verificationUrl: result.json.verification_url,
userCode: extractUserCode(result.json.verification_url),
expiresAt: Date.now() + Math.max(0, Number(result.json.expires_in || 600)) * 1000,
status: 'pending',
error: '',
process: null
};
await startDevicePoll(authRun.deviceCode);
resetLarkDocsStatusCache();
return {
verificationUrl: authRun.verificationUrl,
userCode: authRun.userCode,
expiresAt: authRun.expiresAt,
status: authRun.status
};
}
export async function logoutLarkCli() {
if (authRun?.process) {
authRun.process.kill();
}
authRun = null;
const result = await runLarkCli(['auth', 'logout'], { timeoutMs: 15000 });
resetLarkDocsStatusCache();
if (!result.ok && !/no logged-in users/i.test(result.stdout || result.stderr || result.error || '')) {
throw larkError(result.error || '断开飞书授权失败', { statusCode: 502 });
}
return true;
}
export async function buildCodexLarkCliContext(message = '') {
const status = await getLarkDocsStatus({ authenticated: true, force: false }).catch(() => null);
const enabled = Boolean(status?.codexEnabled);
const requestedInstruction = await buildFeishuSkillInstruction(message);
const instruction = enabled
? requestedInstruction
: requestedInstruction
? [
'CodexMobile Feishu/Lark was requested, but the integration is not currently authorized.',
'Do not run lark-cli commands. Reply in concise Chinese that Feishu authorization has expired or is unavailable, and ask the user to open the top-right Docs panel and reconnect Feishu.'
].join('\n')
: '';
const env = larkCliEnvironment();
if (enabled) {
const configRoot = await ensureAgentLarkConfigDir();
const realCli = await resolveLarkCliCommand();
env.LARKSUITE_CLI_CONFIG_DIR = configRoot;
env.LARKSUITE_CLI_LOG_DIR = path.join(configRoot, 'openclaw', 'logs');
env.LARKSUITE_CLI_NO_UPDATE_NOTIFIER = '1';
if (realCli && realCli !== LARK_CLI) {
const guardDir = await ensureLarkCliGuardDir();
env.CODEXMOBILE_REAL_LARK_CLI = realCli;
env.CODEXMOBILE_LARK_GUARD_STATE_DIR = CODEXMOBILE_STATE_DIR;
prependPathEntry(env, guardDir);
}
}
return {
enabled,
env,
instruction
};
}