Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6070849fae | ||
|
|
6fc9f228a8 | ||
|
|
7164408d74 | ||
|
|
c16ae3a11d |
@@ -41,4 +41,4 @@
|
|||||||
|
|
||||||
本插件只有一个通道:`ntfy`。其他推送通道和对应实现已移除。
|
本插件只有一个通道:`ntfy`。其他推送通道和对应实现已移除。
|
||||||
|
|
||||||
NTFY 元数据使用 `Title`、`Tags`、`X-Project`、`X-Session`、`X-Event`、`X-Task` headers。含中文的字段会写入 Base64 版本的 headers,完整中文内容始终保留在正文中。
|
NTFY 采用 JSON 发布(POST 到 server 根路径):`title`、`message` 随 UTF-8 JSON body 传输,中文标题/正文不受 HTTP header 只允许 Latin-1 的限制;`tags`/`priority`/`click`/`icon`/`email` 同样走 JSON 字段。
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ pi remove npm:pi-ntfy
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"modes": ["tui"],
|
"excludedModes": [],
|
||||||
"titleTemplate": "[{{project}}] {{status}} · {{session}}",
|
"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}}",
|
"template": "项目: {{project}}\\n会话: {{session}}\\n任务: {{task}}\\n状态: {{status}}\\n原因: {{reason}}\\n摘要: {{summary}}\\n耗时: {{duration}}\\n模型: {{model}}\\n主机: {{host}}\\n时间: {{date}} {{time}}\\n工具: {{tools}}",
|
||||||
"channels": [
|
"channels": [
|
||||||
@@ -57,6 +57,8 @@ pi remove npm:pi-ntfy
|
|||||||
|
|
||||||
`token` 可以使用 `$NTFY_TOKEN` 或 `${NTFY_TOKEN}` 引用环境变量;配置文件路径本身不受环境变量影响。
|
`token` 可以使用 `$NTFY_TOKEN` 或 `${NTFY_TOKEN}` 引用环境变量;配置文件路径本身不受环境变量影响。
|
||||||
|
|
||||||
|
工作模式:默认支持 pi 的全部工作模式(`tui`、`json`、`rpc`、`print`),配置里无需列出支持的模式;`excludedModes` 仅列出**不**推送的模式,例如 `"excludedModes": ["json"]` 可让 `pi --mode json -p` 子代理保持安静。旧配置中的 `modes` 白名单已废弃,会被忽略并给出提示。
|
||||||
|
|
||||||
配置字段:
|
配置字段:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
+18
-41
@@ -25,14 +25,6 @@ function eventTagFor(event: NotifyPayload["event"]): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAscii(value: string): boolean {
|
|
||||||
return [...value].every((char) => char.charCodeAt(0) <= 255);
|
|
||||||
}
|
|
||||||
|
|
||||||
function base64(value: string): string {
|
|
||||||
return Buffer.from(value, "utf8").toString("base64url");
|
|
||||||
}
|
|
||||||
|
|
||||||
function addErrorDetail(result: HttpResult): HttpResult {
|
function addErrorDetail(result: HttpResult): HttpResult {
|
||||||
if (result.ok) return result;
|
if (result.ok) return result;
|
||||||
let detail = result.error ?? `HTTP ${result.status}`;
|
let detail = result.error ?? `HTTP ${result.status}`;
|
||||||
@@ -66,44 +58,29 @@ export async function sendNtfy(
|
|||||||
: `Bearer ${token}`;
|
: `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const project = payload.vars.project ?? "";
|
// Publish as JSON (POST to the server root): title/message travel in a UTF-8
|
||||||
const session = payload.vars.session ?? "";
|
// body, so Chinese titles work — plain-text publishing would force headers to
|
||||||
const task = payload.vars.task ?? "";
|
// be Latin-1 (fetch ByteString) and we had to mangle non-ASCII titles.
|
||||||
const asciiSession = isAscii(session) ? session : "";
|
|
||||||
const asciiTitle = isAscii(payload.title)
|
|
||||||
? payload.title
|
|
||||||
: `[${project || "pi"}] pi ${payload.event}${asciiSession ? ` · ${asciiSession}` : ""}`;
|
|
||||||
|
|
||||||
// NTFY headers must be ASCII-safe under Node's fetch implementation.
|
|
||||||
headers.Title = asciiTitle;
|
|
||||||
headers["X-Event"] = payload.event;
|
|
||||||
if (isAscii(project)) headers["X-Project"] = project;
|
|
||||||
if (asciiSession) headers["X-Session"] = asciiSession;
|
|
||||||
if (isAscii(task) && task) headers["X-Task"] = task;
|
|
||||||
if (project) headers["X-Project-B64"] = base64(project);
|
|
||||||
if (session) headers["X-Session-B64"] = base64(session);
|
|
||||||
if (task) headers["X-Task-B64"] = base64(task);
|
|
||||||
|
|
||||||
if (channel.priority != null && channel.priority !== "") {
|
|
||||||
headers.Priority = String(channel.priority);
|
|
||||||
}
|
|
||||||
const configuredTags =
|
const configuredTags =
|
||||||
channel.tags == null
|
channel.tags == null
|
||||||
? ""
|
? []
|
||||||
: Array.isArray(channel.tags)
|
: Array.isArray(channel.tags)
|
||||||
? channel.tags.join(",")
|
? channel.tags
|
||||||
: String(channel.tags);
|
: [String(channel.tags)];
|
||||||
const tags = [configuredTags, eventTagFor(payload.event)]
|
const body = JSON.stringify({
|
||||||
.filter(Boolean)
|
topic,
|
||||||
.join(",");
|
title: payload.title,
|
||||||
if (tags) headers.Tags = tags;
|
message: payload.text,
|
||||||
if (channel.clickUrl) headers.Click = channel.clickUrl;
|
tags: [...configuredTags, eventTagFor(payload.event)],
|
||||||
if (channel.icon) headers.Icon = channel.icon;
|
...(channel.priority != null && channel.priority !== "" && { priority: channel.priority }),
|
||||||
if (channel.email) headers.Email = channel.email;
|
...(channel.clickUrl && { click: channel.clickUrl }),
|
||||||
|
...(channel.icon && { icon: channel.icon }),
|
||||||
|
...(channel.email && { email: channel.email }),
|
||||||
|
});
|
||||||
|
|
||||||
const result = await httpRequest(
|
const result = await httpRequest(
|
||||||
`${server}/${encodeURIComponent(topic)}`,
|
server,
|
||||||
{ method: "POST", headers, body: payload.text },
|
{ method: "POST", headers, body },
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
);
|
);
|
||||||
return addErrorDetail(result);
|
return addErrorDetail(result);
|
||||||
|
|||||||
+1
-1
@@ -116,7 +116,7 @@ function describeConfig(
|
|||||||
`配置: ${config.path}`,
|
`配置: ${config.path}`,
|
||||||
`渠道: ${channel ? formatChannelLabel(channel, 0) : "ntfy(未配置)"}`,
|
`渠道: ${channel ? formatChannelLabel(channel, 0) : "ntfy(未配置)"}`,
|
||||||
`事件: ${(["idle", "interrupted", "needInput", "exit"] as const).map((key) => `${key}=${config.events[key] ? "on" : "off"}`).join(" ")}`,
|
`事件: ${(["idle", "interrupted", "needInput", "exit"] as const).map((key) => `${key}=${config.events[key] ? "on" : "off"}`).join(" ")}`,
|
||||||
`模式: ${config.modes.join(",")}`,
|
`模式: 全部${config.excludedModes.length > 0 ? `(排除: ${config.excludedModes.join(", ")})` : ""}`,
|
||||||
];
|
];
|
||||||
if (last && describeResults)
|
if (last && describeResults)
|
||||||
lines.push(
|
lines.push(
|
||||||
|
|||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"_说明": "pi-ntfy 只支持 NTFY。复制为 config.json 后按需修改。",
|
"_说明": "pi-ntfy 只支持 NTFY。默认所有工作模式(tui/json/rpc/print)都会推送;excludedModes 仅列出不需要推送的模式,例如 [\"json\"] 可让 pi --mode json -p 的子代理保持安静。复制为 config.json 后按需修改。",
|
||||||
"_占位符": "{{status}} {{reason}} {{text}} {{title}} {{event}} {{cwd}} {{project}} {{duration}} {{session}} {{sessionId}} {{model}} {{mode}} {{thinking}} {{task}} {{summary}} {{tools}} {{host}} {{time}} {{date}}",
|
"_占位符": "{{status}} {{reason}} {{text}} {{title}} {{event}} {{cwd}} {{project}} {{duration}} {{session}} {{sessionId}} {{model}} {{mode}} {{thinking}} {{task}} {{summary}} {{tools}} {{host}} {{time}} {{date}}",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"modes": ["tui"],
|
"excludedModes": [],
|
||||||
"timeoutMs": 5000,
|
"timeoutMs": 5000,
|
||||||
"shutdownTimeoutMs": 2000,
|
"shutdownTimeoutMs": 2000,
|
||||||
"dedupeMs": 3000,
|
"dedupeMs": 3000,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from "node:fs";
|
} from "node:fs";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import type { ChannelConfig, NotifyConfig, NtfyChannel } from "./types.ts";
|
import { ALL_MODES, type ChannelConfig, type NotifyConfig, type NtfyChannel } from "./types.ts";
|
||||||
|
|
||||||
const CONFIG_PATH = join(homedir(), ".pi", "agent", "pi-ntfy.json");
|
const CONFIG_PATH = join(homedir(), ".pi", "agent", "pi-ntfy.json");
|
||||||
|
|
||||||
@@ -75,20 +75,44 @@ function str(value: unknown, fallback: string): string {
|
|||||||
return typeof value === "string" && value.length > 0 ? value : fallback;
|
return typeof value === "string" && value.length > 0 ? value : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
function strArray(value: unknown, fallback: string[]): string[] {
|
function strArray(value: unknown): string[] {
|
||||||
if (!Array.isArray(value)) return fallback;
|
if (!Array.isArray(value)) return [];
|
||||||
const items = value.filter(
|
return value.filter(
|
||||||
(v): v is string => typeof v === "string" && v.length > 0,
|
(v): v is string => typeof v === "string" && v.length > 0,
|
||||||
);
|
);
|
||||||
return items.length > 0 ? items : fallback;
|
}
|
||||||
|
|
||||||
|
/** Validate excludedModes: known modes only, deduped, with warnings. */
|
||||||
|
function excludedModes(
|
||||||
|
value: unknown,
|
||||||
|
fallback: string[],
|
||||||
|
warnings: string[],
|
||||||
|
): string[] {
|
||||||
|
if (value === undefined) return fallback;
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
warnings.push('"excludedModes" 必须是数组,已忽略');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const item of value) {
|
||||||
|
if (typeof item !== "string" || item.length === 0) continue;
|
||||||
|
if (!(ALL_MODES as readonly string[]).includes(item)) {
|
||||||
|
warnings.push(
|
||||||
|
`excludedModes 中的 "${item}" 不是有效模式(可选: ${ALL_MODES.join("/")}),已忽略`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!out.includes(item)) out.push(item);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function defaultConfig(path: string): NotifyConfig {
|
export function defaultConfig(path: string): NotifyConfig {
|
||||||
return {
|
return {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
// Only the interactive TUI by default: pi subagents run `pi --mode json -p`,
|
// All working modes (tui/json/rpc/print) push by default. To quiet
|
||||||
// and pushing for every one of those would be pure noise.
|
// `pi --mode json -p` subagents, set "excludedModes": ["json"].
|
||||||
modes: ["tui"],
|
excludedModes: [],
|
||||||
timeoutMs: 5000,
|
timeoutMs: 5000,
|
||||||
shutdownTimeoutMs: 2000,
|
shutdownTimeoutMs: 2000,
|
||||||
dedupeMs: 3000,
|
dedupeMs: 3000,
|
||||||
@@ -168,7 +192,15 @@ export function loadConfig(): NotifyConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
config.enabled = bool(parsed.enabled, config.enabled);
|
config.enabled = bool(parsed.enabled, config.enabled);
|
||||||
config.modes = strArray(parsed.modes, config.modes);
|
config.excludedModes = excludedModes(
|
||||||
|
parsed.excludedModes,
|
||||||
|
config.excludedModes,
|
||||||
|
config.warnings,
|
||||||
|
);
|
||||||
|
if (parsed.modes !== undefined)
|
||||||
|
config.warnings.push(
|
||||||
|
'"modes" 已废弃,请改用 "excludedModes"(仅列出不需要推送的模式)',
|
||||||
|
);
|
||||||
config.timeoutMs = num(parsed.timeoutMs, config.timeoutMs, 200, 60_000);
|
config.timeoutMs = num(parsed.timeoutMs, config.timeoutMs, 200, 60_000);
|
||||||
config.shutdownTimeoutMs = num(
|
config.shutdownTimeoutMs = num(
|
||||||
parsed.shutdownTimeoutMs,
|
parsed.shutdownTimeoutMs,
|
||||||
@@ -186,7 +218,7 @@ export function loadConfig(): NotifyConfig {
|
|||||||
config.maxTextChars = num(parsed.maxTextChars, config.maxTextChars, 20, 4000);
|
config.maxTextChars = num(parsed.maxTextChars, config.maxTextChars, 20, 4000);
|
||||||
config.titleTemplate = str(parsed.titleTemplate, config.titleTemplate);
|
config.titleTemplate = str(parsed.titleTemplate, config.titleTemplate);
|
||||||
config.template = str(parsed.template, config.template);
|
config.template = str(parsed.template, config.template);
|
||||||
config.needInputTools = strArray(parsed.needInputTools, config.needInputTools);
|
config.needInputTools = strArray(parsed.needInputTools);
|
||||||
config.debug = bool(parsed.debug, config.debug);
|
config.debug = bool(parsed.debug, config.debug);
|
||||||
|
|
||||||
if (parsed.events && typeof parsed.events === "object") {
|
if (parsed.events && typeof parsed.events === "object") {
|
||||||
@@ -240,7 +272,7 @@ export const CHANNEL_SETTABLE_FIELDS: Record<string, string[]> = {
|
|||||||
|
|
||||||
export interface RawConfigFile {
|
export interface RawConfigFile {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
modes?: string[];
|
excludedModes?: string[];
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
shutdownTimeoutMs?: number;
|
shutdownTimeoutMs?: number;
|
||||||
dedupeMs?: number;
|
dedupeMs?: number;
|
||||||
@@ -268,8 +300,12 @@ export function loadRawConfig(): {
|
|||||||
exists: false,
|
exists: false,
|
||||||
data: {
|
data: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
modes: ["tui"],
|
events: {
|
||||||
events: { idle: true, interrupted: true, needInput: true, exit: false },
|
idle: true,
|
||||||
|
interrupted: true,
|
||||||
|
needInput: true,
|
||||||
|
exit: false,
|
||||||
|
},
|
||||||
channels: [],
|
channels: [],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -74,8 +74,8 @@ export default function piNtfy(pi: ExtensionAPI) {
|
|||||||
if (!runtimeEnabled) return undefined;
|
if (!runtimeEnabled) return undefined;
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
if (!config.enabled || !config.exists) return undefined;
|
if (!config.enabled || !config.exists) return undefined;
|
||||||
// Subagents run `pi --mode json -p`; pushing for each of those is noise.
|
// All modes push unless listed in excludedModes (e.g. subagents on json).
|
||||||
if (!config.modes.includes(mode)) return undefined;
|
if (config.excludedModes.includes(mode)) return undefined;
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pi-ntfy",
|
"name": "pi-ntfy",
|
||||||
"version": "1.0.2",
|
"version": "1.1.0",
|
||||||
"description": "Project and session-aware NTFY notifications for the pi coding agent.",
|
"description": "Project and session-aware NTFY notifications for the pi coding agent.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ export type ChannelConfig = NtfyChannel;
|
|||||||
|
|
||||||
export interface NotifyConfig {
|
export interface NotifyConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
modes: string[];
|
/** Modes that must stay quiet; all others push. Empty array = push everywhere. */
|
||||||
|
excludedModes: string[];
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
shutdownTimeoutMs: number;
|
shutdownTimeoutMs: number;
|
||||||
dedupeMs: number;
|
dedupeMs: number;
|
||||||
@@ -51,6 +52,14 @@ export interface NotifyConfig {
|
|||||||
exists: boolean;
|
exists: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const ALL_MODES = [
|
||||||
|
"tui",
|
||||||
|
"json",
|
||||||
|
"rpc",
|
||||||
|
"print",
|
||||||
|
] as const;
|
||||||
|
export type KnownMode = (typeof ALL_MODES)[number];
|
||||||
|
|
||||||
export interface NotifyPayload {
|
export interface NotifyPayload {
|
||||||
event: NotifyEvent;
|
event: NotifyEvent;
|
||||||
status: string;
|
status: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user