Files

384 lines
11 KiB
TypeScript

/** pi-ntfy configuration loading. */
import {
existsSync,
mkdirSync,
readFileSync,
statSync,
writeFileSync,
} from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import type { ChannelConfig, NotifyConfig, NtfyChannel } from "./types.ts";
const CONFIG_PATH = join(homedir(), ".pi", "agent", "pi-ntfy.json");
export function configPath(): string {
return CONFIG_PATH;
}
export function logPath(): string {
return join(dirname(configPath()), "pi-ntfy.log");
}
export const KNOWN_TYPES = ["ntfy"] as const;
export type KnownChannelType = (typeof KNOWN_TYPES)[number];
/** Whole-value reference: "$NTFY_TOKEN" */
const ENV_REF = /^\$([A-Za-z_][A-Za-z0-9_]*)$/;
/** Embedded reference: "Bearer ${MY_TOKEN}" */
const ENV_INTERPOLATION = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
export type ConfigValue =
| string
| number
| boolean
| null
| ConfigValue[]
| { [key: string]: ConfigValue };
/** Resolve environment references in string values, recursively. */
function resolveEnv(value: ConfigValue): ConfigValue {
if (typeof value === "string") {
const match = ENV_REF.exec(value);
if (match) return process.env[match[1]] ?? "";
return value.replace(
ENV_INTERPOLATION,
(_full, name: string) => process.env[name] ?? "",
);
}
if (Array.isArray(value)) return value.map(resolveEnv);
if (value && typeof value === "object") {
const out: { [key: string]: ConfigValue } = {};
for (const [key, item] of Object.entries(value)) {
out[key] = resolveEnv(item);
}
return out;
}
return value;
}
function bool(value: unknown, fallback: boolean): boolean {
return typeof value === "boolean" ? value : fallback;
}
function num(
value: unknown,
fallback: number,
min: number,
max: number,
): number {
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
return Math.min(max, Math.max(min, value));
}
function str(value: unknown, fallback: string): string {
return typeof value === "string" && value.length > 0 ? value : fallback;
}
function strArray(value: unknown, fallback: string[]): string[] {
if (!Array.isArray(value)) return fallback;
const items = value.filter(
(v): v is string => typeof v === "string" && v.length > 0,
);
return items.length > 0 ? items : fallback;
}
export function defaultConfig(path: string): NotifyConfig {
return {
enabled: true,
// Only the interactive TUI by default: pi subagents run `pi --mode json -p`,
// and pushing for every one of those would be pure noise.
modes: ["tui"],
timeoutMs: 5000,
shutdownTimeoutMs: 2000,
dedupeMs: 3000,
minDurationSec: 0,
maxTextChars: 2000,
titleTemplate: "[{{project}}] {{status}} · {{session}}",
template:
"项目: {{project}}\n会话: {{session}}\n任务: {{task}}\n状态: {{status}}\n原因: {{reason}}\n摘要: {{summary}}\n耗时: {{duration}}\n模型: {{model}}\n主机: {{host}}\n时间: {{date}} {{time}}\n工具: {{tools}}",
events: { idle: true, interrupted: true, needInput: true, exit: false },
// Question-shaped tools block inside their own execute() waiting on the UI,
// so agent_settled never fires while they wait — they need their own event.
// ask_user_question: @juicesharp/rpiv-ask-user-question
// plan_mode_question: @narumitw/pi-plan-mode
needInputTools: ["ask_user_question", "plan_mode_question"],
debug: false,
channels: [],
warnings: [],
path,
exists: false,
};
}
function normalizeChannels(raw: unknown, warnings: string[]): ChannelConfig[] {
if (raw === undefined) return [];
if (!Array.isArray(raw)) {
warnings.push('"channels" 必须是数组,已忽略');
return [];
}
const channels: ChannelConfig[] = [];
raw.forEach((item, index) => {
if (!item || typeof item !== "object") {
warnings.push(`channels[${index}] 不是对象,已忽略`);
return;
}
const channel = resolveEnv(item) as NtfyChannel;
if (channel.type !== "ntfy") {
warnings.push(`channels[${index}] 不是 ntfy 渠道,已忽略`);
return;
}
channels.push(channel);
});
return channels;
}
let cache: { key: string; config: NotifyConfig } | undefined;
/** Load the config, reusing the previous parse while the file is unchanged. */
export function loadConfig(): NotifyConfig {
const path = configPath();
if (!existsSync(path)) {
cache = undefined;
return defaultConfig(path);
}
let key: string;
try {
const stat = statSync(path);
key = `${path}:${stat.mtimeMs}:${stat.size}`;
} catch {
key = `${path}:unstatable`;
}
if (cache && cache.key === key) return cache.config;
const config = defaultConfig(path);
config.exists = true;
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown>;
} catch (error) {
config.enabled = false;
config.warnings.push(
`config.json 解析失败:${error instanceof Error ? error.message : String(error)}`,
);
cache = { key, config };
return config;
}
config.enabled = bool(parsed.enabled, config.enabled);
config.modes = strArray(parsed.modes, config.modes);
config.timeoutMs = num(parsed.timeoutMs, config.timeoutMs, 200, 60_000);
config.shutdownTimeoutMs = num(
parsed.shutdownTimeoutMs,
config.shutdownTimeoutMs,
200,
10_000,
);
config.dedupeMs = num(parsed.dedupeMs, config.dedupeMs, 0, 600_000);
config.minDurationSec = num(
parsed.minDurationSec,
config.minDurationSec,
0,
86_400,
);
config.maxTextChars = num(parsed.maxTextChars, config.maxTextChars, 20, 4000);
config.titleTemplate = str(parsed.titleTemplate, config.titleTemplate);
config.template = str(parsed.template, config.template);
config.needInputTools = strArray(parsed.needInputTools, config.needInputTools);
config.debug = bool(parsed.debug, config.debug);
if (parsed.events && typeof parsed.events === "object") {
const events = parsed.events as Record<string, unknown>;
config.events = {
idle: bool(events.idle, config.events.idle),
interrupted: bool(events.interrupted, config.events.interrupted),
needInput: bool(events.needInput, config.events.needInput),
exit: bool(events.exit, config.events.exit),
};
}
config.channels = normalizeChannels(parsed.channels, config.warnings);
if (config.enabled && config.channels.length === 0) {
config.warnings.push("没有配置任何推送渠道");
} else if (
config.enabled &&
config.channels.every((c) => c.enabled === false)
) {
config.warnings.push("所有渠道都是 enabled:false,填好 key 后记得打开");
}
cache = { key, config };
return config;
}
/** Drop the mtime cache — used by `/push` so status output is always fresh. */
export function invalidateConfigCache(): void {
cache = undefined;
}
/** Sensitive keys — masked in `/push get` output. */
export const SECRET_KEYS = new Set(["token"]);
/** Per-type fields users can set via `/push set`. */
export const CHANNEL_SETTABLE_FIELDS: Record<string, string[]> = {
ntfy: [
"enabled",
"name",
"topic",
"server",
"token",
"priority",
"tags",
"clickUrl",
"icon",
"email",
"timeoutMs",
],
};
export interface RawConfigFile {
enabled?: boolean;
modes?: string[];
timeoutMs?: number;
shutdownTimeoutMs?: number;
dedupeMs?: number;
minDurationSec?: number;
maxTextChars?: number;
debug?: boolean;
titleTemplate?: string;
template?: string;
events?: Record<string, boolean>;
needInputTools?: string[];
channels?: Array<Record<string, unknown>>;
[key: string]: unknown;
}
/** Read config.json without env expansion — for in-place edits. */
export function loadRawConfig(): {
path: string;
exists: boolean;
data: RawConfigFile;
} {
const path = configPath();
if (!existsSync(path)) {
return {
path,
exists: false,
data: {
enabled: true,
modes: ["tui"],
events: { idle: true, interrupted: true, needInput: true, exit: false },
channels: [],
},
};
}
try {
const data = JSON.parse(readFileSync(path, "utf-8")) as RawConfigFile;
if (!Array.isArray(data.channels)) data.channels = [];
return { path, exists: true, data };
} catch (error) {
throw new Error(
`config.json 解析失败:${error instanceof Error ? error.message : String(error)}`,
);
}
}
/** Atomically-ish write config.json and drop the mtime cache. */
export function saveRawConfig(data: RawConfigFile): string {
const path = configPath();
const dir = dirname(path);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
const body = `${JSON.stringify(data, null, "\t")}\n`;
writeFileSync(path, body, "utf-8");
invalidateConfigCache();
return path;
}
export function maskSecret(key: string, value: unknown): string {
if (value == null) return "";
const text = String(value);
if (
!SECRET_KEYS.has(key) &&
!/token|secret|key|password|authorization/i.test(key)
) {
return text;
}
if (text.length === 0) return "(空)";
if (text.startsWith("$")) return text; // env ref — safe to show
if (text.length <= 4) return "****";
return `${"*".repeat(Math.min(8, text.length - 4))}${text.slice(-4)}`;
}
/**
* Resolve a channel selector to an index in raw.channels.
* Accepts: type (`ntfy`), name, `type#N` (1-based among that type), or 1-based index.
*/
export function resolveChannelIndex(
channels: Array<Record<string, unknown>>,
selector: string,
): number {
const sel = selector.trim();
if (!sel) throw new Error("请指定渠道(type / name / type#N / 序号)");
// pure number → 1-based index
if (/^\d+$/.test(sel)) {
const idx = Number(sel) - 1;
if (idx < 0 || idx >= channels.length) {
throw new Error(`渠道序号 ${sel} 超出范围 1..${channels.length || 0}`);
}
return idx;
}
// type#N
const hash = /^(?<type>[A-Za-z][A-Za-z0-9_-]*)#(?<n>\d+)$/.exec(sel);
if (hash?.groups) {
const type = hash.groups.type.toLowerCase();
const n = Number(hash.groups.n);
const matches = channels
.map((c, i) => ({ c, i }))
.filter(({ c }) => String(c.type ?? "").toLowerCase() === type);
if (matches.length === 0) throw new Error(`没有 type=${type} 的渠道`);
if (n < 1 || n > matches.length) {
throw new Error(`${type} 只有 ${matches.length} 个,无法选 #${n}`);
}
return matches[n - 1].i;
}
const lower = sel.toLowerCase();
// exact name (case-insensitive)
const byName = channels.findIndex(
(c) => typeof c.name === "string" && c.name.toLowerCase() === lower,
);
if (byName >= 0) return byName;
// unique type
const byType = channels
.map((c, i) => ({ c, i }))
.filter(({ c }) => String(c.type ?? "").toLowerCase() === lower);
if (byType.length === 1) return byType[0].i;
if (byType.length > 1) {
throw new Error(
`type=${sel}${byType.length} 个,请用 ${sel}#1 或名称区分`,
);
}
throw new Error(`找不到渠道 "${sel}"(可用 /push list 查看)`);
}
export function formatChannelLabel(
channel: Record<string, unknown>,
index: number,
): string {
const type = String(channel.type ?? "?");
const name =
typeof channel.name === "string" && channel.name.trim()
? channel.name.trim()
: "";
const enabled = channel.enabled === false ? "关" : "开";
const base = name ? `${name} (${type}#${index + 1})` : `${type}#${index + 1}`;
return `${base} [${enabled}]`;
}