#!/usr/bin/env node import { execFileSync } from "node:child_process"; import { mkdir, rmdir } from "node:fs/promises"; import { homedir } from "node:os"; import { resolve } from "node:path"; import { attachmentManifest, canonical, contentDigest, parseConfirmationToken, readJson, verifySecret, writeJsonAtomic, } from "./intake-lib.mjs"; const baseToken = "Ed21b9VwNaZfiesxWgDc67wXnoc"; const taskTableId = "tblelMMlOif1YHPD"; const formShareToken = "shrcnT6yRL9olcpFkppFkUcKe5e"; const formUrl = "https://karni9c557h.feishu.cn/share/base/shrcnT6yRL9olcpFkppFkUcKe5e"; const intakeHome = resolve( process.env.EASYAI_INTAKE_HOME ?? homedir(), ".easyai-intake", ); let lockPath; let receiptPath; let receipt; let submissionStarted = false; class Completed extends Error { constructor(output) { super("completed"); this.output = output; } } try { const confirmation = argument("--confirm"); const { draftId, secret } = parseConfirmationToken(confirmation); const dryRun = process.argv.includes("--dry-run"); const retryUnknown = process.argv.includes("--retry-unknown"); const draftPath = resolve(intakeHome, "drafts", `${draftId}.json`); const draft = await readJson(draftPath); if (!draft || draft.schema_version !== 2 || draft.status_code !== "prepared") throw new Error("确认草稿不存在或已过期,请重新预览"); if (!verifySecret(secret, draft.confirmation_token_hash)) throw new Error("确认令牌缺失或已过期,请重新预览并确认"); const auth = lark(["auth", "status", "--json", "--verify"]); const user = auth.identities?.user; if (!user?.verified || !user.openId) throw new Error("当前电脑未完成飞书用户授权"); if (user.openId !== draft.owner_open_id) throw new Error("当前飞书身份与预览负责人不一致,请重新预览"); const attachments = await attachmentManifest(draft.value.attachments); const currentDigest = contentDigest(draft.value, user.openId, attachments); if (currentDigest !== draft.content_digest) throw new Error("内容或附件已改变,旧确认令牌失效,请重新预览"); lockPath = resolve(intakeHome, "locks", draftId); if (!dryRun) await acquireLock(lockPath); receiptPath = resolve(intakeHome, "receipts", `${draft.submission_key}.json`); receipt = await readJson(receiptPath, { schema_version: 2, status_code: "prepared", draft_id: draftId, submission_key: draft.submission_key, owner_open_id: user.openId, prepared_at: draft.prepared_at, }); if (receipt.status_code === "submitted") { throw new Completed({ ok: true, duplicate: true, ...receipt }); } const existing = findExistingSubmission(draft.submission_key); if (existing) { finalizeOwnerIntent(draft, existing); receipt = submittedReceipt(draft, existing); if (!dryRun) await writeJsonAtomic(receiptPath, receipt); throw new Completed({ ok: true, duplicate: true, ...receipt }); } if (["submitting", "unknown"].includes(receipt.status_code) && !retryUnknown) throw new Error( `提交 ${draft.submission_key} 的结果尚未确认;已查询 Base 未找到记录。请核对后使用 --retry-unknown 明确重试,不能盲目重提`, ); const form = lark([ "base", "+form-detail", "--share-token", formShareToken, "--as", "user", ]); if (form.base_token !== baseToken) throw new Error("飞书表单指向了非预期 Base"); const fields = new Map( (form.questions ?? []).map((field) => [field.title, field]), ); const value = draft.value; const payload = { 类型: value.type, 标题: value.title, 需求背景与目标: value.background, 期望结果: value.expected_result, 优先级: value.priority, 产品负责人: [{ id: user.openId }], 提交幂等键: draft.submission_key, 提交来源: "Codex Skill", ...(value.acceptance_criteria.length ? { 验收标准: value.acceptance_criteria .map((item, index) => `${index + 1}. ${item}`) .join("\n"), } : {}), ...(value.environment ? { 发生环境: value.environment } : {}), ...(value.reproduction_steps?.length ? { 复现步骤: value.reproduction_steps .map((item, index) => `${index + 1}. ${item}`) .join("\n"), } : {}), ...(value.actual_result ? { 实际结果: value.actual_result } : {}), ...(value.impact_scope.length ? { 影响范围: value.impact_scope } : {}), ...(value.constraints.length || value.non_goals.length ? { 约束与非目标: [ ...value.constraints.map((item) => `约束:${item}`), ...value.non_goals.map((item) => `非目标:${item}`), ].join("\n"), } : {}), ...(value.desired_at ? { 期望完成时间: value.desired_at } : {}), ...(value.references ? { 参考资料: value.references } : {}), }; validateLiveForm(fields, payload, value); const submission = { fields: payload, ...(value.attachments.length ? { attachments: { 附件: value.attachments } } : {}), }; if (!dryRun) { submissionStarted = true; receipt = { ...receipt, status_code: "submitting", submission_url: formUrl, content_digest: draft.content_digest, submitting_at: new Date().toISOString(), status: "正在提交;进程中断时先按幂等键核对 Base", }; await writeJsonAtomic(receiptPath, receipt); } let submitted; try { submitted = lark([ "base", "+form-submit", "--share-token", formShareToken, ...(value.attachments.length ? ["--base-token", baseToken] : []), "--json", JSON.stringify(submission), "--as", "user", ...(dryRun ? ["--dry-run"] : []), ]); if (!dryRun) { const created = recordFromResult(submitted) ?? findExistingSubmission(draft.submission_key); if (!created) throw new Error("表单返回成功但无法按提交幂等键定位新记录"); finalizeOwnerIntent(draft, created); submitted = created; } } catch (error) { if (!dryRun) { receipt = { ...receipt, status_code: "unknown", unknown_at: new Date().toISOString(), status: "提交调用未获得确定结果;重试前必须按提交幂等键查询 Base", last_error: error instanceof Error ? error.message : "未知错误", }; await writeJsonAtomic(receiptPath, receipt); } throw error; } if (dryRun) { throw new Completed({ ok: true, dry_run: true, draft_id: draftId, submission_key: draft.submission_key, submitted, }); } receipt = submittedReceipt(draft, submitted); await writeJsonAtomic(receiptPath, receipt); console.log( JSON.stringify({ ok: true, duplicate: false, ...receipt }, null, 2), ); } catch (error) { if (error instanceof Completed) { console.log(JSON.stringify(error.output, null, 2)); } else { if ( submissionStarted && receiptPath && receipt?.status_code === "submitting" ) { receipt = { ...receipt, status_code: "unknown", unknown_at: new Date().toISOString(), status: "提交进程中断;重试前必须按提交幂等键查询 Base", last_error: error instanceof Error ? error.message : "未知错误", }; await writeJsonAtomic(receiptPath, receipt).catch(() => undefined); } console.error( JSON.stringify({ ok: false, status_code: receipt?.status_code, submission_key: receipt?.submission_key, error: error instanceof Error ? error.message : "未知错误", }), ); process.exitCode = 1; } } finally { if (lockPath) await rmdir(lockPath).catch(() => undefined); } function submittedReceipt(draft, result) { const record = recordFromResult(result) ?? result; return { schema_version: 2, submission_url: formUrl, draft_id: draft.draft_id, submission_key: draft.submission_key, owner_open_id: draft.owner_open_id, content_digest: draft.content_digest, status_code: "submitted", status: "已登记,等待系统分配任务编号", submitted_at: new Date().toISOString(), ...(record?.record_id ? { record_id: record.record_id } : {}), }; } function recordFromResult(result) { return ( result?.record ?? result?.data?.[0] ?? (result?.record_id ? result : undefined) ); } function finalizeOwnerIntent(draft, record) { const recordId = record?.record_id; if (!recordId) throw new Error("已登记记录缺少 record_id,无法固化原始意图指纹"); lark([ "base", "+record-upsert", "--base-token", baseToken, "--table-id", taskTableId, "--record-id", recordId, "--json", JSON.stringify({ 原始意图指纹: draft.content_digest }), "--as", "user", ]); } function findExistingSubmission(submissionKey) { const result = lark([ "base", "+record-search", "--base-token", baseToken, "--table-id", taskTableId, "--keyword", submissionKey, "--search-field", "提交幂等键", "--field-id", "提交幂等键", "--field-id", "任务编号", "--limit", "2", "--format", "json", "--as", "user", ]); const records = Array.isArray(result?.data) ? result.data : []; return records.find((record) => { const fields = record.fields ?? record; return scalarText(fields["提交幂等键"]) === submissionKey; }); } function validateLiveForm(fields, payload, value) { const expected = { 类型: "select", 标题: "text", 需求背景与目标: "text", 期望结果: "text", 优先级: "select", 产品负责人: "user", 提交幂等键: "text", 提交来源: "select", }; for (const [name, type] of Object.entries(expected)) assertField(fields, name, type); for (const name of Object.keys(payload)) if (!fields.has(name)) throw new Error(`受控表单缺少字段: ${name}`); for (const field of fields.values()) if ( field.required && field.title !== "任务编号(系统自动生成,请勿填写)" && !hasPayloadValue(payload[field.title]) ) throw new Error(`受控表单仍要求非 V2 必填字段: ${field.title}`); assertSelectOptions(fields, "类型", [value.type]); assertSelectOptions(fields, "优先级", [value.priority]); assertSelectOptions(fields, "提交来源", ["Codex Skill"]); if (value.impact_scope.length) assertSelectOptions(fields, "影响范围", value.impact_scope); if (value.attachments.length) assertField(fields, "附件", "attachment"); } function assertField(fields, name, type) { const field = fields.get(name); if (!field) throw new Error(`受控表单缺少必需字段: ${name}`); if (field.type !== type) throw new Error( `受控表单字段 ${name} 类型错误: 期望 ${type},实际 ${field.type}`, ); } function hasPayloadValue(value) { return ( value !== undefined && value !== null && value !== "" && (!Array.isArray(value) || value.length > 0) ); } function scalarText(value) { if (typeof value === "string") return value; if (Array.isArray(value)) return value .map((part) => (typeof part === "string" ? part : (part?.text ?? ""))) .join(""); return value?.text ?? ""; } async function acquireLock(path) { try { await mkdir(resolve(path, ".."), { recursive: true, mode: 0o700 }); await mkdir(path, { mode: 0o700 }); } catch (error) { if (error?.code === "EEXIST") throw new Error("同一草稿正在提交,请等待当前提交完成"); throw error; } } function lark(args) { try { const output = execFileSync( process.env.EASYAI_LARK_CLI ?? "lark-cli", args, { encoding: "utf8", env: { ...process.env, LARKSUITE_CLI_NO_UPDATE_NOTIFIER: "1", LARKSUITE_CLI_NO_SKILLS_NOTIFIER: "1", }, stdio: ["ignore", "pipe", "pipe"], }, ); return unwrapLark(output); } catch (error) { for (const output of [error?.stdout, error?.stderr]) { const message = larkErrorMessage(output); if (message) throw new Error(message); } throw error; } } function argument(name) { const index = process.argv.indexOf(name); return index >= 0 ? process.argv[index + 1] : undefined; } function unwrapLark(output) { const envelope = JSON.parse(output); if (envelope.ok === false) throw new Error(larkErrorMessage(output) ?? "飞书 CLI 调用失败"); return envelope.data ?? envelope; } function larkErrorMessage(output) { if (typeof output !== "string" || !output.trim()) return undefined; try { const envelope = JSON.parse(output); if (envelope.ok !== false) return undefined; return ( [envelope.error?.message, envelope.error?.hint] .filter(Boolean) .join(": ") || undefined ); } catch { return undefined; } } function assertSelectOptions(fields, name, values) { const options = new Set( (fields.get(name)?.options ?? []).map((option) => option.name), ); for (const value of values) if (!options.has(value)) throw new Error(`受控表单字段 ${name} 缺少选项: ${value}`); } // Used by focused tests to prove that attachment metadata participates in a draft. export const __test = { canonical };