feat: 新增 MCP 聊天导出自动化并升级到 3.0.0

This commit is contained in:
ILoveBingLu
2026-04-03 21:58:33 +08:00
parent 4a0efe8904
commit 66a96c089f
11 changed files with 624 additions and 5 deletions
+1 -1
View File
@@ -7,7 +7,7 @@
**一款现代化的微信聊天记录查看与分析工具**
[![License](https://img.shields.io/badge/license-CC--BY--NC--SA--4.0-blue.svg)](LICENSE)
[![Version](https://img.shields.io/badge/version-2.3.5-green.svg)](package.json)
[![Version](https://img.shields.io/badge/version-3.0.0-green.svg)](package.json)
[![Platform](https://img.shields.io/badge/platform-Windows-0078D6.svg?logo=windows)]()
[![Electron](https://img.shields.io/badge/Electron-39-47848F.svg?logo=electron)]()
[![React](https://img.shields.io/badge/React-19-61DAFB.svg?logo=react)]()
+13
View File
@@ -32,6 +32,19 @@ export async function executeMcpTool(
payload
}
}
case 'export_chat': {
const payload = await readService.exportChat(args as any, reporter)
return {
summary: payload.success
? `Exported chat for ${payload.resolvedSession?.displayName || payload.resolvedSession?.sessionId || 'target session'}.`
: payload.success === false
? `Failed to export chat for ${payload.resolvedSession?.displayName || payload.resolvedSession?.sessionId || 'target session'}.`
: payload.canExport
? `Prepared export for ${payload.resolvedSession?.displayName || payload.resolvedSession?.sessionId || 'target session'}.`
: 'Export request needs more information.',
payload
}
}
case 'get_global_statistics': {
const payload = await readService.getGlobalStatistics(args as any)
return { summary: 'Loaded global statistics.', payload }
+319 -1
View File
@@ -1,10 +1,11 @@
import { existsSync, mkdirSync } from 'fs'
import { accessSync, constants, existsSync, mkdirSync } from 'fs'
import { writeFile } from 'fs/promises'
import { join } from 'path'
import { z } from 'zod'
import { analyticsService } from '../analyticsService'
import { chatService, type ChatSession, type ContactInfo, type Message } from '../chatService'
import { ConfigService } from '../config'
import { exportService, type ExportOptions as ExportServiceOptions } from '../exportService'
import { imageDecryptService } from '../imageDecryptService'
import { videoService } from '../videoService'
import { McpToolError } from './result'
@@ -15,6 +16,11 @@ import {
type McpContactKind,
type McpContactsPayload,
type McpCursor,
type McpExportChatPayload,
type McpExportDateRange,
type McpExportFormat,
type McpExportMediaOptions,
type McpExportMissingField,
type McpGlobalStatisticsPayload,
type McpContactRankingItem,
type McpContactRankingsPayload,
@@ -57,6 +63,33 @@ const resolveSessionArgsSchema = z.object({
limit: z.number().int().positive().optional()
})
const exportChatArgsSchema = z.object({
sessionId: z.string().trim().min(1).optional(),
query: z.string().trim().min(1).optional(),
format: z.enum(['chatlab', 'chatlab-jsonl', 'json', 'excel', 'html']).optional(),
dateRange: z.object({
start: z.number().int().positive(),
end: z.number().int().positive()
}).optional(),
mediaOptions: z.object({
exportAvatars: z.boolean().optional(),
exportImages: z.boolean().optional(),
exportVideos: z.boolean().optional(),
exportEmojis: z.boolean().optional(),
exportVoices: z.boolean().optional()
}).optional(),
outputDir: z.string().trim().min(1).optional(),
validateOnly: z.boolean().optional()
}).superRefine((value, ctx) => {
if (!value.sessionId && !value.query) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['sessionId'],
message: 'sessionId or query is required'
})
}
})
const getMessagesArgsSchema = z.object({
sessionId: z.string().trim().min(1),
offset: z.number().int().nonnegative().optional(),
@@ -126,6 +159,7 @@ const getSessionContextArgsSchema = z.object({
type ListSessionsArgs = z.infer<typeof listSessionsArgsSchema>
type ResolveSessionArgs = z.infer<typeof resolveSessionArgsSchema>
type ExportChatArgs = z.infer<typeof exportChatArgsSchema>
type GetMessagesArgs = z.infer<typeof getMessagesArgsSchema>
type ListContactsArgs = z.infer<typeof listContactsArgsSchema>
type SearchMessagesArgs = z.infer<typeof searchMessagesArgsSchema>
@@ -160,6 +194,8 @@ type McpStreamReporter = {
partial?: <K extends keyof McpStreamPartialPayloadMap>(toolName: K, payload: McpStreamPartialPayloadMap[K]) => void | Promise<void>
}
const SUPPORTED_EXPORT_FORMATS: McpExportFormat[] = ['chatlab', 'chatlab-jsonl', 'json', 'excel', 'html']
function toTimestampMs(value?: number | null): number {
if (!value || !Number.isFinite(value) || value <= 0) return 0
return value < 1_000_000_000_000 ? value * 1000 : value
@@ -631,6 +667,134 @@ function buildSearchSessionSummaries(hits: McpSearchHit[]): McpSearchMessagesPay
.sort((a, b) => b.hitCount - a.hitCount || b.topScore - a.topScore)
}
function getDefaultExportPath(): string | null {
const config = new ConfigService()
try {
const exportPath = String(config.get('exportPath') || '').trim()
return exportPath || null
} finally {
config.close()
}
}
function isWritableDirectory(dir: string): boolean {
try {
if (!dir) return false
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
accessSync(dir, constants.W_OK)
return true
} catch {
return false
}
}
function isCompleteMediaOptions(
mediaOptions?: ExportChatArgs['mediaOptions']
): mediaOptions is McpExportMediaOptions {
return Boolean(
mediaOptions
&& typeof mediaOptions.exportAvatars === 'boolean'
&& typeof mediaOptions.exportImages === 'boolean'
&& typeof mediaOptions.exportVideos === 'boolean'
&& typeof mediaOptions.exportEmojis === 'boolean'
&& typeof mediaOptions.exportVoices === 'boolean'
)
}
function getNextExportQuestion(missingFields: McpExportMissingField[]): string | undefined {
if (missingFields.includes('session')) {
return '请先确认要导出哪个会话,可以提供 sessionId 或更具体的联系人线索。'
}
if (missingFields.includes('dateRange')) {
return '请补充导出的时间范围,至少需要开始时间和结束时间。'
}
if (missingFields.includes('format')) {
return '请确认导出格式,仅支持 chatlab、chatlab-jsonl、json、excel、html。'
}
if (missingFields.includes('mediaOptions')) {
return '请明确是否导出头像、图片、视频、表情、语音。'
}
if (missingFields.includes('outputDir')) {
return '默认导出目录不可用,请提供一个可写入的导出目录。'
}
return undefined
}
function buildExportFollowUpQuestions(missingFields: McpExportMissingField[]): Array<{
field: McpExportMissingField
question: string
}> {
const questions: Array<{ field: McpExportMissingField; question: string }> = []
for (const field of missingFields) {
if (field === 'session') {
questions.push({
field,
question: '你要导出哪个会话?可以给我更具体的联系人、备注名或 sessionId。'
})
} else if (field === 'dateRange') {
questions.push({
field,
question: '这次导出的时间范围是什么?请给我开始时间和结束时间。'
})
} else if (field === 'format') {
questions.push({
field,
question: '你要导出成哪种格式?目前支持 chatlab、chatlab-jsonl、json、excel、html。'
})
} else if (field === 'mediaOptions') {
questions.push({
field,
question: '媒体要怎么导?请分别确认是否包含头像、图片、视频、表情、语音。'
})
} else if (field === 'outputDir') {
questions.push({
field,
question: '默认导出目录不可用,请给我一个可写入的导出目录。'
})
}
}
return questions
}
function buildPredictedExportPath(
outputDir: string,
resolvedSession: Pick<McpResolvedSessionCandidate, 'displayName'>,
format: McpExportFormat,
mediaOptions: McpExportMediaOptions
): string {
const safeName = resolvedSession.displayName.replace(/[<>:"/\\|?*]/g, '_').replace(/\.+$/, '').trim() || 'export'
const ext = format === 'chatlab-jsonl'
? '.jsonl'
: format === 'excel'
? '.xlsx'
: format === 'html'
? '.html'
: '.json'
const hasMedia = mediaOptions.exportImages || mediaOptions.exportVideos || mediaOptions.exportEmojis || mediaOptions.exportVoices
const sessionOutputDir = hasMedia ? join(outputDir, safeName) : outputDir
return join(sessionOutputDir, `${safeName}${ext}`)
}
function toExportServiceOptions(
format: McpExportFormat,
dateRange: McpExportDateRange,
mediaOptions: McpExportMediaOptions
): ExportServiceOptions {
return {
format,
dateRange,
exportAvatars: mediaOptions.exportAvatars,
exportImages: mediaOptions.exportImages,
exportVideos: mediaOptions.exportVideos,
exportEmojis: mediaOptions.exportEmojis,
exportVoices: mediaOptions.exportVoices
}
}
function resolveSessionRefStrict(
rawInput: string,
sessions: McpSessionItem[],
@@ -1060,6 +1224,160 @@ export class McpReadService {
return payload
}
async exportChat(rawArgs: ExportChatArgs, reporter?: McpStreamReporter): Promise<McpExportChatPayload> {
const args = exportChatArgsSchema.safeParse(rawArgs)
if (!args.success) {
throw new McpToolError('BAD_REQUEST', 'Invalid export_chat arguments.', args.error.message)
}
const data = args.data
const validateOnly = Boolean(data.validateOnly)
await reportProgress(reporter, {
stage: 'validating_export_request',
message: 'Validating export request.'
})
const [{ items: sessions, map: sessionMap }, { items: contacts, map: contactMap }] = await Promise.all([
getSessionCatalog(),
getContactCatalog()
])
let resolvedSession: McpResolvedSessionCandidate | undefined
let candidates: McpResolvedSessionCandidate[] = []
if (data.sessionId || data.query) {
const query = data.sessionId || data.query || ''
const matchedCandidates = findSessionCandidates(query, sessions, contacts).slice(0, 5)
candidates = matchedCandidates.map((candidate) => toResolvedCandidate(candidate, query))
try {
const resolved = await resolveSessionRefStrictWithProgress(query, sessions, sessionMap, contacts, contactMap, reporter)
const matched = matchedCandidates.find((candidate) => candidate.entry.session.sessionId === resolved.sessionId)
resolvedSession = matched ? toResolvedCandidate(matched, query) : {
...resolved,
score: 1000,
confidence: 'high',
aliases: [resolved.displayName, resolved.sessionId],
evidence: ['Resolved directly from the provided session clue.']
}
} catch (error) {
if (!(error instanceof McpToolError) || (error.code !== 'BAD_REQUEST' && error.code !== 'SESSION_NOT_FOUND')) {
throw error
}
}
}
const missingFields: McpExportMissingField[] = []
if (!resolvedSession) {
missingFields.push('session')
}
if (!data.dateRange || !data.dateRange.start || !data.dateRange.end) {
missingFields.push('dateRange')
} else if (data.dateRange.start > data.dateRange.end) {
throw new McpToolError('BAD_REQUEST', 'Invalid export date range.', 'dateRange.start must be earlier than or equal to dateRange.end.')
}
if (!data.format) {
missingFields.push('format')
} else if (!SUPPORTED_EXPORT_FORMATS.includes(data.format)) {
throw new McpToolError('BAD_REQUEST', 'Unsupported export format.', `Only ${SUPPORTED_EXPORT_FORMATS.join(', ')} are supported.`)
}
if (!isCompleteMediaOptions(data.mediaOptions)) {
missingFields.push('mediaOptions')
}
const requestedOutputDir = String(data.outputDir || '').trim()
const outputDir = requestedOutputDir || getDefaultExportPath() || ''
if (!outputDir || !isWritableDirectory(outputDir)) {
missingFields.push('outputDir')
}
const nextQuestion = getNextExportQuestion(missingFields)
const followUpQuestions = buildExportFollowUpQuestions(missingFields)
const payload: McpExportChatPayload = {
canExport: missingFields.length === 0,
validateOnly,
missingFields,
nextQuestion,
followUpQuestions,
resolvedSession,
candidates,
outputDir: outputDir || undefined,
format: data.format,
dateRange: data.dateRange,
mediaOptions: isCompleteMediaOptions(data.mediaOptions) ? data.mediaOptions : undefined,
message: missingFields.length === 0
? validateOnly
? 'Export request is complete and ready to run.'
: 'Export request validated and ready to execute.'
: 'Export request is incomplete and needs more information.'
}
await reportPartial(reporter, 'export_chat', payload)
if (missingFields.length > 0 || validateOnly) {
return payload
}
await reportProgress(reporter, {
stage: 'preparing_export',
message: `Preparing export for ${resolvedSession!.displayName}.`,
candidates: [{ sessionId: resolvedSession!.sessionId, displayName: resolvedSession!.displayName, kind: resolvedSession!.kind }],
candidateCount: 1
})
const exportOptions = toExportServiceOptions(
data.format!,
data.dateRange!,
data.mediaOptions as McpExportMediaOptions
)
const predictedOutputPath = buildPredictedExportPath(
outputDir,
resolvedSession!,
data.format!,
data.mediaOptions as McpExportMediaOptions
)
const result = await exportService.exportSessions(
[resolvedSession!.sessionId],
outputDir,
exportOptions,
(progress) => {
const stage = progress.phase === 'writing'
? 'writing'
: progress.phase === 'exporting'
? 'exporting'
: progress.phase === 'complete'
? 'completed'
: 'preparing_export'
void reportProgress(reporter, {
stage,
message: progress.detail || progress.phase,
sessionsScanned: progress.current,
candidates: [{ sessionId: resolvedSession!.sessionId, displayName: resolvedSession!.displayName, kind: resolvedSession!.kind }],
candidateCount: 1
})
}
)
const completedPayload: McpExportChatPayload = {
...payload,
canExport: true,
success: result.success,
successCount: result.successCount,
failCount: result.failCount,
error: result.error,
outputPath: predictedOutputPath,
message: result.success
? `Exported chat for ${resolvedSession!.displayName}.`
: `Failed to export chat for ${resolvedSession!.displayName}.`
}
await reportPartial(reporter, 'export_chat', completedPayload)
return completedPayload
}
async listSessions(rawArgs: ListSessionsArgs, reporter?: McpStreamReporter): Promise<McpSessionsPayload> {
const args = listSessionsArgsSchema.safeParse(rawArgs)
if (!args.success) {
+5
View File
@@ -7,6 +7,7 @@ import type {
McpActivityDistributionPayload,
McpContactRankingsPayload,
McpContactsPayload,
McpExportChatPayload,
McpGlobalStatisticsPayload,
McpHealthPayload,
McpMessagesPayload,
@@ -270,6 +271,10 @@ export class McpReadService {
return this.callProxy<McpResolveSessionPayload>('resolve_session', rawArgs)
}
async exportChat(rawArgs: Record<string, unknown>): Promise<McpExportChatPayload> {
return this.callProxy<McpExportChatPayload>('export_chat', rawArgs)
}
async listSessions(rawArgs: Record<string, unknown>): Promise<McpSessionsPayload> {
return this.callProxy<McpSessionsPayload>('list_sessions', rawArgs)
}
+30
View File
@@ -47,6 +47,36 @@ export function registerCipherTalkMcpTools(server: any) {
}
})
server.registerTool('export_chat', {
title: 'Export Chat',
description: 'Validate and export chat history for one resolved session. This tool strictly checks target session, date range, export format, media selections, and output directory before exporting.',
inputSchema: {
sessionId: z.string().trim().min(1).optional().describe('Resolved sessionId when already known.'),
query: z.string().trim().min(1).optional().describe('Fuzzy session clue when sessionId is not yet known.'),
format: z.enum(['chatlab', 'chatlab-jsonl', 'json', 'excel', 'html']).optional().describe('Export format.'),
dateRange: z.object({
start: z.number().int().positive(),
end: z.number().int().positive()
}).optional().describe('Required export time range in seconds or milliseconds.'),
mediaOptions: z.object({
exportAvatars: z.boolean().optional(),
exportImages: z.boolean().optional(),
exportVideos: z.boolean().optional(),
exportEmojis: z.boolean().optional(),
exportVoices: z.boolean().optional()
}).optional().describe('Required explicit media export selections.'),
outputDir: z.string().trim().min(1).optional().describe('Optional output directory. If omitted, the configured default export path will be used when available.'),
validateOnly: z.boolean().optional().describe('When true, only validate completeness and return missing fields without exporting.')
}
}, async (args: unknown) => {
try {
const payload = await readService.exportChat((args || {}) as any)
return createToolSuccess(payload.message, payload)
} catch (error) {
return createToolError(error)
}
})
server.registerTool('get_global_statistics', {
title: 'Get Global Statistics',
description: 'Return global private-chat statistics for agent-side analysis.',
+52
View File
@@ -2,6 +2,7 @@ export const MCP_TOOL_NAMES = [
'health_check',
'get_status',
'resolve_session',
'export_chat',
'list_sessions',
'get_messages',
'list_contacts',
@@ -56,7 +57,11 @@ export type McpStreamProgressStage =
| 'searching_contacts'
| 'searching_sessions'
| 'resolving_candidates'
| 'validating_export_request'
| 'preparing_export'
| 'scanning_messages'
| 'exporting'
| 'writing'
| 'streaming_hits'
| 'completed'
| 'failed'
@@ -150,6 +155,51 @@ export interface McpResolveSessionPayload {
message: string
}
export type McpExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'excel' | 'html'
export interface McpExportMediaOptions {
exportAvatars: boolean
exportImages: boolean
exportVideos: boolean
exportEmojis: boolean
exportVoices: boolean
}
export type McpExportMissingField =
| 'session'
| 'dateRange'
| 'format'
| 'mediaOptions'
| 'outputDir'
export interface McpExportDateRange {
start: number
end: number
}
export interface McpExportChatPayload {
canExport: boolean
validateOnly: boolean
missingFields: McpExportMissingField[]
nextQuestion?: string
followUpQuestions?: Array<{
field: McpExportMissingField
question: string
}>
resolvedSession?: McpResolvedSessionCandidate
candidates?: McpResolvedSessionCandidate[]
outputDir?: string
outputPath?: string
format?: McpExportFormat
dateRange?: McpExportDateRange
mediaOptions?: McpExportMediaOptions
success?: boolean
successCount?: number
failCount?: number
error?: string
message: string
}
export interface McpContactItem {
contactId: string
sessionId?: string
@@ -309,6 +359,7 @@ export interface McpStreamProgressPayload {
export interface McpStreamPartialPayloadMap {
resolve_session: Partial<McpResolveSessionPayload>
export_chat: Partial<McpExportChatPayload>
list_sessions: Partial<McpSessionsPayload>
list_contacts: Partial<McpContactsPayload>
get_messages: Partial<McpMessagesPayload>
@@ -317,6 +368,7 @@ export interface McpStreamPartialPayloadMap {
}
export type McpStreamPartialPayload =
| McpStreamPartialPayloadMap['export_chat']
| McpStreamPartialPayloadMap['list_sessions']
| McpStreamPartialPayloadMap['list_contacts']
| McpStreamPartialPayloadMap['get_messages']
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ciphertalk",
"version": "2.3.5",
"version": "3.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ciphertalk",
"version": "2.3.5",
"version": "3.0.0",
"hasInstallScript": true,
"license": "CC-BY-NC-SA-4.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ciphertalk",
"version": "2.3.5",
"version": "3.0.0",
"description": "密语 - 微信聊天记录查看工具",
"author": "ILoveBingLu",
"license": "CC-BY-NC-SA-4.0",
+36
View File
@@ -66,6 +66,39 @@ After each meaningful exploration round, produce a very short battle report for
Keep it short. It should help trace the reasoning, not overshadow the answer.
## Export workflow
When the user asks to export chat history:
1. Check whether the request already includes:
- target session
- time range
- export format
- media selections
2. If the target is fuzzy, resolve it first with `resolve_session`.
3. If the target is still ambiguous, keep narrowing and do not export yet.
4. Use `export_chat(validateOnly=true)` to audit whether the request is complete.
5. If `missingFields` is non-empty, prefer `followUpQuestions`; otherwise fall back to `nextQuestion`.
6. Ask follow-up questions until the missing fields are all resolved.
7. Prefer the configured default export directory when it exists and is writable.
8. If the default export directory is unavailable, ask the user for an output directory.
9. Only call `export_chat` without `validateOnly` after the request is complete.
When asking follow-up questions for export:
- ask only for missing fields
- do not ask again for fields the user already confirmed
- treat media selections as required and explicit
- do not silently assume a time range
After export finishes, summarize:
- which session was exported
- the time range
- the format
- which media were included
- where the files were written
## Never do this
- Do not conclude “没有数据” after a single failed query.
@@ -73,7 +106,10 @@ Keep it short. It should help trace the reasoning, not overshadow the answer.
- Do not ignore `hint` or candidate summaries returned by MCP.
- Do not ignore `evidence` on resolved candidates or `sessionSummaries` on search results.
- Do not lock onto a candidate while ambiguity is still obvious.
- Do not start exporting before target session, time range, format, and media selections are all confirmed.
- Do not quietly choose a time range or media mix on the users behalf.
## References
- Read [references/queries.md](references/queries.md) when you need concrete fuzzy-query playbooks, fallback chains, or battle-report examples.
- Read [references/export.md](references/export.md) when the user asks to export chat history.
+104
View File
@@ -0,0 +1,104 @@
# CipherTalk MCP Export Playbook
## Goal
Turn vague export requests into a complete, executable export plan.
## Required fields before exporting
Do not export until all of these are known:
- target session
- time range
- export format
- media selections
Output directory may be omitted only if the configured default export directory is available and writable.
## Export routing
### 1. User asks to export chat history with incomplete info
Example:
- “导出聊天记录”
- “把那个人的聊天导出来”
Use this order:
1. Resolve the session if needed with `resolve_session`
2. Call `export_chat(validateOnly=true)`
3. Read `missingFields`
4. Prefer `followUpQuestions`; use `nextQuestion` only as fallback
5. Ask only for the missing fields
6. Repeat `validateOnly` until `canExport=true`
7. Call `export_chat(validateOnly=false)`
Battle report:
- “战报:导出条件还没齐,先把缺项问全。”
### 2. User gives target and format but no time range
Example:
- “导出这个会话为 html”
Use this order:
1. Confirm the target session
2. Run `export_chat(validateOnly=true)`
3. Ask for time range
4. Ask for media selections if still missing
5. Export only after validation passes
### 3. User gives almost everything
Example:
- “导出最近三个月的聊天记录为 html,只要图片和视频”
Use this order:
1. Resolve the target session if needed
2. Run `export_chat(validateOnly=true)`
3. If only `outputDir` is missing, prefer the configured default export path
4. If validation passes, export directly
Battle report:
- “战报:导出参数基本齐了,只差最后确认落盘位置。”
## How to ask follow-up questions
Ask in this priority order:
1. target session
2. time range
3. format
4. media selections
5. output directory only if default path is unavailable
When asking about media selections, be explicit:
- avatars
- images
- videos
- emojis
- voices
Do not accept vague phrasing like “带媒体” without clarifying the exact set.
## Answer style after export
Keep the export completion summary short and operational:
- exported session
- time range
- format
- included media
- output path
## Local helper
If you want a local dry-run outside MCP, use `scripts/validate-export-request.cjs` to sanity-check a request payload before wiring it into tool calls.
@@ -0,0 +1,61 @@
const fs = require('fs')
function readInput() {
const chunks = []
const fd = 0
try {
const stat = fs.fstatSync(fd)
if (stat.size === 0 && process.stdin.isTTY) {
return null
}
} catch {}
return fs.readFileSync(fd, 'utf8').trim() || null
}
function validate(payload) {
const missingFields = []
if (!payload.sessionId && !payload.query) missingFields.push('session')
if (!payload.dateRange || !payload.dateRange.start || !payload.dateRange.end) {
missingFields.push('dateRange')
}
if (!payload.format) {
missingFields.push('format')
}
const media = payload.mediaOptions
const completeMedia = media
&& typeof media.exportAvatars === 'boolean'
&& typeof media.exportImages === 'boolean'
&& typeof media.exportVideos === 'boolean'
&& typeof media.exportEmojis === 'boolean'
&& typeof media.exportVoices === 'boolean'
if (!completeMedia) {
missingFields.push('mediaOptions')
}
return {
canExport: missingFields.length === 0,
missingFields
}
}
const raw = readInput()
if (!raw) {
console.error('Provide a JSON payload via stdin.')
process.exit(1)
}
let payload
try {
payload = JSON.parse(raw)
} catch (error) {
console.error(`Invalid JSON: ${error.message}`)
process.exit(1)
}
process.stdout.write(`${JSON.stringify(validate(payload), null, 2)}\n`)