453 lines
13 KiB
JavaScript
453 lines
13 KiB
JavaScript
#!/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 };
|