feat(插件): 发布意图提单 V2
This commit is contained in:
+420
-92
@@ -1,124 +1,452 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdir, realpath, rename, 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';
|
||||
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 formShareToken = 'shrcnT6yRL9olcpFkppFkUcKe5e';
|
||||
const formUrl = 'https://karni9c557h.feishu.cn/share/base/shrcnT6yRL9olcpFkppFkUcKe5e';
|
||||
const receiptPath = resolve(homedir(), '.easyai-intake', 'receipts.json');
|
||||
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 value = normalize(await readStdin());
|
||||
const identity = tokens(value);
|
||||
const confirmation = argument('--confirm');
|
||||
const dryRun = process.argv.includes('--dry-run');
|
||||
if (!confirmation || confirmation !== identity.confirmation_token)
|
||||
throw new Error('确认令牌缺失或已过期,请重新预览并确认');
|
||||
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 receipts = await readJson(receiptPath, {});
|
||||
if (!dryRun && receipts[identity.submission_key]) {
|
||||
const existing = receipts[identity.submission_key];
|
||||
if (existing.status_code === 'submitting')
|
||||
throw new Error(`提交 ${identity.submission_key} 的结果待中央调度器核对,请勿重复提交`);
|
||||
console.log(JSON.stringify({ ok: true, duplicate: true, ...existing }, 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("当前电脑未完成飞书用户授权");
|
||||
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 auth = lark(['auth', 'status', '--json', '--verify']);
|
||||
const user = auth.identities?.user;
|
||||
if (!user?.verified || !user.openId) throw new Error('当前电脑未完成飞书用户授权');
|
||||
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]));
|
||||
for (const name of ['类型', '标题', '需求背景与目标', '验收标准', '优先级', '产品负责人', '提交幂等键', '提交来源'])
|
||||
if (!fields.has(name)) throw new Error(`受控表单缺少必需字段: ${name}`);
|
||||
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.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 } : {}),
|
||||
类型: 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 } : {}),
|
||||
};
|
||||
for (const attachment of value.attachments) await assertSafePath(attachment);
|
||||
validateLiveForm(fields, payload, value);
|
||||
|
||||
const submission = {
|
||||
fields: payload,
|
||||
...(value.attachments.length ? { attachments: { '附件': value.attachments } } : {}),
|
||||
...(value.attachments.length
|
||||
? { attachments: { 附件: value.attachments } }
|
||||
: {}),
|
||||
};
|
||||
|
||||
if (!dryRun) {
|
||||
receipts[identity.submission_key] = {
|
||||
submissionStarted = true;
|
||||
receipt = {
|
||||
...receipt,
|
||||
status_code: "submitting",
|
||||
submission_url: formUrl,
|
||||
submission_key: identity.submission_key,
|
||||
status_code: 'submitting',
|
||||
status: '正在提交;若进程中断,请等待中央调度器核对,勿重复提交',
|
||||
content_digest: draft.content_digest,
|
||||
submitting_at: new Date().toISOString(),
|
||||
status: "正在提交;进程中断时先按幂等键核对 Base",
|
||||
};
|
||||
await writeReceipts(receipts);
|
||||
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;
|
||||
}
|
||||
const 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) {
|
||||
console.log(JSON.stringify({ ok: true, dry_run: true, submission_key: identity.submission_key, submitted }, null, 2));
|
||||
process.exit(0);
|
||||
throw new Completed({
|
||||
ok: true,
|
||||
dry_run: true,
|
||||
draft_id: draftId,
|
||||
submission_key: draft.submission_key,
|
||||
submitted,
|
||||
});
|
||||
}
|
||||
|
||||
const receipt = {
|
||||
submission_url: formUrl,
|
||||
submission_key: identity.submission_key,
|
||||
status_code: 'submitted',
|
||||
status: '已登记,等待系统分配任务编号',
|
||||
};
|
||||
receipts[identity.submission_key] = receipt;
|
||||
await writeReceipts(receipts);
|
||||
|
||||
console.log(JSON.stringify({ ok: true, duplicate: false, ...receipt }, null, 2));
|
||||
receipt = submittedReceipt(draft, submitted);
|
||||
await writeJsonAtomic(receiptPath, receipt);
|
||||
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;
|
||||
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) {
|
||||
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;
|
||||
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;
|
||||
}
|
||||
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 unwrapLark(output) {
|
||||
const envelope = JSON.parse(output);
|
||||
if (envelope.ok === false)
|
||||
throw new Error(larkErrorMessage(output) ?? "飞书 CLI 调用失败");
|
||||
return envelope.data ?? envelope;
|
||||
}
|
||||
async function writeReceipts(receipts) {
|
||||
const directory = dirname(receiptPath);
|
||||
const temporary = `${receiptPath}.${process.pid}.tmp`;
|
||||
await mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
await writeFile(temporary, `${JSON.stringify(receipts, null, 2)}\n`, { mode: 0o600 });
|
||||
await rename(temporary, receiptPath);
|
||||
|
||||
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 };
|
||||
|
||||
Reference in New Issue
Block a user