diff --git a/packages/cli/src/__tests__/notify-option.test.ts b/packages/cli/src/__tests__/notify-option.test.ts new file mode 100644 index 00000000..fa8691b6 --- /dev/null +++ b/packages/cli/src/__tests__/notify-option.test.ts @@ -0,0 +1,348 @@ +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const writeNextChapterMock = vi.fn(); +const auditDraftMock = vi.fn(); +const reviseDraftMock = vi.fn(); +const dispatchNotificationMock = vi.fn(); +const buildPipelineConfigMock = vi.fn(); +const loadConfigMock = vi.fn(); +const loadBookConfigMock = vi.fn(); +const getNextChapterNumberMock = vi.fn(); +const logMock = vi.fn(); +const logErrorMock = vi.fn(); + +vi.mock("@actalk/inkos-core", () => ({ + PipelineRunner: class { + writeNextChapter = writeNextChapterMock; + auditDraft = auditDraftMock; + reviseDraft = reviseDraftMock; + }, + StateManager: class { + async loadBookConfig() { + return loadBookConfigMock(); + } + async getNextChapterNumber() { + return getNextChapterNumberMock(); + } + }, + dispatchNotification: dispatchNotificationMock, + resolveChapterReviewMode: vi.fn(() => "auto"), + resolveRevisionGate: vi.fn(() => undefined), + DEFAULT_REVISE_MODE: "spot-fix", + // Real localization.ts imports these from core; keep them deterministic. + formatLengthCount: (count: number) => `${count}字`, + resolveLengthCountingMode: () => "chars", +})); + +vi.mock("../utils.js", () => ({ + loadConfig: loadConfigMock, + buildPipelineConfig: buildPipelineConfigMock, + findProjectRoot: vi.fn(() => "/project"), + resolveBookId: vi.fn(async (bookId?: string) => bookId ?? "auto-book"), + getLegacyMigrationHint: vi.fn(async () => null), + resolveContext: vi.fn(async () => undefined), + log: logMock, + logError: logErrorMock, +})); + +const notifyChannels = [ + { type: "telegram", botToken: "123:ABC", chatId: "-100", format: "text" }, +]; + +function chapterResult(chapterNumber: number, status = "ready-for-review") { + return { + chapterNumber, + title: `第${chapterNumber}章`, + wordCount: 3000, + auditResult: { passed: true, issues: [], summary: "ok" }, + revised: false, + status, + }; +} + +const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never); + +describe("--notify command option", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + loadBookConfigMock.mockResolvedValue({ + title: "示例书", + language: "zh", + writing: {}, + }); + loadConfigMock.mockResolvedValue({ + llm: {}, + writing: { reviewRetries: 1 }, + notify: notifyChannels, + }); + buildPipelineConfigMock.mockReturnValue({}); + dispatchNotificationMock.mockResolvedValue(undefined); + }); + + afterAll(() => { + exitSpy.mockRestore(); + }); + + describe("write next", () => { + it("skips the success notification for a single-chapter run (pipeline already notified per chapter)", async () => { + writeNextChapterMock.mockResolvedValueOnce(chapterResult(4)); + + const { writeCommand } = await import("../commands/write.js"); + await writeCommand.parseAsync(["node", "write", "next", "demo-book", "--notify"], { from: "node" }); + + expect(writeNextChapterMock).toHaveBeenCalledTimes(1); + expect(dispatchNotificationMock).not.toHaveBeenCalled(); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it("sends one batch summary for a multi-chapter run", async () => { + let chapter = 3; + writeNextChapterMock.mockImplementation(async () => chapterResult(++chapter)); + + const { writeCommand } = await import("../commands/write.js"); + await writeCommand.parseAsync( + ["node", "write", "next", "demo-book", "--count", "2", "--notify"], + { from: "node" }, + ); + + expect(dispatchNotificationMock).toHaveBeenCalledTimes(1); + const [channels, message] = dispatchNotificationMock.mock.calls[0]!; + expect(channels).toBe(notifyChannels); + expect(message.title).toBe("✅ 写作完成《示例书》"); + expect(message.body).toContain("本次完成 2 章(第4章到第5章)"); + expect(message.body).toContain("第4章 第4章 | 3000字 | 审计通过"); + }); + + it("does not send a batch summary without --notify", async () => { + let chapter = 3; + writeNextChapterMock.mockImplementation(async () => chapterResult(++chapter)); + + const { writeCommand } = await import("../commands/write.js"); + await writeCommand.parseAsync( + ["node", "write", "next", "demo-book", "--count", "2"], + { from: "node" }, + ); + + expect(dispatchNotificationMock).not.toHaveBeenCalled(); + }); + + it("sends a failure notification with the error message before exiting", async () => { + writeNextChapterMock.mockRejectedValueOnce(new Error("LLM exploded")); + + const { writeCommand } = await import("../commands/write.js"); + await writeCommand.parseAsync(["node", "write", "next", "demo-book", "--notify"], { from: "node" }); + + expect(dispatchNotificationMock).toHaveBeenCalledTimes(1); + const [, message] = dispatchNotificationMock.mock.calls[0]!; + expect(message.title).toBe("❌ 写作失败《示例书》"); + expect(message.body).toContain("LLM exploded"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("sends no failure notification without --notify", async () => { + writeNextChapterMock.mockRejectedValueOnce(new Error("LLM exploded")); + + const { writeCommand } = await import("../commands/write.js"); + await writeCommand.parseAsync(["node", "write", "next", "demo-book"], { from: "node" }); + + expect(dispatchNotificationMock).not.toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + }); + + describe("write rewrite", () => { + it("sends a failure notification when the command fails", async () => { + const { writeCommand } = await import("../commands/write.js"); + await writeCommand.parseAsync( + ["node", "write", "rewrite", "a", "b", "c", "--notify"], + { from: "node" }, + ); + + expect(dispatchNotificationMock).toHaveBeenCalledTimes(1); + const [channels, message] = dispatchNotificationMock.mock.calls[0]!; + // Failure happened before the book config was loaded: helper loads the + // project config itself and falls back to zh with no book name. + expect(channels).toBe(notifyChannels); + expect(message.title).toBe("❌ 重写失败"); + expect(message.body).toContain("Usage: inkos write rewrite"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + }); + + describe("audit", () => { + it("sends a completion notification with the audit verdict", async () => { + auditDraftMock.mockResolvedValueOnce({ + chapterNumber: 4, + passed: true, + issues: [], + summary: "整体一致", + }); + + const { auditCommand } = await import("../commands/audit.js"); + await auditCommand.parseAsync(["node", "audit", "demo-book", "--notify"], { from: "node" }); + + expect(dispatchNotificationMock).toHaveBeenCalledTimes(1); + const [channels, message] = dispatchNotificationMock.mock.calls[0]!; + expect(channels).toBe(notifyChannels); + expect(message.title).toBe("✅ 审计完成《示例书》"); + expect(message.body).toBe("第4章审计通过(0 个问题)\n整体一致"); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it("uses English copy when the book language is en", async () => { + loadBookConfigMock.mockResolvedValue({ title: "My Book", language: "en", writing: {} }); + auditDraftMock.mockResolvedValueOnce({ + chapterNumber: 2, + passed: false, + issues: [{ severity: "major", category: "timeline", description: "conflict" }], + summary: "timeline conflict", + }); + + const { auditCommand } = await import("../commands/audit.js"); + await auditCommand.parseAsync(["node", "audit", "demo-book", "--notify"], { from: "node" }); + + const [, message] = dispatchNotificationMock.mock.calls[0]!; + expect(message.title).toBe("✅ Audit complete: My Book"); + expect(message.body).toBe("Chapter 2 audit failed (1 issue(s))\ntimeline conflict"); + }); + + it("warns and skips when --notify is set but no channels are configured", async () => { + loadConfigMock.mockResolvedValue({ llm: {}, writing: { reviewRetries: 1 }, notify: [] }); + auditDraftMock.mockResolvedValueOnce({ + chapterNumber: 4, + passed: true, + issues: [], + summary: "ok", + }); + + const { auditCommand } = await import("../commands/audit.js"); + await auditCommand.parseAsync(["node", "audit", "demo-book", "--notify"], { from: "node" }); + + expect(dispatchNotificationMock).not.toHaveBeenCalled(); + expect(logErrorMock).toHaveBeenCalledWith(expect.stringContaining("--notify")); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it("does not let a notification failure change the command exit code", async () => { + dispatchNotificationMock.mockRejectedValueOnce(new Error("network down")); + auditDraftMock.mockResolvedValueOnce({ + chapterNumber: 4, + passed: true, + issues: [], + summary: "ok", + }); + + const { auditCommand } = await import("../commands/audit.js"); + await auditCommand.parseAsync(["node", "audit", "demo-book", "--notify"], { from: "node" }); + + expect(logErrorMock).toHaveBeenCalledWith(expect.stringContaining("network down")); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it("sends a failure notification when the audit fails", async () => { + auditDraftMock.mockRejectedValueOnce(new Error("no chapters")); + + const { auditCommand } = await import("../commands/audit.js"); + await auditCommand.parseAsync(["node", "audit", "demo-book", "--notify"], { from: "node" }); + + const [, message] = dispatchNotificationMock.mock.calls[0]!; + expect(message.title).toBe("❌ 审计失败《示例书》"); + expect(message.body).toContain("no chapters"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + }); + + describe("revise", () => { + it("sends a completion notification when the revision is applied", async () => { + reviseDraftMock.mockResolvedValueOnce({ + chapterNumber: 3, + wordCount: 3200, + fixedIssues: ["fix a", "fix b"], + applied: true, + status: "ready-for-review", + }); + + const { reviseCommand } = await import("../commands/revise.js"); + await reviseCommand.parseAsync(["node", "revise", "demo-book", "3", "--notify"], { from: "node" }); + + expect(dispatchNotificationMock).toHaveBeenCalledTimes(1); + const [, message] = dispatchNotificationMock.mock.calls[0]!; + expect(message.title).toBe("✅ 修订完成《示例书》"); + expect(message.body).toBe("第3章已修订 | 3200字 | 修复 2 个问题"); + }); + + it("reports a kept original draft with the skip reason", async () => { + reviseDraftMock.mockResolvedValueOnce({ + chapterNumber: 3, + wordCount: 3000, + fixedIssues: [], + applied: false, + status: "unchanged", + skippedReason: "无阻断问题", + }); + + const { reviseCommand } = await import("../commands/revise.js"); + await reviseCommand.parseAsync(["node", "revise", "demo-book", "3", "--notify"], { from: "node" }); + + const [, message] = dispatchNotificationMock.mock.calls[0]!; + expect(message.title).toBe("✅ 修订完成《示例书》"); + expect(message.body).toBe("第3章保留原稿:无阻断问题"); + }); + + it("sends a failure notification when the revision fails", async () => { + reviseDraftMock.mockRejectedValueOnce(new Error("revision blew up")); + + const { reviseCommand } = await import("../commands/revise.js"); + await reviseCommand.parseAsync(["node", "revise", "demo-book", "3", "--notify"], { from: "node" }); + + const [, message] = dispatchNotificationMock.mock.calls[0]!; + expect(message.title).toBe("❌ 修订失败《示例书》"); + expect(message.body).toContain("revision blew up"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + }); + + describe("auto", () => { + it("sends one batch summary for a multi-chapter run", async () => { + getNextChapterNumberMock.mockResolvedValue(1); + let chapter = 0; + writeNextChapterMock.mockImplementation(async () => chapterResult(++chapter)); + + const { autoCommand } = await import("../commands/auto.js"); + await autoCommand.parseAsync(["node", "auto", "demo-book", "3", "--notify"], { from: "node" }); + + expect(writeNextChapterMock).toHaveBeenCalledTimes(3); + expect(dispatchNotificationMock).toHaveBeenCalledTimes(1); + const [, message] = dispatchNotificationMock.mock.calls[0]!; + expect(message.title).toBe("✅ 自动连写完成《示例书》"); + expect(message.body).toContain("本次完成 3 章(第1章到第3章)"); + }); + + it("skips the success notification for a single-chapter run (pipeline already notified per chapter)", async () => { + getNextChapterNumberMock.mockResolvedValue(3); + writeNextChapterMock.mockResolvedValueOnce(chapterResult(3)); + + const { autoCommand } = await import("../commands/auto.js"); + await autoCommand.parseAsync(["node", "auto", "demo-book", "3", "--notify"], { from: "node" }); + + expect(dispatchNotificationMock).not.toHaveBeenCalled(); + }); + + it("sends a failure notification when a chapter write fails mid-run", async () => { + getNextChapterNumberMock.mockResolvedValue(1); + writeNextChapterMock + .mockResolvedValueOnce(chapterResult(1)) + .mockRejectedValueOnce(new Error("LLM exploded")); + + const { autoCommand } = await import("../commands/auto.js"); + await autoCommand.parseAsync(["node", "auto", "demo-book", "3", "--notify"], { from: "node" }); + + expect(dispatchNotificationMock).toHaveBeenCalledTimes(1); + const [, message] = dispatchNotificationMock.mock.calls[0]!; + expect(message.title).toBe("❌ 自动连写失败《示例书》"); + expect(message.body).toContain("Chapter 2 failed"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + }); +}); diff --git a/packages/cli/src/commands/audit.ts b/packages/cli/src/commands/audit.ts index 227c02e7..e3382d65 100644 --- a/packages/cli/src/commands/audit.ts +++ b/packages/cli/src/commands/audit.ts @@ -1,13 +1,24 @@ import { Command } from "commander"; -import { PipelineRunner } from "@actalk/inkos-core"; +import { PipelineRunner, StateManager } from "@actalk/inkos-core"; import { loadConfig, buildPipelineConfig, findProjectRoot, resolveBookId, log, logError } from "../utils.js"; +import { + formatNotifyAuditBody, + formatNotifyCommandTitle, + formatNotifyFailureBody, + resolveCliLanguage, + type CliLanguage, +} from "../localization.js"; +import { sendCommandNotification } from "../notify-helper.js"; export const auditCommand = new Command("audit") .description("Audit a chapter for continuity issues") .argument("[book-id]", "Book ID (auto-detected if only one book)") .argument("[chapter]", "Chapter number (defaults to latest)") .option("--json", "Output JSON") + .option("--notify", "Send a notification to configured notify channels when the command finishes") .action(async (bookIdArg: string | undefined, chapterStr: string | undefined, opts) => { + let notifyLanguage: CliLanguage = "zh"; + let notifyBookName: string | undefined; try { const config = await loadConfig(); const root = findProjectRoot(); @@ -23,6 +34,12 @@ export const auditCommand = new Command("audit") chapterNumber = chapterStr ? parseInt(chapterStr, 10) : undefined; } + const state = new StateManager(root); + const book = await state.loadBookConfig(bookId); + const language = resolveCliLanguage(book.language); + notifyLanguage = language; + notifyBookName = book.title ?? bookId; + const pipeline = new PipelineRunner(buildPipelineConfig(config, root)); if (!opts.json) log(`Auditing "${bookId}"${chapterNumber ? ` chapter ${chapterNumber}` : " (latest)"}...`); @@ -41,7 +58,27 @@ export const auditCommand = new Command("audit") } } } + + // Unlike write commands, the pipeline sends no notification for + // auditDraft, so --notify always sends the completion notification here. + if (opts.notify) { + await sendCommandNotification({ + title: formatNotifyCommandTitle(language, "audit", notifyBookName, true), + body: formatNotifyAuditBody(language, { + chapterNumber: result.chapterNumber, + passed: result.passed, + issueCount: result.issues.length, + summary: result.summary, + }), + }, config); + } } catch (e) { + if (opts.notify) { + await sendCommandNotification({ + title: formatNotifyCommandTitle(notifyLanguage, "audit", notifyBookName, false), + body: formatNotifyFailureBody(notifyLanguage, e), + }); + } if (opts.json) { log(JSON.stringify({ error: String(e) })); } else { diff --git a/packages/cli/src/commands/auto.ts b/packages/cli/src/commands/auto.ts index 7388158d..c1b40a31 100644 --- a/packages/cli/src/commands/auto.ts +++ b/packages/cli/src/commands/auto.ts @@ -4,11 +4,16 @@ import { loadConfig, buildPipelineConfig, findProjectRoot, getLegacyMigrationHin import { formatAutoWriteAlreadyComplete, formatAutoWriteStart, + formatNotifyBatchWriteBody, + formatNotifyCommandTitle, + formatNotifyFailureBody, formatWriteNextComplete, formatWriteNextProgress, formatWriteNextResultLines, resolveCliLanguage, + type CliLanguage, } from "../localization.js"; +import { sendCommandNotification } from "../notify-helper.js"; export const autoCommand = new Command("auto") .description("Auto-write chapters until the book reaches a target chapter number: auto [book-id] ") @@ -16,7 +21,10 @@ export const autoCommand = new Command("auto") .option("--words ", "Words per chapter (overrides book config)") .option("--json", "Output JSON") .option("-q, --quiet", "Suppress console output") + .option("--notify", "Send a notification to configured notify channels when the command finishes") .action(async (args: ReadonlyArray, opts) => { + let notifyLanguage: CliLanguage = "zh"; + let notifyBookName: string | undefined; try { const root = findProjectRoot(); @@ -40,6 +48,8 @@ export const autoCommand = new Command("auto") const state = new StateManager(root); const book = await state.loadBookConfig(bookId); const language = resolveCliLanguage(book.language); + notifyLanguage = language; + notifyBookName = book.title ?? bookId; const migrationHint = await getLegacyMigrationHint(root, bookId); if (migrationHint && !opts.json) { log(`[migration] ${migrationHint}`); @@ -109,7 +119,30 @@ export const autoCommand = new Command("auto") } else { log(formatWriteNextComplete(language)); } + + // The pipeline itself already sends one notification per completed + // chapter whenever notify channels are configured (runner.ts, end of + // writeNextChapter). A single-chapter run would therefore duplicate that + // exact notification — only send a command-level batch summary when this + // run wrote more than one chapter. + if (opts.notify && results.length > 1) { + await sendCommandNotification({ + title: formatNotifyCommandTitle(language, "auto", notifyBookName, true), + body: formatNotifyBatchWriteBody(language, results.map((r) => ({ + chapterNumber: r.chapterNumber, + title: r.title, + wordCount: r.wordCount, + auditPassed: r.auditResult.passed, + }))), + }, config); + } } catch (e) { + if (opts.notify) { + await sendCommandNotification({ + title: formatNotifyCommandTitle(notifyLanguage, "auto", notifyBookName, false), + body: formatNotifyFailureBody(notifyLanguage, e), + }); + } if (opts.json) { log(JSON.stringify({ error: String(e) })); } else { diff --git a/packages/cli/src/commands/revise.ts b/packages/cli/src/commands/revise.ts index 2070cb42..828aafc0 100644 --- a/packages/cli/src/commands/revise.ts +++ b/packages/cli/src/commands/revise.ts @@ -1,6 +1,14 @@ import { Command } from "commander"; import { DEFAULT_REVISE_MODE, PipelineRunner, StateManager, resolveRevisionGate, type ReviseMode } from "@actalk/inkos-core"; import { loadConfig, buildPipelineConfig, findProjectRoot, resolveBookId, log, logError } from "../utils.js"; +import { + formatNotifyCommandTitle, + formatNotifyFailureBody, + formatNotifyReviseBody, + resolveCliLanguage, + type CliLanguage, +} from "../localization.js"; +import { sendCommandNotification } from "../notify-helper.js"; export const reviseCommand = new Command("revise") .description("Revise a chapter based on audit issues") @@ -9,7 +17,10 @@ export const reviseCommand = new Command("revise") .option("--mode ", "Revise mode: spot-fix, polish, rewrite, rework, anti-detect", DEFAULT_REVISE_MODE) .option("--brief ", "One-off creative guidance for this revise/rewrite only") .option("--json", "Output JSON") + .option("--notify", "Send a notification to configured notify channels when the command finishes") .action(async (bookIdArg: string | undefined, chapterStr: string | undefined, opts) => { + let notifyLanguage: CliLanguage = "zh"; + let notifyBookName: string | undefined; try { const config = await loadConfig(); const root = findProjectRoot(); @@ -26,6 +37,9 @@ export const reviseCommand = new Command("revise") const state = new StateManager(root); const book = await state.loadBookConfig(bookId); + const language = resolveCliLanguage(book.language); + notifyLanguage = language; + notifyBookName = book.title ?? bookId; const pipeline = new PipelineRunner(buildPipelineConfig(config, root, { externalContext: opts.brief, revisionGate: resolveRevisionGate(book, config.writing), @@ -50,7 +64,28 @@ export const reviseCommand = new Command("revise") log(` - ${fix}`); } } + + // Unlike write commands, the pipeline sends no notification for + // reviseDraft, so --notify always sends the completion notification here. + if (opts.notify) { + await sendCommandNotification({ + title: formatNotifyCommandTitle(language, "revise", notifyBookName, true), + body: formatNotifyReviseBody(language, { + chapterNumber: result.chapterNumber, + applied: result.applied, + wordCount: result.wordCount, + fixedCount: result.fixedIssues.length, + skippedReason: result.skippedReason, + }), + }, config); + } } catch (e) { + if (opts.notify) { + await sendCommandNotification({ + title: formatNotifyCommandTitle(notifyLanguage, "revise", notifyBookName, false), + body: formatNotifyFailureBody(notifyLanguage, e), + }); + } if (opts.json) { log(JSON.stringify({ error: String(e) })); } else { diff --git a/packages/cli/src/commands/write.ts b/packages/cli/src/commands/write.ts index 386e0414..e2d9135e 100644 --- a/packages/cli/src/commands/write.ts +++ b/packages/cli/src/commands/write.ts @@ -4,7 +4,17 @@ import { readdir, stat, unlink } from "node:fs/promises"; import { join } from "node:path"; import { createInterface } from "node:readline"; import { loadConfig, buildPipelineConfig, findProjectRoot, getLegacyMigrationHint, resolveContext, resolveBookId, log, logError } from "../utils.js"; -import { formatWriteNextComplete, formatWriteNextProgress, formatWriteNextResultLines, resolveCliLanguage } from "../localization.js"; +import { + formatNotifyBatchWriteBody, + formatNotifyCommandTitle, + formatNotifyFailureBody, + formatWriteNextComplete, + formatWriteNextProgress, + formatWriteNextResultLines, + resolveCliLanguage, + type CliLanguage, +} from "../localization.js"; +import { sendCommandNotification } from "../notify-helper.js"; export const writeCommand = new Command("write") .description("Write chapters"); @@ -19,7 +29,10 @@ writeCommand .option("--context-file ", "Read guidance from file") .option("--json", "Output JSON") .option("-q, --quiet", "Suppress console output") + .option("--notify", "Send a notification to configured notify channels when the command finishes") .action(async (bookIdArg: string | undefined, opts) => { + let notifyLanguage: CliLanguage = "zh"; + let notifyBookName: string | undefined; try { const root = findProjectRoot(); const bookId = await resolveBookId(bookIdArg, root); @@ -27,6 +40,8 @@ writeCommand const state = new StateManager(root); const book = await state.loadBookConfig(bookId); const language = resolveCliLanguage(book.language); + notifyLanguage = language; + notifyBookName = book.title ?? bookId; const migrationHint = await getLegacyMigrationHint(root, bookId); if (migrationHint && !opts.json) { log(`[migration] ${migrationHint}`); @@ -79,7 +94,30 @@ writeCommand } else { log(formatWriteNextComplete(language)); } + + // The pipeline itself already sends one notification per completed + // chapter whenever notify channels are configured (runner.ts, end of + // writeNextChapter). A single-chapter run would therefore duplicate that + // exact notification — only send a command-level batch summary when this + // run wrote more than one chapter. + if (opts.notify && results.length > 1) { + await sendCommandNotification({ + title: formatNotifyCommandTitle(language, "write-next", notifyBookName, true), + body: formatNotifyBatchWriteBody(language, results.map((r) => ({ + chapterNumber: r.chapterNumber, + title: r.title, + wordCount: r.wordCount, + auditPassed: r.auditResult.passed, + }))), + }, config); + } } catch (e) { + if (opts.notify) { + await sendCommandNotification({ + title: formatNotifyCommandTitle(notifyLanguage, "write-next", notifyBookName, false), + body: formatNotifyFailureBody(notifyLanguage, e), + }); + } if (opts.json) { log(JSON.stringify({ error: String(e) })); } else { @@ -97,7 +135,10 @@ writeCommand .option("--words ", "Words per chapter (overrides book config)") .option("--brief ", "One-off creative guidance for this rewrite only") .option("--json", "Output JSON") + .option("--notify", "Send a notification to configured notify channels when the command finishes") .action(async (args: ReadonlyArray, opts) => { + let notifyLanguage: CliLanguage = "zh"; + let notifyBookName: string | undefined; try { const root = findProjectRoot(); @@ -129,6 +170,8 @@ writeCommand const state = new StateManager(root); const book = await state.loadBookConfig(bookId); + notifyLanguage = resolveCliLanguage(book.language); + notifyBookName = book.title ?? bookId; const bookDir = state.bookDir(bookId); const chaptersDir = join(bookDir, "chapters"); const restoreFrom = chapter - 1; @@ -205,7 +248,18 @@ writeCommand log(line); } } + + // Success notification intentionally skipped: the pipeline already sent + // the per-chapter notification for this exact chapter (runner.ts, end of + // writeNextChapter) — a command-level one would be a duplicate. --notify + // only adds the failure notification for this command. } catch (e) { + if (opts.notify) { + await sendCommandNotification({ + title: formatNotifyCommandTitle(notifyLanguage, "write-rewrite", notifyBookName, false), + body: formatNotifyFailureBody(notifyLanguage, e), + }); + } if (opts.json) { log(JSON.stringify({ error: String(e) })); } else { diff --git a/packages/cli/src/localization.ts b/packages/cli/src/localization.ts index 3e39697b..230f22d9 100644 --- a/packages/cli/src/localization.ts +++ b/packages/cli/src/localization.ts @@ -163,6 +163,105 @@ export function formatAutoWriteAlreadyComplete( }); } +export type NotifyCommandAction = "write-next" | "write-rewrite" | "revise" | "audit" | "auto"; + +const NOTIFY_ACTION_LABELS: Record = { + "write-next": { zh: "写作", en: "Write" }, + "write-rewrite": { zh: "重写", en: "Rewrite" }, + revise: { zh: "修订", en: "Revise" }, + audit: { zh: "审计", en: "Audit" }, + auto: { zh: "自动连写", en: "Auto-write" }, +}; + +export function formatNotifyCommandTitle( + language: CliLanguage, + action: NotifyCommandAction, + bookName: string | undefined, + succeeded: boolean, +): string { + const label = localize(language, NOTIFY_ACTION_LABELS[action]); + const book = bookName === undefined + ? "" + : localize(language, { zh: `《${bookName}》`, en: `: ${bookName}` }); + return succeeded + ? localize(language, { zh: `✅ ${label}完成${book}`, en: `✅ ${label} complete${book}` }) + : localize(language, { zh: `❌ ${label}失败${book}`, en: `❌ ${label} failed${book}` }); +} + +export function formatNotifyBatchWriteBody( + language: CliLanguage, + chapters: ReadonlyArray<{ + readonly chapterNumber: number; + readonly title: string; + readonly wordCount: number; + readonly auditPassed: boolean; + }>, +): string { + const first = chapters[0]!; + const last = chapters[chapters.length - 1]!; + const lines = [ + localize(language, { + zh: `本次完成 ${chapters.length} 章(第${first.chapterNumber}章到第${last.chapterNumber}章)`, + en: `${chapters.length} chapter(s) written (chapter ${first.chapterNumber} to ${last.chapterNumber})`, + }), + ...chapters.map((ch) => { + const lengthLabel = formatLengthCount(ch.wordCount, resolveLengthCountingMode(language)); + return localize(language, { + zh: `第${ch.chapterNumber}章 ${ch.title} | ${lengthLabel} | ${ch.auditPassed ? "审计通过" : "需复核"}`, + en: `Chapter ${ch.chapterNumber} ${ch.title} | ${lengthLabel} | ${ch.auditPassed ? "audit passed" : "needs review"}`, + }); + }), + ]; + return lines.join("\n"); +} + +export function formatNotifyAuditBody( + language: CliLanguage, + result: { + readonly chapterNumber: number; + readonly passed: boolean; + readonly issueCount: number; + readonly summary: string; + }, +): string { + const head = localize(language, { + zh: `第${result.chapterNumber}章审计${result.passed ? "通过" : "未通过"}(${result.issueCount} 个问题)`, + en: `Chapter ${result.chapterNumber} audit ${result.passed ? "passed" : "failed"} (${result.issueCount} issue(s))`, + }); + return result.summary ? `${head}\n${result.summary}` : head; +} + +export function formatNotifyReviseBody( + language: CliLanguage, + result: { + readonly chapterNumber: number; + readonly applied: boolean; + readonly wordCount: number; + readonly fixedCount: number; + readonly skippedReason?: string; + }, +): string { + if (!result.applied) { + return localize(language, { + zh: `第${result.chapterNumber}章保留原稿${result.skippedReason ? `:${result.skippedReason}` : ""}`, + en: `Chapter ${result.chapterNumber} kept original draft${result.skippedReason ? `: ${result.skippedReason}` : ""}`, + }); + } + const lengthLabel = formatLengthCount(result.wordCount, resolveLengthCountingMode(language)); + return localize(language, { + zh: `第${result.chapterNumber}章已修订 | ${lengthLabel} | 修复 ${result.fixedCount} 个问题`, + en: `Chapter ${result.chapterNumber} revised | ${lengthLabel} | ${result.fixedCount} issue(s) fixed`, + }); +} + +export function formatNotifyFailureBody(language: CliLanguage, error: unknown): string { + const detail = error instanceof Error ? error.message : String(error); + return localize(language, { + zh: `错误:${detail}`, + en: `Error: ${detail}`, + }); +} + export function formatImportChaptersDiscovery( language: CliLanguage, chapterCount: number, diff --git a/packages/cli/src/notify-helper.ts b/packages/cli/src/notify-helper.ts new file mode 100644 index 00000000..eb7ca3b5 --- /dev/null +++ b/packages/cli/src/notify-helper.ts @@ -0,0 +1,34 @@ +import { dispatchNotification, type ProjectConfig } from "@actalk/inkos-core"; +import { loadConfig, logError } from "./utils.js"; + +export interface CliNotifyMessage { + readonly title: string; + readonly body: string; +} + +/** + * Send a command-level notification (--notify) to the project's configured + * notify channels. Callers guard with the --notify flag before invoking so + * message strings are only assembled when a notification will be attempted. + * + * - Uses the caller's already-loaded project config when provided; otherwise + * loads it here (failure paths may fail before the command loaded it). + * - Never throws: notification delivery must not change the command's exit + * code, so every failure is written to stderr as a warning instead. + */ +export async function sendCommandNotification( + message: CliNotifyMessage, + config?: ProjectConfig, +): Promise { + try { + const resolved = config ?? (await loadConfig()); + const channels = resolved.notify ?? []; + if (channels.length === 0) { + logError("--notify: no notify channels configured in project config (notify: []), skipping notification"); + return; + } + await dispatchNotification(channels, message); + } catch (e) { + logError(`--notify: failed to send notification: ${e}`); + } +}