Files
pi-ntfy/command.ts
T

237 lines
7.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 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) };
}