Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ NOTIFICATION_ENABLED=true
# 是否启用声音提醒 (true/false)
SOUND_ENABLED=true

# 声音音效(可选,支持 .wav / .mp3):留空=默认 Windows 通知音
# SOUND_FILE —— 任务完成(Stop hook)时播放
# SOUND_FILE_ASK —— Claude 问你 / 等你授权(Notification hook)时播放;留空则回退到 SOUND_FILE
# 例如:
# SOUND_FILE=C:\Windows\Media\tada.wav
# SOUND_FILE_ASK=C:\Windows\Media\chimes.wav
# SOUND_FILE=
# SOUND_FILE_ASK=

# Telegram Bot配置
# 获取方法:
# 1. 与 @BotFather 对话创建机器人,获取 token
Expand All @@ -20,6 +29,50 @@ SOUND_ENABLED=true
TELEGRAM_BOT_TOKEN=your_bot_token_here
TELEGRAM_CHAT_ID=your_chat_id_here

# Bark 推送配置(iOS)
# 获取方法:
# 1. 在 iPhone 安装 Bark App
# 2. 打开 App 首页,复制「设备 key」
# (如 https://api.day.app/AbCd1234.../ 里的 AbCd1234...)
# 3. 把 key 填到 BARK_KEY;BARK_SERVER 默认官方服务器,自建服务器才需要改
BARK_KEY=your_bark_device_key_here
BARK_SERVER=https://api.day.app

# ----- Bark 扩展功能(都可选,不填用下面的默认值)-----
# 通知图标 URL(默认 Claude 图标)。可到 Apple 应用商店对应 App 页面复制图片链接填入
BARK_ICON=https://claude.ai/apple-touch-icon.png
# 推送铃声(可选,默认不启用):iPhone 收到时播放的铃声名,留空=Bark 默认铃声
# 用法:取消下面某行注释,或写 BARK_SOUND=<名字>,例如 BARK_SOUND=birdsong
# iOS 预置音效名(任选其一):
# alarm anticipate bell birdsong bloom
# calypso chime choo descent electronic
# fanfare glass gotosleep healthnotification horn
# ladder mailsent minuet multiwayinvitation newmail
# newsflash noir paymentsuccess shake sherwoodforest
# silence spell suspense telegraph tiptoes
# typewriters update
# 也可填你在 Bark App 里「铃声 → 上传铃声」导入的自定义音效名
# BARK_SOUND=
# 通知级别:active / timeSensitive(时效性,穿透专注模式) / passive
BARK_LEVEL=timeSensitive
# 消息分组(默认启用,用项目名分组);设 false 关闭
BARK_GROUP_ENABLED=true
# 自动保存到通知历史:Stop 默认存、AskUserQuestion 默认不存
BARK_ARCHIVE_STOP=true
BARK_ARCHIVE_ASK=false
# 重要警告(critical,静音/勿扰也强制响):off / stop / ask / all,默认 off
BARK_CRITICAL=off
BARK_CRITICAL_VOLUME=5
# 持续响铃(call,响约30秒):off / stop / ask / all,默认 off
BARK_CALL=off
# 推送加密(端到端):填密钥开启,需与 Bark App「推送加密」的算法/密钥/IV 一致
# key 长度决定 AES-128/192/256(如 32 位=AES-256)
# BARK_ENCRYPT_IV 固定 16 位;留空则每次随机生成并放进 iv 参数一起发(更推荐)
# 注意:固定 IV 会让相同前缀的消息密文前缀相同,中继方可据此关联消息;追求隐私就留空用随机
# BARK_ENCRYPT_KEY=
# BARK_ENCRYPT_IV=
# BARK_ENCRYPT_MODE=CBC

# HTTP代理配置(可选)
# 如果需要通过代理访问Telegram API,请配置以下选项之一
# 支持的格式:
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Claude Code 完成任务时自动发通知到手机/手环,你不用一直盯着屏幕等。

