mirror of
https://github.com/Narcooo/inkos.git
synced 2026-08-28 23:02:03 +08:00
feat(cli): --notify flag on write/rewrite/auto/revise/audit (#308)
- 命令成功/失败后发送完成通知;通知发送失败只警告不影响退出码 - 与 runner 既有单章通知去重:单章成功场景跳过命令层通知, 多章连写发批量摘要,audit/revise/所有失败场景由命令层负责
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user