From 3b156abc40f175d30947d5595d0d19af638bd3c3 Mon Sep 17 00:00:00 2001 From: ZzzHe Date: Tue, 17 Mar 2026 15:15:03 +0800 Subject: [PATCH 1/9] Add ServerChan notification functionality --- packages/core/src/notify/serverchan.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 packages/core/src/notify/serverchan.ts diff --git a/packages/core/src/notify/serverchan.ts b/packages/core/src/notify/serverchan.ts new file mode 100644 index 00000000..8d34d073 --- /dev/null +++ b/packages/core/src/notify/serverchan.ts @@ -0,0 +1,20 @@ +export interface ServerChanConfig { + readonly sendUrl: string; +} + +export async function sendServerChan( + config: ServerChanConfig, + title: string, + content: string, +): Promise { + const url = new URL(config.sendUrl); + url.searchParams.set("title", title); + url.searchParams.set("desp", content); + + const response = await fetch(url, { method: "GET" }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`ServerChan send failed: ${response.status} ${body}`); + } +} From af13db104c84df76732fdea96c11a452e53dbbef Mon Sep 17 00:00:00 2001 From: ZzzHe Date: Tue, 17 Mar 2026 15:15:34 +0800 Subject: [PATCH 2/9] Add BarkConfig interface and sendBark function Implement BarkConfig interface and sendBark function for sending notifications. --- packages/core/src/notify/bark.ts | 40 ++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 packages/core/src/notify/bark.ts diff --git a/packages/core/src/notify/bark.ts b/packages/core/src/notify/bark.ts new file mode 100644 index 00000000..5b6d160c --- /dev/null +++ b/packages/core/src/notify/bark.ts @@ -0,0 +1,40 @@ +export interface BarkConfig { + readonly serverUrl: string; + readonly deviceKey: string; + readonly group?: string; + readonly url?: string; + readonly sound?: string; + readonly icon?: string; + readonly level?: "active" | "timeSensitive" | "passive" | "critical"; +} + +export async function sendBark( + config: BarkConfig, + title: string, + body: string, +): Promise { + const serverUrl = config.serverUrl.replace(/\/+$/, ""); + + const payload: Record = { + device_key: config.deviceKey, + title, + body, + }; + + if (config.group) payload.group = config.group; + if (config.url) payload.url = config.url; + if (config.sound) payload.sound = config.sound; + if (config.icon) payload.icon = config.icon; + if (config.level) payload.level = config.level; + + const response = await fetch(`${serverUrl}/push`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Bark send failed: ${response.status} ${text}`); + } +} From f06d0c01e01d418416a533d26e79ab5574432b56 Mon Sep 17 00:00:00 2001 From: ZzzHe Date: Tue, 17 Mar 2026 15:17:20 +0800 Subject: [PATCH 3/9] =?UTF-8?q?Add=20new=20notification=20types=20for=20Se?= =?UTF-8?q?rver=E9=85=B1=20and=20Bark?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/core/src/models/project.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/core/src/models/project.ts b/packages/core/src/models/project.ts index 06df00b4..7ca6df9d 100644 --- a/packages/core/src/models/project.ts +++ b/packages/core/src/models/project.ts @@ -20,20 +20,42 @@ export const NotifyChannelSchema = z.discriminatedUnion("type", [ botToken: z.string().min(1), chatId: z.string().min(1), }), + // 企业微信 z.object({ type: z.literal("wechat-work"), webhookUrl: z.string().url(), }), + // 飞书 z.object({ type: z.literal("feishu"), webhookUrl: z.string().url(), }), + // webhook z.object({ type: z.literal("webhook"), url: z.string().url(), secret: z.string().optional(), events: z.array(z.string()).default([]), }), + // NEW: Server酱 + z.object({ + type: z.literal("serverchan"), + sendUrl: z.string().url(), + }), + + // NEW: Bark + z.object({ + type: z.literal("bark"), + serverUrl: z.string().url().default("https://api.day.app"), + deviceKey: z.string().min(1), + group: z.string().optional(), + url: z.string().optional(), + sound: z.string().optional(), + icon: z.string().url().optional(), + level: z + .enum(["active", "timeSensitive", "passive", "critical"]) + .optional(), + }), ]); export type NotifyChannel = z.infer; From 17cfddec5dd5c2f302bf284b75dc02fe050903db Mon Sep 17 00:00:00 2001 From: ZzzHe Date: Tue, 17 Mar 2026 15:19:32 +0800 Subject: [PATCH 4/9] Add support for ServerChan and Bark notifications --- packages/core/src/notify/dispatcher.ts | 43 +++++++++++++++++++++----- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/packages/core/src/notify/dispatcher.ts b/packages/core/src/notify/dispatcher.ts index d5e05dd4..e349a4a9 100644 --- a/packages/core/src/notify/dispatcher.ts +++ b/packages/core/src/notify/dispatcher.ts @@ -3,6 +3,9 @@ import { sendTelegram } from "./telegram.js"; import { sendFeishu } from "./feishu.js"; import { sendWechatWork } from "./wechat-work.js"; import { sendWebhook, type WebhookPayload } from "./webhook.js"; +import { sendServerChan } from "./serverchan.js"; +import { sendBark } from "./bark.js"; + export interface NotifyMessage { readonly title: string; @@ -24,6 +27,7 @@ export async function dispatchNotification( fullText, ); break; + case "feishu": await sendFeishu( { webhookUrl: channel.webhookUrl }, @@ -31,37 +35,60 @@ export async function dispatchNotification( message.body, ); break; + case "wechat-work": await sendWechatWork( { webhookUrl: channel.webhookUrl }, fullText, ); break; + case "webhook": - // Webhook channels are handled by dispatchWebhookEvent for structured events. - // For generic text notifications, send as a pipeline-complete event. await sendWebhook( { url: channel.url, secret: channel.secret, events: channel.events }, { event: "pipeline-complete", bookId: "", timestamp: new Date().toISOString(), - data: { title: message.title, body: message.body }, + data: { + title: message.title, + body: message.body, + }, + }, + ); + break; + + case "serverchan": + await sendServerChan( + { sendUrl: channel.sendUrl }, + message.title, + message.body, + ); + break; + + case "bark": + await sendBark( + { + serverUrl: channel.serverUrl, + deviceKey: channel.deviceKey, + group: channel.group, + url: channel.url, + sound: channel.sound, + icon: channel.icon, + level: channel.level, }, + message.title, + message.body, ); break; } } catch (e) { - // Log but don't throw — notification failure shouldn't block pipeline - process.stderr.write( - `[notify] ${channel.type} failed: ${e}\n`, - ); + process.stderr.write(`[notify] ${channel.type} failed: ${e}\n`); } }); await Promise.all(tasks); } - /** Dispatch a structured webhook event to all webhook channels. */ export async function dispatchWebhookEvent( channels: ReadonlyArray, From 256e7042fce074d132355bc38131fd9c43994f00 Mon Sep 17 00:00:00 2001 From: ZzzHe Date: Tue, 17 Mar 2026 15:26:07 +0800 Subject: [PATCH 5/9] Update utils.ts --- packages/cli/src/utils.ts | 65 ++++++++++++++++++++++++++++++++++----- 1 file changed, 57 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts index 2ddc9bdb..139d0d22 100644 --- a/packages/cli/src/utils.ts +++ b/packages/cli/src/utils.ts @@ -2,7 +2,13 @@ import { readFile, access } from "node:fs/promises"; import { homedir } from "node:os"; import { join, resolve } from "node:path"; import { config as loadEnv } from "dotenv"; -import { createLLMClient, StateManager, type ProjectConfig, ProjectConfigSchema, type PipelineConfig } from "@actalk/inkos-core"; +import { + createLLMClient, + StateManager, + type ProjectConfig, + ProjectConfigSchema, + type PipelineConfig, +} from "@actalk/inkos-core"; export const GLOBAL_CONFIG_DIR = join(homedir(), ".inkos"); export const GLOBAL_ENV_PATH = join(GLOBAL_CONFIG_DIR, ".env"); @@ -12,9 +18,11 @@ export async function resolveContext(opts: { readonly contextFile?: string; }): Promise { if (opts.context) return opts.context; + if (opts.contextFile) { return readFile(resolve(opts.contextFile), "utf-8"); } + // Read from stdin if piped (non-TTY) if (!process.stdin.isTTY) { const chunks: Buffer[] = []; @@ -24,6 +32,7 @@ export async function resolveContext(opts: { const text = Buffer.concat(chunks).toString("utf-8").trim(); if (text.length > 0) return text; } + return undefined; } @@ -56,30 +65,68 @@ export async function loadConfig(): Promise { try { config = JSON.parse(raw); } catch { - throw new Error(`inkos.json in ${root} is not valid JSON. Check the file for syntax errors.`); + throw new Error( + `inkos.json in ${root} is not valid JSON.\nCheck the file for syntax errors.`, + ); } // .env overrides inkos.json for LLM settings const env = process.env; const llm = (config.llm ?? {}) as Record; + if (env.INKOS_LLM_PROVIDER) llm.provider = env.INKOS_LLM_PROVIDER; if (env.INKOS_LLM_BASE_URL) llm.baseUrl = env.INKOS_LLM_BASE_URL; if (env.INKOS_LLM_MODEL) llm.model = env.INKOS_LLM_MODEL; - if (env.INKOS_LLM_TEMPERATURE) llm.temperature = parseFloat(env.INKOS_LLM_TEMPERATURE); - if (env.INKOS_LLM_MAX_TOKENS) llm.maxTokens = parseInt(env.INKOS_LLM_MAX_TOKENS, 10); - if (env.INKOS_LLM_THINKING_BUDGET) llm.thinkingBudget = parseInt(env.INKOS_LLM_THINKING_BUDGET, 10); + if (env.INKOS_LLM_TEMPERATURE) { + llm.temperature = parseFloat(env.INKOS_LLM_TEMPERATURE); + } + if (env.INKOS_LLM_MAX_TOKENS) { + llm.maxTokens = parseInt(env.INKOS_LLM_MAX_TOKENS, 10); + } + if (env.INKOS_LLM_THINKING_BUDGET) { + llm.thinkingBudget = parseInt(env.INKOS_LLM_THINKING_BUDGET, 10); + } if (env.INKOS_LLM_API_FORMAT) llm.apiFormat = env.INKOS_LLM_API_FORMAT; + config.llm = llm; // API key ONLY from env — never stored in inkos.json const apiKey = env.INKOS_LLM_API_KEY; if (!apiKey) { throw new Error( - "INKOS_LLM_API_KEY not set. Run 'inkos config set-global' or add it to project .env file.", + "INKOS_LLM_API_KEY not set.\nRun 'inkos config set-global' or add it to project .env file.", ); } llm.apiKey = apiKey; + // Build notify channels from env and append them to inkos.json notify[] + const envNotify: unknown[] = []; + + if (env.INKOS_SERVERCHAN_SEND_URL) { + envNotify.push({ + type: "serverchan", + sendUrl: env.INKOS_SERVERCHAN_SEND_URL, + }); + } + + if (env.INKOS_BARK_DEVICE_KEY) { + envNotify.push({ + type: "bark", + serverUrl: env.INKOS_BARK_SERVER_URL ?? "https://api.day.app", + deviceKey: env.INKOS_BARK_DEVICE_KEY, + group: env.INKOS_BARK_GROUP || "InkOS", + level: env.INKOS_BARK_LEVEL ?? "active", + sound: env.INKOS_BARK_SOUND || undefined, + icon: env.INKOS_BARK_ICON || undefined, + url: env.INKOS_BARK_URL || undefined, + }); + } + + config.notify = [ + ...(((config.notify ?? []) as unknown[]) || []), + ...envNotify, + ]; + return ProjectConfigSchema.parse(config); } @@ -90,7 +137,7 @@ export function createClient(config: ProjectConfig) { export function buildPipelineConfig( config: ProjectConfig, root: string, - extra?: Partial>, + extra?: Partial, ): PipelineConfig { return { client: createLLMClient(config.llm), @@ -135,12 +182,14 @@ export async function resolveBookId( if (books.length === 0) { throw new Error( - "No books found. Create one first:\n inkos book create --title '...' --genre xuanhuan", + "No books found.\nCreate one first:\n inkos book create --title '...' --genre xuanhuan", ); } + if (books.length === 1) { return books[0]!; } + throw new Error( `Multiple books found: ${books.join(", ")}\nPlease specify a book-id.`, ); From 2f1332380c34adddf896863bc227a829d0520205 Mon Sep 17 00:00:00 2001 From: ZzzHe Date: Tue, 17 Mar 2026 15:31:58 +0800 Subject: [PATCH 6/9] Refactor project schema and add new notification types --- packages/core/src/models/project.ts | 78 ++++++++++++++++------------- 1 file changed, 42 insertions(+), 36 deletions(-) diff --git a/packages/core/src/models/project.ts b/packages/core/src/models/project.ts index 7ca6df9d..03b657ca 100644 --- a/packages/core/src/models/project.ts +++ b/packages/core/src/models/project.ts @@ -20,36 +20,35 @@ export const NotifyChannelSchema = z.discriminatedUnion("type", [ botToken: z.string().min(1), chatId: z.string().min(1), }), - // 企业微信 + z.object({ type: z.literal("wechat-work"), webhookUrl: z.string().url(), }), - // 飞书 + z.object({ type: z.literal("feishu"), webhookUrl: z.string().url(), }), - // webhook + z.object({ type: z.literal("webhook"), url: z.string().url(), secret: z.string().optional(), events: z.array(z.string()).default([]), }), - // NEW: Server酱 + z.object({ type: z.literal("serverchan"), sendUrl: z.string().url(), }), - // NEW: Bark z.object({ type: z.literal("bark"), serverUrl: z.string().url().default("https://api.day.app"), deviceKey: z.string().min(1), group: z.string().optional(), - url: z.string().optional(), + url: z.string().min(1).optional(), sound: z.string().optional(), icon: z.string().url().optional(), level: z @@ -99,37 +98,44 @@ export const ProjectConfigSchema = z.object({ notify: z.array(NotifyChannelSchema).default([]), detection: DetectionConfigSchema.optional(), modelOverrides: z.record(z.string(), ModelOverrideValueSchema).optional(), - daemon: z.object({ - schedule: z.object({ - radarCron: z.string().default("0 */6 * * *"), - writeCron: z.string().default("*/15 * * * *"), - }), - maxConcurrentBooks: z.number().int().min(1).default(3), - chaptersPerCycle: z.number().int().min(1).max(20).default(1), - retryDelayMs: z.number().int().min(0).default(30_000), - cooldownAfterChapterMs: z.number().int().min(0).default(10_000), - maxChaptersPerDay: z.number().int().min(1).default(50), - qualityGates: QualityGatesSchema.default({ - maxAuditRetries: 2, - pauseAfterConsecutiveFailures: 3, - retryTemperatureStep: 0.1, + daemon: z + .object({ + schedule: z + .object({ + radarCron: z.string().default("0 */6 * * *"), + writeCron: z.string().default("*/15 * * * *"), + }) + .default({ + radarCron: "0 */6 * * *", + writeCron: "*/15 * * * *", + }), + maxConcurrentBooks: z.number().int().min(1).default(3), + chaptersPerCycle: z.number().int().min(1).max(20).default(1), + retryDelayMs: z.number().int().min(0).default(30_000), + cooldownAfterChapterMs: z.number().int().min(0).default(10_000), + maxChaptersPerDay: z.number().int().min(1).default(50), + qualityGates: QualityGatesSchema.default({ + maxAuditRetries: 2, + pauseAfterConsecutiveFailures: 3, + retryTemperatureStep: 0.1, + }), + }) + .default({ + schedule: { + radarCron: "0 */6 * * *", + writeCron: "*/15 * * * *", + }, + maxConcurrentBooks: 3, + chaptersPerCycle: 1, + retryDelayMs: 30_000, + cooldownAfterChapterMs: 10_000, + maxChaptersPerDay: 50, + qualityGates: { + maxAuditRetries: 2, + pauseAfterConsecutiveFailures: 3, + retryTemperatureStep: 0.1, + }, }), - }).default({ - schedule: { - radarCron: "0 */6 * * *", - writeCron: "*/15 * * * *", - }, - maxConcurrentBooks: 3, - chaptersPerCycle: 1, - retryDelayMs: 30_000, - cooldownAfterChapterMs: 10_000, - maxChaptersPerDay: 50, - qualityGates: { - maxAuditRetries: 2, - pauseAfterConsecutiveFailures: 3, - retryTemperatureStep: 0.1, - }, - }), }); export type ProjectConfig = z.infer; From af8ad2803f09a3bfe308b4662778388974a8735d Mon Sep 17 00:00:00 2001 From: ZzzHe Date: Tue, 17 Mar 2026 15:32:26 +0800 Subject: [PATCH 7/9] Update dispatcher.ts --- packages/core/src/notify/dispatcher.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/core/src/notify/dispatcher.ts b/packages/core/src/notify/dispatcher.ts index e349a4a9..d22700c5 100644 --- a/packages/core/src/notify/dispatcher.ts +++ b/packages/core/src/notify/dispatcher.ts @@ -6,7 +6,6 @@ import { sendWebhook, type WebhookPayload } from "./webhook.js"; import { sendServerChan } from "./serverchan.js"; import { sendBark } from "./bark.js"; - export interface NotifyMessage { readonly title: string; readonly body: string; @@ -44,8 +43,14 @@ export async function dispatchNotification( break; case "webhook": + // Webhook channels are handled by dispatchWebhookEvent for structured events. + // For generic text notifications, send as a pipeline-complete event. await sendWebhook( - { url: channel.url, secret: channel.secret, events: channel.events }, + { + url: channel.url, + secret: channel.secret, + events: channel.events, + }, { event: "pipeline-complete", bookId: "", @@ -83,12 +88,14 @@ export async function dispatchNotification( break; } } catch (e) { + // Log but don't throw — notification failure shouldn't block pipeline process.stderr.write(`[notify] ${channel.type} failed: ${e}\n`); } }); await Promise.all(tasks); } + /** Dispatch a structured webhook event to all webhook channels. */ export async function dispatchWebhookEvent( channels: ReadonlyArray, From 7d69788e26fc6296471fa86d88f855fa0861fc10 Mon Sep 17 00:00:00 2001 From: ZzzHe Date: Tue, 17 Mar 2026 15:33:00 +0800 Subject: [PATCH 8/9] Refactor sendServerChan to use POST method --- packages/core/src/notify/serverchan.ts | 38 ++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/packages/core/src/notify/serverchan.ts b/packages/core/src/notify/serverchan.ts index 8d34d073..78169117 100644 --- a/packages/core/src/notify/serverchan.ts +++ b/packages/core/src/notify/serverchan.ts @@ -2,19 +2,45 @@ export interface ServerChanConfig { readonly sendUrl: string; } +interface ServerChanResponse { + readonly code: number; + readonly message: string; + readonly data?: unknown; +} + export async function sendServerChan( config: ServerChanConfig, title: string, content: string, ): Promise { - const url = new URL(config.sendUrl); - url.searchParams.set("title", title); - url.searchParams.set("desp", content); + const response = await fetch(config.sendUrl, { + method: "POST", + headers: { + "Content-Type": "application/json; charset=utf-8", + }, + body: JSON.stringify({ + title, + desp: content, + }), + }); - const response = await fetch(url, { method: "GET" }); + const text = await response.text(); if (!response.ok) { - const body = await response.text(); - throw new Error(`ServerChan send failed: ${response.status} ${body}`); + throw new Error(`ServerChan send failed: ${response.status} ${text}`); + } + + try { + const result = JSON.parse(text) as ServerChanResponse; + if (typeof result.code === "number" && result.code !== 0) { + throw new Error( + `ServerChan send failed: ${result.code} ${result.message}`, + ); + } + } catch (error) { + if (error instanceof Error && error.message.startsWith("ServerChan send failed:")) { + throw error; + } + // 部分兼容网关可能不返回标准 JSON,这里 HTTP 200 就视为成功 } } From f3261d5c4f8431d54ea09e2b9f09c09b2e104506 Mon Sep 17 00:00:00 2001 From: ZzzHe Date: Tue, 17 Mar 2026 15:33:23 +0800 Subject: [PATCH 9/9] Update Content-Type header to include charset --- packages/core/src/notify/bark.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/core/src/notify/bark.ts b/packages/core/src/notify/bark.ts index 5b6d160c..d155a65d 100644 --- a/packages/core/src/notify/bark.ts +++ b/packages/core/src/notify/bark.ts @@ -29,12 +29,15 @@ export async function sendBark( const response = await fetch(`${serverUrl}/push`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json; charset=utf-8", + }, body: JSON.stringify(payload), }); + const text = await response.text(); + if (!response.ok) { - const text = await response.text(); throw new Error(`Bark send failed: ${response.status} ${text}`); } }