feat(cli): --notify flag on write/rewrite/auto/revise/audit (#308)

- 命令成功/失败后发送完成通知;通知发送失败只警告不影响退出码
- 与 runner 既有单章通知去重:单章成功场景跳过命令层通知,
  多章连写发批量摘要,audit/revise/所有失败场景由命令层负责
This commit is contained in:
Ma
2026-07-07 14:07:24 +08:00
parent 2be11cdb05
commit ed41b860de
7 changed files with 642 additions and 2 deletions
@@ -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);
});
});
});
+38 -1
View File
@@ -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 {
+33
View File
@@ -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] <target-chapter>")
@@ -16,7 +21,10 @@ export const autoCommand = new Command("auto")
.option("--words <n>", "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<string>, 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 {
+35
View File
@@ -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 <mode>", "Revise mode: spot-fix, polish, rewrite, rework, anti-detect", DEFAULT_REVISE_MODE)
.option("--brief <text>", "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 {
+55 -1
View File
@@ -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 <path>", "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 <n>", "Words per chapter (overrides book config)")
.option("--brief <text>", "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<string>, 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 {
+99
View File
@@ -163,6 +163,105 @@ export function formatAutoWriteAlreadyComplete(
});
}
export type NotifyCommandAction = "write-next" | "write-rewrite" | "revise" | "audit" | "auto";
const NOTIFY_ACTION_LABELS: Record<NotifyCommandAction, { zh: string; en: string }> = {
"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,
+34
View File
@@ -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<void> {
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}`);
}
}