import { isoDateString, type ExportFile } from "@pipeline/shared"; import { getTimezone, createLogger, describeError } from "@pipeline/shared/node"; import { uploadToOss, type OssConfig } from "./oss.js"; import { sendFeishuMessage, type FeishuConfig } from "./feishu.js"; const log = createLogger("publish"); export type { OssConfig, FeishuConfig }; export interface PublishConfig { oss?: OssConfig; feishu?: FeishuConfig; } export interface PublishContext { jobId: string; template: string; platforms: string[]; } export interface UploadedFile { platform: string; filePath: string; ossUrl: string; } export interface PublishOutcome { /** true when there is no OSS configured, or every file uploaded successfully. */ ok: boolean; uploaded: UploadedFile[]; /** Present when an upload failed (the render itself still succeeded). */ error?: string; } function parseBool(value: string | undefined, fallback: boolean): boolean { if (value === undefined || value === "") return fallback; return value === "1" || value.toLowerCase() === "true"; } /** * Build a PublishConfig from the raw YAML config merged with environment * variables. Env vars take precedence and are the recommended place for secrets. * Returns undefined when neither OSS nor Feishu is usable. */ export function resolvePublishConfig( raw?: Record | null ): PublishConfig | undefined { const ossRaw = raw?.oss ?? {}; const accessKeyId = process.env.OSS_ACCESS_KEY_ID || ossRaw.accessKeyId || ""; const accessKeySecret = process.env.OSS_ACCESS_KEY_SECRET || ossRaw.accessKeySecret || ""; const region = process.env.OSS_REGION || ossRaw.region || ""; const bucket = process.env.OSS_BUCKET || ossRaw.bucket || ""; const oss: OssConfig = { region, bucket, accessKeyId, accessKeySecret, endpoint: process.env.OSS_ENDPOINT || ossRaw.endpoint, path: process.env.OSS_PATH || ossRaw.path, publicDomain: process.env.OSS_PUBLIC_DOMAIN || ossRaw.publicDomain, secure: parseBool(process.env.OSS_SECURE, ossRaw.secure ?? true), }; const ossEnabled = Boolean(oss.region && oss.bucket && oss.accessKeyId && oss.accessKeySecret); const feishuRaw = raw?.feishu ?? {}; const webhook = process.env.FEISHU_WEBHOOK_URL || feishuRaw.webhook || ""; const feishu: FeishuConfig = { webhook, secret: process.env.FEISHU_WEBHOOK_SECRET || feishuRaw.secret, }; const feishuEnabled = Boolean(feishu.webhook); if (!ossEnabled && !feishuEnabled) return undefined; return { oss: ossEnabled ? oss : undefined, feishu: feishuEnabled ? feishu : undefined, }; } async function safeNotify(config: FeishuConfig, text: string): Promise { try { await sendFeishuMessage(config, text); return true; } catch (err) { // Notification is best-effort (must not mask the real outcome), but log it // so a misconfigured webhook / signing failure / network error is visible // (including the fetch cause: DNS / connection / TLS) instead of silent. log.error(`feishu send failed: ${describeError(err)}`); return false; } } function buildSuccessText( ctx: PublishContext, uploaded: UploadedFile[], files: ExportFile[] ): string { const lines = uploaded.length ? uploaded.map((u) => `- [${u.platform}] ${u.ossUrl}`) : files.map((f) => `- [${f.platform}] ${f.filePath}`); return [ "✅ 视频生成成功", `模板: ${ctx.template}`, `平台: ${ctx.platforms.join(", ")}`, `任务: ${ctx.jobId}`, "资源:", ...lines, ].join("\n"); } function buildUploadFailureText( ctx: PublishContext, files: ExportFile[], error: string ): string { return [ "⚠️ 视频已生成,但 OSS 上传失败", `模板: ${ctx.template}`, `平台: ${ctx.platforms.join(", ")}`, `任务: ${ctx.jobId}`, `本地文件: ${files.map((f) => f.filePath).join(", ")}`, `错误: ${error}`, ].join("\n"); } function buildGenerationFailureText(ctx: PublishContext, error: string): string { return [ "❌ 视频生成失败", `模板: ${ctx.template}`, `平台: ${ctx.platforms.join(", ")}`, `任务: ${ctx.jobId}`, `错误: ${error}`, ].join("\n"); } /** * Upload all exported files to OSS (if configured) and notify Feishu. * - Upload success → push OSS resource links. * - Upload failure → push failure info (render still succeeded locally). * - No OSS configured → push success with local file paths. */ export async function runPublish( files: ExportFile[], publish: PublishConfig, ctx: PublishContext ): Promise { const dateDir = isoDateString(new Date(), getTimezone()); const uploaded: UploadedFile[] = []; if (publish.oss) { log.info(`oss upload start files=${files.length} prefix=${publish.oss.path ?? "(root)"}`); try { for (const file of files) { const result = await uploadToOss(file.filePath, publish.oss, { dateDir }); uploaded.push({ platform: file.platform, filePath: file.filePath, ossUrl: result.url }); log.info(`oss uploaded [${file.platform}] key=${result.key} url=${result.url}`); } } catch (err) { const error = describeError(err); log.error(`oss upload failed: ${error}`); if (publish.feishu) { const ok = await safeNotify(publish.feishu, buildUploadFailureText(ctx, files, error)); log.info(`feishu upload-failure notice ${ok ? "sent" : "failed"}`); } return { ok: false, uploaded, error }; } } if (publish.feishu) { const ok = await safeNotify(publish.feishu, buildSuccessText(ctx, uploaded, files)); log.info(`feishu success notice ${ok ? "sent" : "failed"}`); } return { ok: true, uploaded }; } /** Notify Feishu that the render itself failed before any file was produced. */ export async function notifyGenerationFailure( publish: PublishConfig, ctx: PublishContext, error: string ): Promise { if (!publish.feishu) return; await safeNotify(publish.feishu, buildGenerationFailureText(ctx, error)); }