支持飞书 Webhook、Telegram Bot、Windows 声音提醒。
支持飞书 Webhook、Telegram Bot、Bark(iOS 推送)、Windows 声音提醒。

## 一句话配置

Expand Down
43 changes: 43 additions & 0 deletions SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,53 @@ TELEGRAM_CHAT_ID=你的chat_id

需要代理的话加一行 `HTTPS_PROXY=http://127.0.0.1:7890`。

## Bark 通知配置(iOS)

1. iPhone 安装 Bark App,打开首页复制「设备 key」(如 `https://api.day.app/AbCd1234.../` 里的 `AbCd1234...`)
2. 填入 `.env`:

```bash
BARK_KEY=你的设备key
BARK_SERVER=https://api.day.app
```

`BARK_SERVER` 默认官方服务器,自建 Bark 服务器时才需要改。测试:`node notify-system.js --task "测试"`。

### Bark 扩展功能(可选)

都在 `.env` 里配置,按事件(Stop=任务完成 / ask=Claude 等你)区分:

| 变量 | 作用 | 默认 |
|------|------|------|
| `BARK_ICON` | 通知图标 URL | Claude 图标 |
| `BARK_SOUND` | iPhone 铃声:iOS 预置音效名或 App 导入的自定义音效名(预置名列表见 `.env.example`) | 空=Bark 默认 |
| `BARK_LEVEL` | `active`/`timeSensitive`(时效性)/`passive` | `timeSensitive` |
| `BARK_GROUP_ENABLED` | 用项目名分组 | `true` |
| `BARK_ARCHIVE_STOP` / `BARK_ARCHIVE_ASK` | 存进通知历史 | Stop 存 / ask 不存 |
| `BARK_CRITICAL` | 重要警告(静音也响):`off`/`stop`/`ask`/`all` | `off` |
| `BARK_CRITICAL_VOLUME` | 重要警告音量 0~10 | `5` |
| `BARK_CALL` | 持续响铃~30秒:`off`/`stop`/`ask`/`all` | `off` |
| `BARK_ENCRYPT_KEY` | 端到端加密密钥(16/24/32 位),需与 App「推送加密」一致 | 空=不加密 |
| `BARK_ENCRYPT_IV` | 固定 IV(16 位);固定 IV 会让相同前缀消息密文前缀相同(中继可关联),追求隐私建议留空用随机 | 空=随机(推荐) |
| `BARK_ENCRYPT_MODE` | 加密模式 `CBC`/`ECB` | `CBC` |

> 加密:在 Bark App「设置 → 推送加密」里选相同算法/模式并填同一密钥,服务器就只转发密文、看不到内容。

## 声音提醒

默认开启,仅支持 Windows。不需要的话设 `SOUND_ENABLED=false`。

想换音效:在 `.env` 里设音效路径,留空则用默认 Windows 通知音。**支持 `.wav` 和 `.mp3`**(`.wav` 走 SoundPlayer,`.mp3`/`.m4a`/`.wma` 走 MediaPlayer)。可给两类事件分别配音效:

```bash
SOUND_FILE=C:\Windows\Media\tada.wav # 任务完成(Stop hook)
SOUND_FILE_ASK=C:\Windows\Media\chimes.wav # Claude 问你/等你(Notification hook)
```

