Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7164408d74 | ||
|
|
c16ae3a11d | ||
|
|
a4efba3f1f | ||
|
|
2f9f865d17 | ||
|
|
4628ee02d7 | ||
|
|
42b20c42d6 | ||
|
|
65cde39b1b |
@@ -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 字段。
|
||||||
|
|||||||
@@ -15,33 +15,54 @@ Pi coding agent 的 NTFY 专用通知插件,适合并行开发时区分项目
|
|||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pi remove /home/yanghao05/Workspace/pi-push
|
pi install npm:pi-ntfy
|
||||||
pi install /home/yanghao05/Workspace/pi-push
|
|
||||||
```
|
```
|
||||||
|
|
||||||
不要同时安装或启用旧版 `npm:pi-agent-push`。
|
卸载:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pi remove npm:pi-ntfy
|
||||||
|
```
|
||||||
|
|
||||||
## 配置
|
## 配置
|
||||||
|
|
||||||
```bash
|
插件固定从以下路径读取配置,不再通过环境变量选择配置文件:
|
||||||
cp config.example.json config.json
|
|
||||||
|
```text
|
||||||
|
~/.pi/agent/pi-ntfy.json
|
||||||
```
|
```
|
||||||
|
|
||||||
默认配置:
|
如果文件不存在,插件保持静默;可直接创建该文件,或使用 `/ntfy set`、`/ntfy enable` 等命令生成和修改配置。
|
||||||
|
|
||||||
|
示例配置:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"modes": ["tui"],
|
||||||
|
"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}}",
|
||||||
|
"channels": [
|
||||||
|
{
|
||||||
|
"type": "ntfy",
|
||||||
|
"name": "Agent",
|
||||||
|
"enabled": true,
|
||||||
|
"topic": "Agent",
|
||||||
|
"server": "https://n.ipao.vip",
|
||||||
|
"token": "$NTFY_TOKEN"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`token` 可以使用 `$NTFY_TOKEN` 或 `${NTFY_TOKEN}` 引用环境变量;配置文件路径本身不受环境变量影响。
|
||||||
|
|
||||||
|
配置字段:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"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": [
|
|
||||||
{
|
|
||||||
"type": "ntfy",
|
|
||||||
"enabled": true,
|
|
||||||
"topic": "$NTFY_TOPIC",
|
|
||||||
"server": "$NTFY_HOST",
|
|
||||||
"token": "$NTFY_TOKEN"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -69,4 +90,20 @@ cp config.example.json config.json
|
|||||||
/ntfy off
|
/ntfy off
|
||||||
```
|
```
|
||||||
|
|
||||||
旧的 `/push` 命令不再使用。
|
## 发布
|
||||||
|
|
||||||
|
版本发布由 Gitea Actions 完成。推送 `v*` 标签或手动运行 `Publish pi-ntfy to npm` workflow 即可发布到 npm。
|
||||||
|
|
||||||
|
workflow 使用账户级 Secret:
|
||||||
|
|
||||||
|
```text
|
||||||
|
NPM_PACKAGE_TOKEN
|
||||||
|
```
|
||||||
|
|
||||||
|
例如:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm version patch
|
||||||
|
git push
|
||||||
|
git push --tags
|
||||||
|
```
|
||||||
|
|||||||
+19
-42
@@ -8,7 +8,7 @@
|
|||||||
import { httpRequest, localFailure, type HttpResult } from "../http.ts";
|
import { httpRequest, localFailure, type HttpResult } from "../http.ts";
|
||||||
import type { NotifyPayload, NtfyChannel } from "../types.ts";
|
import type { NotifyPayload, NtfyChannel } from "../types.ts";
|
||||||
|
|
||||||
const DEFAULT_SERVER = "https://ntfy.sh";
|
const DEFAULT_SERVER = "https://n.ipao.vip";
|
||||||
|
|
||||||
function eventTagFor(event: NotifyPayload["event"]): string {
|
function eventTagFor(event: NotifyPayload["event"]): string {
|
||||||
switch (event) {
|
switch (event) {
|
||||||
@@ -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);
|
||||||
|
|||||||
+2
-2
@@ -23,8 +23,8 @@
|
|||||||
"type": "ntfy",
|
"type": "ntfy",
|
||||||
"name": "手机 ntfy",
|
"name": "手机 ntfy",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"topic": "$NTFY_TOPIC",
|
"topic": "Agent",
|
||||||
"server": "$NTFY_HOST",
|
"server": "https://n.ipao.vip",
|
||||||
"token": "$NTFY_TOKEN",
|
"token": "$NTFY_TOKEN",
|
||||||
"priority": "default",
|
"priority": "default",
|
||||||
"tags": "pi,computer"
|
"tags": "pi,computer"
|
||||||
|
|||||||
@@ -9,35 +9,21 @@ 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 { fileURLToPath } from "node:url";
|
|
||||||
import type { ChannelConfig, NotifyConfig, NtfyChannel } from "./types.ts";
|
import type { ChannelConfig, NotifyConfig, NtfyChannel } from "./types.ts";
|
||||||
|
|
||||||
function resolveExtensionDir(): string {
|
const CONFIG_PATH = join(homedir(), ".pi", "agent", "pi-ntfy.json");
|
||||||
try {
|
|
||||||
return dirname(fileURLToPath(import.meta.url));
|
|
||||||
} catch {
|
|
||||||
return join(homedir(), ".pi", "agent", "extensions", "pi-ntfy");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const EXTENSION_DIR = resolveExtensionDir();
|
|
||||||
|
|
||||||
export function configPath(): string {
|
export function configPath(): string {
|
||||||
const override =
|
return CONFIG_PATH;
|
||||||
process.env.PI_NTFY_CONFIG?.trim() ||
|
|
||||||
process.env.PI_AGENT_PUSH_CONFIG?.trim() ||
|
|
||||||
process.env.PI_PUSH_CONFIG?.trim();
|
|
||||||
return override ? override : join(EXTENSION_DIR, "config.json");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logPath(): string {
|
export function logPath(): string {
|
||||||
// Next to the config file, so PI_AGENT_PUSH_CONFIG keeps everything together.
|
return join(dirname(configPath()), "pi-ntfy.log");
|
||||||
return join(dirname(configPath()), "push.log");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const KNOWN_TYPES = ["ntfy"] as const;
|
export const KNOWN_TYPES = ["ntfy"] as const;
|
||||||
export type KnownChannelType = (typeof KNOWN_TYPES)[number];
|
export type KnownChannelType = (typeof KNOWN_TYPES)[number];
|
||||||
/** Whole-value reference: "$BARK_KEY" */
|
/** Whole-value reference: "$NTFY_TOKEN" */
|
||||||
const ENV_REF = /^\$([A-Za-z_][A-Za-z0-9_]*)$/;
|
const ENV_REF = /^\$([A-Za-z_][A-Za-z0-9_]*)$/;
|
||||||
/** Embedded reference: "Bearer ${MY_TOKEN}" */
|
/** Embedded reference: "Bearer ${MY_TOKEN}" */
|
||||||
const ENV_INTERPOLATION = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
const ENV_INTERPOLATION = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
/**
|
/** pi-ntfy HTTP transport. */
|
||||||
* pi-agent-push — HTTP transport.
|
|
||||||
*
|
|
||||||
* Every request carries its own `AbortSignal.timeout`. It must never use the
|
|
||||||
* agent's `ctx.signal`: on the "interrupted" path that signal is already
|
|
||||||
* aborted, so the notification about the cancellation would cancel itself.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ChannelResult } from "./types.ts";
|
import type { ChannelResult } from "./types.ts";
|
||||||
|
|
||||||
|
|||||||
+1
-5
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pi-ntfy",
|
"name": "pi-ntfy",
|
||||||
"version": "1.0.0",
|
"version": "1.0.3",
|
||||||
"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",
|
||||||
@@ -35,10 +35,6 @@
|
|||||||
"LICENSE",
|
"LICENSE",
|
||||||
"DESIGN.md"
|
"DESIGN.md"
|
||||||
],
|
],
|
||||||
"scripts": {
|
|
||||||
"test": "node test/run.ts",
|
|
||||||
"prepublishOnly": "node test/run.ts"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@earendil-works/pi-coding-agent": "*"
|
"@earendil-works/pi-coding-agent": "*"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
/**
|
/** pi-ntfy template rendering. */
|
||||||
* pi-agent-push — `{{placeholder}}` substitution.
|
|
||||||
*
|
|
||||||
* Escaping matters: a raw string template that lands inside JSON must have its
|
|
||||||
* values JSON-escaped, otherwise a quote or newline in an error message
|
|
||||||
* produces an invalid request body. Object templates avoid the problem
|
|
||||||
* entirely (substitute first, stringify after) and are the recommended form.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const PLACEHOLDER = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g;
|
const PLACEHOLDER = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user