feat: add pi-ntfy NTFY notification plugin

This commit is contained in:
杨豪
2026-09-17 09:50:46 +08:00
commit 7dc17e1a9f
16 changed files with 1902 additions and 0 deletions
+236
View File
@@ -0,0 +1,236 @@
/** pi-ntfy command parser and config mutations. */
import {
CHANNEL_SETTABLE_FIELDS,
formatChannelLabel,
invalidateConfigCache,
loadConfig,
loadRawConfig,
maskSecret,
saveRawConfig,
type RawConfigFile,
} from "./config.ts";
import type { ChannelResult, NotifyConfig } from "./types.ts";
export interface CommandResult {
ok: boolean;
message: string;
runTest?: boolean;
testFilter?: string;
}
function usage(): string {
return [
"用法:",
" /ntfy 状态",
" /ntfy list 查看 ntfy 配置",
" /ntfy get 查看配置(密钥脱敏)",
" /ntfy enable|disable 开关 ntfy",
" /ntfy set key=value ... 设置 topic/token/server 等",
" /ntfy test 发送测试通知",
" /ntfy on | off 当前会话总开关",
" /ntfy events [k=on|off…] 设置事件开关",
" /ntfy help 帮助",
"",
"示例:",
" /ntfy set topic=my-topic token=$NTFY_TOKEN enabled=true",
" /ntfy test",
].join("\n");
}
export function tokenize(args: string): string[] {
const out: string[] = [];
const re = /"([^"\\]|\\.)*"|'([^'\\]|\\.)*'|\S+/g;
for (const match of args.matchAll(re)) {
let token = match[0];
if (
(token.startsWith('"') && token.endsWith('"')) ||
(token.startsWith("'") && token.endsWith("'"))
) {
token = token.slice(1, -1).replace(/\\([\\'"])/g, "$1");
}
out.push(token);
}
return out;
}
function parseBool(raw: string): boolean {
const value = raw.trim().toLowerCase();
if (["1", "true", "on", "yes", "开", "启用"].includes(value)) return true;
if (["0", "false", "off", "no", "关", "禁用"].includes(value)) return false;
throw new Error(`无法解析布尔值: ${raw}(用 true/false 或 on/off)`);
}
function coerceField(key: string, raw: string): string | number | boolean {
if (key === "enabled") return parseBool(raw);
if (key === "timeoutMs" || key === "priority")
return /^\d+$/.test(raw) ? Number(raw) : raw;
return raw;
}
function channelData(data: RawConfigFile): Array<Record<string, unknown>> {
if (!Array.isArray(data.channels)) data.channels = [];
return data.channels;
}
function ntfyIndex(data: RawConfigFile): number {
const channels = channelData(data);
const index = channels.findIndex(
(channel) => String(channel.type ?? "ntfy") === "ntfy",
);
if (index >= 0) return index;
channels.push({ type: "ntfy", name: "ntfy", enabled: true });
return channels.length - 1;
}
function applyPairs(
channel: Record<string, unknown>,
pairs: string[],
): string[] {
const allowed = new Set(CHANNEL_SETTABLE_FIELDS.ntfy);
const changed: string[] = [];
for (const pair of pairs) {
const separator = pair.indexOf("=");
if (separator <= 0) throw new Error(`参数应为 key=value,收到: ${pair}`);
const key = pair.slice(0, separator).trim();
if (!allowed.has(key))
throw new Error(
`ntfy 不支持字段 "${key}"。可设: ${[...allowed].join(", ")}`,
);
channel[key] = coerceField(key, pair.slice(separator + 1));
changed.push(key);
}
if (changed.length === 0) throw new Error("请至少提供一个 key=value");
return changed;
}
function describeConfig(
config: NotifyConfig,
runtimeEnabled: boolean,
last?: { at: number; event: string; results: ChannelResult[] },
describeResults?: (results: ChannelResult[]) => string,
): string {
const channel = config.channels[0];
const lines = [
`pi-ntfy: ${runtimeEnabled && config.enabled ? "已启用" : "已停用"}${config.exists ? "" : "(无配置文件)"}`,
`配置: ${config.path}`,
`渠道: ${channel ? formatChannelLabel(channel, 0) : "ntfy(未配置)"}`,
`事件: ${(["idle", "interrupted", "needInput", "exit"] as const).map((key) => `${key}=${config.events[key] ? "on" : "off"}`).join(" ")}`,
`模式: ${config.modes.join(",")}`,
];
if (last && describeResults)
lines.push(
`上次推送: ${new Date(last.at).toTimeString().slice(0, 8)} ${last.event} → ${describeResults(last.results)}`,
);
else lines.push("上次推送: (无)");
if (config.warnings.length > 0)
lines.push(`警告: ${config.warnings.join("; ")}`);
return lines.join("\n");
}
export function handlePushCommand(
args: string,
options: {
runtimeEnabled: boolean;
setRuntimeEnabled: (enabled: boolean) => void;
last?: { at: number; event: string; results: ChannelResult[] };
describeResults: (results: ChannelResult[]) => string;
},
): CommandResult {
const tokens = tokenize(args.trim());
const action = (tokens[0] ?? "").toLowerCase();
if (!action || action === "status") {
invalidateConfigCache();
const config = loadConfig();
return {
ok: true,
message: describeConfig(
config,
options.runtimeEnabled,
options.last,
options.describeResults,
),
};
}
if (["help", "-h", "--help"].includes(action))
return { ok: true, message: usage() };
if (action === "on" || action === "off") {
options.setRuntimeEnabled(action === "on");
return {
ok: true,
message: `pi-ntfy 已${action === "on" ? "开启" : "关闭"}(仅当前会话)`,
};
}
if (action === "test") return { ok: true, message: "", runTest: true };
if (action === "list" || action === "get") {
const raw = loadRawConfig();
const channels = channelData(raw.data);
if (channels.length === 0) return { ok: true, message: "没有配置 ntfy" };
const channel = channels[0];
if (action === "list")
return {
ok: true,
message: `${formatChannelLabel(channel, 0)} topic=${maskSecret("topic", channel.topic)}`,
};
return {
ok: true,
message: [
formatChannelLabel(channel, 0),
...CHANNEL_SETTABLE_FIELDS.ntfy
.filter((key) => key in channel)
.map((key) => `${key}=${maskSecret(key, channel[key])}`),
].join("\n"),
};
}
if (action === "enable" || action === "disable") {
const raw = loadRawConfig();
const index = ntfyIndex(raw.data);
channelData(raw.data)[index].enabled = action === "enable";
const path = saveRawConfig(raw.data);
return {
ok: true,
message: `ntfy 已${action === "enable" ? "开启" : "关闭"}\n已写入 ${path}`,
};
}
if (action === "set") {
const raw = loadRawConfig();
const index = ntfyIndex(raw.data);
const changed = applyPairs(channelData(raw.data)[index], tokens.slice(1));
const path = saveRawConfig(raw.data);
return {
ok: true,
message: `已更新 ntfy: ${changed.map((key) => `${key}=${maskSecret(key, channelData(raw.data)[index][key])}`).join(" ")}\n已写入 ${path}`,
};
}
if (action === "events") {
const raw = loadRawConfig();
const events = (raw.data.events ??= {
idle: true,
interrupted: true,
needInput: true,
exit: false,
});
for (const pair of tokens.slice(1)) {
const separator = pair.indexOf("=");
if (separator <= 0) throw new Error(`参数应为 event=on|off,收到: ${pair}`);
const key = pair.slice(0, separator);
if (!(key in events)) throw new Error(`未知事件 "${key}"`);
events[key] = parseBool(pair.slice(separator + 1));
}
if (tokens.length > 1) saveRawConfig(raw.data);
return {
ok: true,
message: Object.entries(events)
.map(([key, value]) => `${key}=${value ? "on" : "off"}`)
.join(" "),
};
}
return { ok: false, message: `未知子命令 "${action}"\n${usage()}` };
}
export function filterConfigToChannel(
config: NotifyConfig,
_selector: string,
): NotifyConfig {
return { ...config, channels: config.channels.slice(0, 1) };
}