`SOUND_FILE_ASK` 留空则回退到 `SOUND_FILE`。系统自带音效在 `C:\Windows\Media\`(如 `tada.wav`、`chimes.wav`),也可填任意 wav/mp3(建议短音效,1~3 秒)。

hook 命令里 `--event ask` 表示这是「等你」事件;`--sound <路径>` 可临时覆盖音效。(原来的机器人语音已换成音效播放。)

## 故障排除

- 飞书收不到:检查 webhook 地址是否完整复制了
Expand Down
223 changes: 223 additions & 0 deletions bark-notify.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
/**
* Bark 通知脚本 - iOS 推送提醒版
* 通过 Bark 服务器(默认 https://api.day.app)推送消息到 iPhone
* 扩展能力:自定义图标、时效性/重要警告级别、消息分组、通知历史归档、持续响铃、端到端加密
*/

require('dotenv').config();
const https = require('https');
const http = require('http');
const crypto = require('crypto');

/**
* 根据已解析的 Bark 配置构建推送 payload
* @param {string} title 标题
* @param {string} body 内容
* @param {Object} cfg 已按事件解析好的 Bark 配置
* @param {string} projectName 项目名(用于消息分组)
*/
function buildBarkPayload(title, body, cfg = {}, projectName = '') {
// critical(重要警告) 优先级高于普通 level
const level = cfg.critical ? 'critical' : (cfg.level || 'active');
const payload = { title, body, level };

if (cfg.icon) payload.icon = cfg.icon; // 自定义图标
if (cfg.sound) payload.sound = cfg.sound; // 推送铃声(iOS 预置/自定义音效名)
if (level === 'critical') { // 重要警告音量 0~10
const v = cfg.criticalVolume;
payload.volume = (v === undefined || v === null || Number.isNaN(v)) ? 5 : v;
}
if (cfg.groupEnabled && projectName) payload.group = projectName; // 消息分组=项目名
if (cfg.isArchive === '1' || cfg.isArchive === '0') payload.isArchive = cfg.isArchive; // 归档
if (cfg.call) payload.call = '1'; // 持续响铃(~30s)

return payload;
}

/**
* AES 加密 payload(Bark 端到端加密,服务器只转发密文)
* 密钥长度决定 AES-128/192/256;需与 Bark App「推送加密」里的算法/密钥一致
* @returns {{ciphertext:string, iv:(string|null)}}
*/
function encryptPayload(plaintext, key, mode = 'CBC', fixedIv = '') {
const keyBuf = Buffer.from(key, 'utf8');
const bits = keyBuf.length * 8;
if (![128, 192, 256].includes(bits)) {
throw new Error(`BARK_ENCRYPT_KEY 长度必须是 16/24/32 字符,当前 ${keyBuf.length} 字符`);
}
const m = (mode || 'CBC').toUpperCase();
if (m === 'CBC') {
// 固定 IV(BARK_ENCRYPT_IV) 优先,否则每次随机生成;两种都会放进 iv 参数一起发
const iv = fixedIv || crypto.randomBytes(8).toString('hex'); // 16 个 ASCII 字符
if (Buffer.byteLength(iv, 'utf8') !== 16) {
throw new Error(`BARK_ENCRYPT_IV 必须是 16 字符,当前 ${Buffer.byteLength(iv, 'utf8')} 字符`);
}
const cipher = crypto.createCipheriv(`aes-${bits}-cbc`, keyBuf, Buffer.from(iv, 'utf8'));
let ct = cipher.update(plaintext, 'utf8', 'base64');
ct += cipher.final('base64');
return { ciphertext: ct, iv };
}
if (m === 'ECB') {
const cipher = crypto.createCipheriv(`aes-${bits}-ecb`, keyBuf, null);
let ct = cipher.update(plaintext, 'utf8', 'base64');
ct += cipher.final('base64');
return { ciphertext: ct, iv: null };
}
throw new Error(`暂不支持的加密模式: ${m}(本脚本支持 CBC / ECB)`);
}

/**
* Bark 推送通知类
*/
class BarkNotifier {
/**
* @param {Object} cfg 已解析的 Bark 配置(含 key/server/encryptKey 等)
*/
constructor(cfg = {}) {
this.cfg = cfg || {};
this.deviceKey = this.cfg.key || '';
this.server = (this.cfg.server || 'https://api.day.app').replace(/\/+$/, '');
}

/**
* 发送 payload(配置了 encryptKey 则自动加密)
* @returns {Promise<boolean>}
*/
async send(payload) {
if (this.cfg.encryptKey) {
const { ciphertext, iv } = encryptPayload(
JSON.stringify(payload), this.cfg.encryptKey, this.cfg.encryptMode, this.cfg.encryptIv
);
let form = 'ciphertext=' + encodeURIComponent(ciphertext);
if (iv) form += '&iv=' + encodeURIComponent(iv);
return this._request(form, 'application/x-www-form-urlencoded');
}
return this._request(JSON.stringify(payload), 'application/json; charset=utf-8');
}

/**
* 发送 HTTP 请求到 Bark:POST ${server}/${deviceKey}
*/
_request(data, contentType) {
return new Promise((resolve) => {
const url = new URL(`${this.server}/${this.deviceKey}`);
const options = {
hostname: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: url.pathname + url.search,
method: 'POST',
headers: {
'Content-Type': contentType,
'Content-Length': Buffer.byteLength(data)
}
};
const protocol = url.protocol === 'https:' ? https : http;

const req = protocol.request(options, (res) => {
let responseData = '';
res.on('data', (chunk) => { responseData += chunk; });
res.on('end', () => {
try {
const result = JSON.parse(responseData);
if (result.code === 200) {
console.log('✅ Bark 通知发送成功');
resolve(true);
} else {
console.error('❌ Bark 通知发送失败:', result.message || responseData);
resolve(false);
}
} catch (error) {
console.error('❌ 解析 Bark 响应失败:', responseData || error.message);
resolve(false);
}
});
});

req.on('error', (error) => {
console.error('❌ 发送 Bark 请求失败:', error.message);
resolve(false);
});

req.write(data);
req.end();
});
}
}

/**
* 任务完成通知函数
* @param {string} taskInfo 任务信息
* @param {Object} barkConfig 已按事件解析的 Bark 配置 { key, server, icon, level, ... }
* @param {string} projectName 项目名称
* @returns {Promise<boolean>}
*/
async function notifyTaskCompletion(taskInfo = 'Claude Code任务已完成', barkConfig = {}, projectName = '') {
const deviceKey = barkConfig.key || process.env.BARK_KEY || '';
const server = barkConfig.server || process.env.BARK_SERVER || 'https://api.day.app';

if (!deviceKey || deviceKey.includes('your_bark_device_key_here')) {
console.log('⚠️ 请先配置 Bark 设备 key');
console.log('📝 打开 Bark App 首页复制「设备 key」,在 .env 中设置 BARK_KEY');
return false;
}

const cfg = { ...barkConfig, key: deviceKey, server };
const notifier = new BarkNotifier(cfg);

const title = projectName || 'Claude Code';
const body = `${taskInfo}\n${new Date().toLocaleString('zh-CN')}`;
const payload = buildBarkPayload(title, body, cfg, projectName);

try {
const success = await notifier.send(payload);
if (success) {
console.log('🎉 任务完成通知已推送到 Bark!');
console.log('📱 您的 iPhone 将收到推送通知');
} else {
console.log('❌ Bark 通知发送失败,请检查 BARK_KEY / BARK_SERVER / 加密配置');
}
return success;
} catch (error) {
console.error('❌ 发送 Bark 通知时发生错误:', error.message);
return false;
}
}

/**
* 获取命令行参数
*/
function getCommandLineArgs() {
const args = process.argv.slice(2);
const options = {};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg.startsWith('--')) {
const key = arg.slice(2);
const value = args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : true;
options[key] = value;
if (value !== true) i++;
}
}
return options;
}

// 如果直接运行此脚本(按 Stop 事件的默认策略解析扩展项)
if (require.main === module) {
const options = getCommandLineArgs();
const taskInfo = options.message || options.task || 'Claude Code任务已完成';
const { envConfig } = require('./env-config');
const bark = envConfig.getBarkConfig();
bark.isArchive = bark.archiveStop ? '1' : '0';
bark.critical = (bark.criticalScope === 'all' || bark.criticalScope === 'stop');
bark.call = (bark.callScope === 'all' || bark.callScope === 'stop');

console.log('🚀 开始发送 Bark 通知...');
notifyTaskCompletion(taskInfo, bark, options.project || '');
}

module.exports = {
BarkNotifier,
buildBarkPayload,
encryptPayload,
notifyTaskCompletion
};
Loading