114 lines
5.6 KiB
JavaScript
114 lines
5.6 KiB
JavaScript
#!/usr/bin/env node
|
|
import { execFileSync } from 'node:child_process';
|
|
import { mkdir, realpath, writeFile } from 'node:fs/promises';
|
|
import { homedir } from 'node:os';
|
|
import { dirname, isAbsolute, resolve } from 'node:path';
|
|
import { normalize, readJson, readStdin, tokens } from './intake-lib.mjs';
|
|
|
|
const baseToken = 'Ed21b9VwNaZfiesxWgDc67wXnoc';
|
|
const tableId = 'tblelMMlOif1YHPD';
|
|
const receiptPath = resolve(homedir(), '.easyai-intake', 'receipts.json');
|
|
|
|
try {
|
|
const value = normalize(await readStdin());
|
|
const identity = tokens(value);
|
|
const confirmation = argument('--confirm');
|
|
if (!confirmation || confirmation !== identity.confirmation_token)
|
|
throw new Error('确认令牌缺失或已过期,请重新预览并确认');
|
|
|
|
const receipts = await readJson(receiptPath, {});
|
|
if (receipts[identity.submission_key]) {
|
|
console.log(JSON.stringify({ ok: true, duplicate: true, ...receipts[identity.submission_key] }, null, 2));
|
|
process.exit(0);
|
|
}
|
|
|
|
const auth = lark(['auth', 'status', '--json', '--verify']);
|
|
const user = auth.identities?.user;
|
|
if (!user?.verified || !user.openId) throw new Error('当前电脑未完成飞书用户授权');
|
|
const fieldList = lark(['base', '+field-list', '--base-token', baseToken, '--table-id', tableId, '--as', 'user']);
|
|
const fields = new Map((fieldList.fields ?? []).map((field) => [field.name, field]));
|
|
for (const name of ['类型', '标题', '需求背景与目标', '验收标准', '优先级', '产品负责人', '提交幂等键', '提交来源'])
|
|
if (!fields.has(name)) throw new Error(`Base 缺少必需字段: ${name}`);
|
|
|
|
const payload = {
|
|
'类型': value.type,
|
|
'标题': value.title,
|
|
'需求背景与目标': value.background,
|
|
'验收标准': value.acceptance_criteria.map((item, index) => `${index + 1}. ${item}`).join('\n'),
|
|
'优先级': value.priority,
|
|
'产品负责人': [{ id: user.openId }],
|
|
'提交幂等键': identity.submission_key,
|
|
'提交来源': 'Codex Skill',
|
|
...(value.environment ? { '发生环境': value.environment } : {}),
|
|
...(value.reproduction_steps ? { '复现步骤': value.reproduction_steps.map((item, index) => `${index + 1}. ${item}`).join('\n') } : {}),
|
|
...(value.actual_result ? { '实际结果': value.actual_result } : {}),
|
|
...(value.expected_result ? { '期望结果': value.expected_result } : {}),
|
|
...(value.impact_scope.length ? { '影响范围': value.impact_scope } : {}),
|
|
...(value.desired_at ? { '期望完成时间': value.desired_at } : {}),
|
|
...(value.references ? { '参考资料': value.references } : {}),
|
|
};
|
|
const created = lark([
|
|
'base', '+record-upsert', '--base-token', baseToken, '--table-id', tableId,
|
|
'--json', JSON.stringify(payload), '--as', 'user',
|
|
]);
|
|
const recordId = findId(created, 'rec');
|
|
if (!recordId) throw new Error('飞书未返回 record_id');
|
|
|
|
const recordUrl = findUrl(created) ?? `https://feishu.cn/base/${baseToken}?table=${tableId}&record=${recordId}`;
|
|
const receipt = {
|
|
record_id: recordId,
|
|
record_url: recordUrl,
|
|
submission_key: identity.submission_key,
|
|
status: '已登记,等待系统分配任务编号',
|
|
};
|
|
receipts[identity.submission_key] = receipt;
|
|
await mkdir(dirname(receiptPath), { recursive: true, mode: 0o700 });
|
|
await writeFile(receiptPath, `${JSON.stringify(receipts, null, 2)}\n`, { mode: 0o600 });
|
|
|
|
const attachmentField = fields.get('附件');
|
|
for (const attachment of value.attachments) {
|
|
if (!attachmentField?.id) throw new Error('Base 缺少附件字段');
|
|
await assertSafePath(attachment);
|
|
lark([
|
|
'base', '+record-upload-attachment', '--base-token', baseToken, '--table-id', tableId,
|
|
'--record-id', recordId, '--field-id', attachmentField.id, '--file', attachment, '--as', 'user',
|
|
]);
|
|
}
|
|
console.log(JSON.stringify({ ok: true, duplicate: false, ...receipt }, null, 2));
|
|
} catch (error) {
|
|
console.error(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : '未知错误' }));
|
|
process.exitCode = 1;
|
|
}
|
|
|
|
function lark(args) {
|
|
const output = execFileSync('lark-cli', args, {
|
|
encoding: 'utf8',
|
|
env: { ...process.env, LARKSUITE_CLI_NO_UPDATE_NOTIFIER: '1', LARKSUITE_CLI_NO_SKILLS_NOTIFIER: '1' },
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
const envelope = JSON.parse(output);
|
|
if (envelope.ok === false) throw new Error([envelope.error?.message, envelope.error?.hint].filter(Boolean).join(': '));
|
|
return envelope.data ?? envelope;
|
|
}
|
|
function argument(name) {
|
|
const index = process.argv.indexOf(name);
|
|
return index >= 0 ? process.argv[index + 1] : undefined;
|
|
}
|
|
async function assertSafePath(path) {
|
|
if (typeof path !== 'string' || !path || isAbsolute(path) || path.split('/').includes('..'))
|
|
throw new Error(`不安全的附件路径: ${path}`);
|
|
const root = await realpath(process.cwd());
|
|
const target = await realpath(resolve(root, path));
|
|
if (target !== root && !target.startsWith(`${root}/`)) throw new Error(`附件超出当前目录: ${path}`);
|
|
}
|
|
function findId(value, prefix) {
|
|
if (typeof value === 'string' && value.startsWith(prefix)) return value;
|
|
if (Array.isArray(value)) for (const item of value) { const found = findId(item, prefix); if (found) return found; }
|
|
if (value && typeof value === 'object') for (const item of Object.values(value)) { const found = findId(item, prefix); if (found) return found; }
|
|
}
|
|
function findUrl(value) {
|
|
if (typeof value === 'string' && /^https?:\/\//.test(value)) return value;
|
|
if (Array.isArray(value)) for (const item of value) { const found = findUrl(item); if (found) return found; }
|
|
if (value && typeof value === 'object') for (const item of Object.values(value)) { const found = findUrl(item); if (found) return found; }
|
|
}
|