From 42fd907841516ebd7b53e66ffbc142829d025c4e Mon Sep 17 00:00:00 2001 From: PAKingdom <68528277+PAKingdom@users.noreply.github.com> Date: Wed, 8 Jul 2026 05:04:44 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20Bark(iOS)=20=E6=8E=A8?= =?UTF-8?q?=E9=80=81=E9=80=9A=E7=9F=A5=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 bark-notify.js:BarkNotifier + notifyTaskCompletion,POST JSON 到 ${server}/${key} - env-config.js 增加 getBarkConfig(),纳入 getAllConfig() - notification-manager.js 注册 bark 通知器,补全名称/图标/结果汇总 - notify-system.js 将 bark 配置透传给 NotificationManager - .env.example / README.md / SETUP.md 补充 Bark 配置说明 Co-Authored-By: Claude Opus 4.8 --- .env.example | 9 ++ README.md | 2 +- SETUP.md | 12 +++ bark-notify.js | 176 ++++++++++++++++++++++++++++++++++++++++ env-config.js | 13 +++ notification-manager.js | 20 ++++- notify-system.js | 1 + 7 files changed, 231 insertions(+), 2 deletions(-) create mode 100644 bark-notify.js diff --git a/.env.example b/.env.example index dc7ea7d..fa5e266 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,15 @@ 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 + # HTTP代理配置(可选) # 如果需要通过代理访问Telegram API,请配置以下选项之一 # 支持的格式: diff --git a/README.md b/README.md index 19803f5..deb6b5a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Claude Code 完成任务时自动发通知到手机/手环,你不用一直盯着屏幕等。 -支持飞书 Webhook、Telegram Bot、Windows 声音提醒。 +支持飞书 Webhook、Telegram Bot、Bark(iOS 推送)、Windows 声音提醒。 ## 一句话配置 diff --git a/SETUP.md b/SETUP.md index eef86fc..5c2913d 100644 --- a/SETUP.md +++ b/SETUP.md @@ -24,6 +24,18 @@ 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 "测试"`。 + ## 声音提醒 默认开启,仅支持 Windows。不需要的话设 `SOUND_ENABLED=false`。 diff --git a/bark-notify.js b/bark-notify.js new file mode 100644 index 0000000..d5e02fd --- /dev/null +++ b/bark-notify.js @@ -0,0 +1,176 @@ +/** + * Bark 通知脚本 - iOS 推送提醒版 + * 通过 Bark 服务器(默认 https://api.day.app)推送消息到 iPhone + */ + +require('dotenv').config(); +const https = require('https'); +const http = require('http'); + +/** + * Bark 推送通知类 + */ +class BarkNotifier { + /** + * 构造函数 + * @param {string} deviceKey - Bark 设备 key(App 首页复制) + * @param {string} server - Bark 服务器地址,默认 https://api.day.app + */ + constructor(deviceKey, server = 'https://api.day.app') { + this.deviceKey = deviceKey; + // 去掉结尾斜杠,避免拼出双斜杠 + this.server = (server || 'https://api.day.app').replace(/\/+$/, ''); + } + + /** + * 发送推送到 Bark + * @param {string} title - 通知标题 + * @param {string} body - 通知内容 + * @param {Object} options - 额外选项(group / sound / icon / level 等) + * @returns {Promise} 发送是否成功 + */ + async send(title, body, options = {}) { + const payload = { + title: title, + body: body, + group: 'Claude Code', + ...options + }; + + return this._sendPayload(payload); + } + + /** + * 发送 HTTP 请求到 Bark 服务器 + * POST ${server}/${deviceKey} body: {title, body, ...} + * @param {Object} payload - 请求载荷 + * @returns {Promise} 发送是否成功 + */ + _sendPayload(payload) { + return new Promise((resolve) => { + const data = JSON.stringify(payload); + 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': 'application/json; charset=utf-8', + '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 } + * @param {string} projectName - 项目名称 + * @returns {Promise} 发送是否成功 + */ +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('📝 配置方法:'); + console.log('1. 安装 Bark App(iOS)'); + console.log('2. 打开 App 首页,复制「设备 key」(如 https://api.day.app/AbCd1234.../ 里的 AbCd1234...)'); + console.log('3. 在 .env 中设置 BARK_KEY'); + return false; + } + + const notifier = new BarkNotifier(deviceKey, server); + + const title = projectName ? `${projectName}` : 'Claude Code'; + const body = `${taskInfo}\n${new Date().toLocaleString('zh-CN')}`; + + try { + const success = await notifier.send(title, body); + + 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; +} + +// 如果直接运行此脚本 +if (require.main === module) { + const options = getCommandLineArgs(); + const taskInfo = options.message || options.task || 'Claude Code任务已完成'; + + console.log('🚀 开始发送 Bark 通知...'); + notifyTaskCompletion(taskInfo, {}, options.project || ''); +} + +module.exports = { + BarkNotifier, + notifyTaskCompletion +}; diff --git a/env-config.js b/env-config.js index 2a25787..feafa31 100644 --- a/env-config.js +++ b/env-config.js @@ -60,6 +60,18 @@ class EnvConfig { }; } + /** + * 获取 Bark 配置 + */ + getBarkConfig() { + const key = process.env.BARK_KEY || ''; + return { + server: process.env.BARK_SERVER || 'https://api.day.app', + key: key, + enabled: !!key && !key.includes('your_bark_device_key_here') + }; + } + /** * 获取声音通知配置 */ @@ -86,6 +98,7 @@ class EnvConfig { return { feishu: this.getFeishuConfig(), telegram: this.getTelegramConfig(), + bark: this.getBarkConfig(), sound: this.getSoundConfig(), notification: this.getNotificationConfig() }; diff --git a/notification-manager.js b/notification-manager.js index 2bd27f5..a4be980 100644 --- a/notification-manager.js +++ b/notification-manager.js @@ -5,6 +5,7 @@ const { FeishuNotifier } = require('./feishu-notify'); const { TelegramNotifier } = require('./telegram-notify'); +const { BarkNotifier } = require('./bark-notify'); /** * 通知管理器类 @@ -46,6 +47,18 @@ class NotificationManager { }; } + // Bark通知器 + if (this.config.notification.bark && this.config.notification.bark.enabled) { + notifiers.bark = { + enabled: true, + notifier: new BarkNotifier(this.config.notification.bark.key, this.config.notification.bark.server), + send: async (taskInfo) => { + const { notifyTaskCompletion } = require('./bark-notify'); + return await notifyTaskCompletion(taskInfo, this.config.notification.bark, this.projectName); + } + }; + } + return notifiers; } @@ -97,6 +110,7 @@ class NotificationManager { const typeNames = { feishu: '飞书通知', telegram: 'Telegram通知', + bark: 'Bark 通知', sound: '声音提醒' }; return typeNames[type] || type; @@ -114,7 +128,7 @@ class NotificationManager { const typeName = this.getTypeName(type); const result = results[index]; const status = result && result.value && result.value.success ? '✅ 成功' : '❌ 失败'; - const icon = type === 'feishu' ? '📱' : type === 'telegram' ? '📲' : '🔊'; + const icon = type === 'feishu' ? '📱' : type === 'telegram' ? '📲' : type === 'bark' ? '📲' : '🔊'; console.log(` ${icon} ${typeName}:${status}`); }); @@ -127,6 +141,9 @@ class NotificationManager { if (this.notifiers.telegram) { console.log(' 📲 Telegram将收到推送通知'); } + if (this.notifiers.bark) { + console.log(' 📱 iPhone 将收到 Bark 推送通知'); + } console.log(''); } @@ -137,6 +154,7 @@ class NotificationManager { const icons = []; if (this.notifiers.feishu) icons.push('📱'); if (this.notifiers.telegram) icons.push('📲'); + if (this.notifiers.bark) icons.push('📲'); return icons.join(' '); } } diff --git a/notify-system.js b/notify-system.js index a18559d..755a55a 100644 --- a/notify-system.js +++ b/notify-system.js @@ -29,6 +29,7 @@ class NotificationSystem { type: envVars.feishu.enabled ? 'feishu' : 'sound', feishu: envVars.feishu, telegram: envVars.telegram, + bark: envVars.bark, sound: envVars.sound } }; From d4aff0bb54cb0c7954af6b8bdc2784f1ca2d7d1f Mon Sep 17 00:00:00 2001 From: PAKingdom <68528277+PAKingdom@users.noreply.github.com> Date: Wed, 8 Jul 2026 05:04:45 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=E5=A3=B0=E9=9F=B3=E6=8F=90=E9=86=92?= =?UTF-8?q?=E6=94=B9=E7=94=A8=E9=9F=B3=E6=95=88=E6=96=87=E4=BB=B6=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=20mp3=20=E4=B8=8E=20--sound=20=E8=A6=86?= =?UTF-8?q?=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - notify-system.js:移除机器人语音,改播 wav/mp3 音效(.wav 走 SoundPlayer, mp3/m4a/wma 走 MediaPlayer);新增 --sound 命令行参数覆盖 SOUND_FILE, 可给不同 hook 配不同音效;静音黑窗、detached 让长音频播完不被打断 - env-config.js:SOUND_FILE 环境变量纳入声音配置 - .env.example / SETUP.md:补充 SOUND_FILE(支持 wav/mp3)说明 Co-Authored-By: Claude Opus 4.8 --- .env.example | 6 ++++++ SETUP.md | 10 ++++++++++ env-config.js | 1 + notify-system.js | 45 +++++++++++++++++++++++++++++++++++++-------- 4 files changed, 54 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index fa5e266..9ef4fa1 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,12 @@ NOTIFICATION_ENABLED=true # 是否启用声音提醒 (true/false) SOUND_ENABLED=true +# 声音音效(可选,支持 .wav / .mp3):留空=默认 Windows 通知音 +# 换音效就填任意 wav 或 mp3 路径,例如: +# SOUND_FILE=C:\Windows\Media\tada.wav +# SOUND_FILE=C:\Users\你\Music\alert.mp3 +# SOUND_FILE= + # Telegram Bot配置 # 获取方法: # 1. 与 @BotFather 对话创建机器人,获取 token diff --git a/SETUP.md b/SETUP.md index 5c2913d..add1904 100644 --- a/SETUP.md +++ b/SETUP.md @@ -40,6 +40,16 @@ BARK_SERVER=https://api.day.app 默认开启,仅支持 Windows。不需要的话设 `SOUND_ENABLED=false`。 +想换音效:在 `.env` 里设 `SOUND_FILE`,留空则用默认 Windows 通知音。**支持 `.wav` 和 `.mp3`**(`.wav` 走 SoundPlayer,`.mp3`/`.m4a`/`.wma` 走 MediaPlayer)。系统自带音效在 `C:\Windows\Media\`,例如: + +```bash +SOUND_FILE=C:\Windows\Media\tada.wav # 欢快的“ta-da” +SOUND_FILE=C:\Windows\Media\chimes.wav # 清脆风铃 +SOUND_FILE=C:\Users\你\Music\alert.mp3 # 你自己的 mp3 +``` + +也可以填任意 wav/mp3 文件路径(建议短音效,1~3 秒)。(原来的机器人语音已换成音效播放。) + ## 故障排除 - 飞书收不到:检查 webhook 地址是否完整复制了 diff --git a/env-config.js b/env-config.js index feafa31..c56e438 100644 --- a/env-config.js +++ b/env-config.js @@ -78,6 +78,7 @@ class EnvConfig { getSoundConfig() { return { enabled: process.env.SOUND_ENABLED !== 'false', + file: process.env.SOUND_FILE || '', backup: true }; } diff --git a/notify-system.js b/notify-system.js index 755a55a..62ec99d 100644 --- a/notify-system.js +++ b/notify-system.js @@ -13,7 +13,8 @@ const { NotificationManager } = require('./notification-manager'); * 通知系统管理器 */ class NotificationSystem { - constructor() { + constructor(options = {}) { + this.options = options || {}; this.config = this.loadConfig(); this.projectName = this.getProjectName(); this.notificationManager = new NotificationManager(this.config, this.projectName); @@ -24,13 +25,18 @@ class NotificationSystem { */ loadConfig() { const envVars = envConfig.getAllConfig(); + const sound = { ...envVars.sound }; + // 命令行 --sound 覆盖 .env 的 SOUND_FILE(可给不同 hook 配不同音效) + if (typeof this.options.sound === 'string' && this.options.sound) { + sound.file = this.options.sound; + } return { notification: { type: envVars.feishu.enabled ? 'feishu' : 'sound', feishu: envVars.feishu, telegram: envVars.telegram, bark: envVars.bark, - sound: envVars.sound + sound: sound } }; } @@ -83,11 +89,33 @@ class NotificationSystem { * 播放Windows系统声音 */ playWindowsSound() { - const psScript = `Add-Type -AssemblyName System.Speech; (New-Object System.Speech.Synthesis.SpeechSynthesizer).Speak("任务完成,已发送手机通知"); [console]::Beep(800, 300)`; + // SOUND_FILE 未配置时用默认 Windows 通知音 + const defaultSound = 'C:\\Windows\\Media\\Windows Notify System Generic.wav'; + const soundFile = this.config.notification.sound.file || defaultSound; + const safePath = soundFile.replace(/'/g, "''"); // 转义 PowerShell 单引号 + + let psScript; + if (/\.wav$/i.test(soundFile)) { + // .wav:SoundPlayer 同步播放,快且稳 + psScript = `try { (New-Object Media.SoundPlayer '${safePath}').PlaySync() } catch { [console]::Beep(800, 300) }`; + } else { + // .mp3/.m4a/.wma 等:用 MediaPlayer 播放(等到时长可读后播完再退出) + psScript = `try {` + + ` Add-Type -AssemblyName PresentationCore;` + + ` $p = New-Object System.Windows.Media.MediaPlayer;` + + ` $p.Open([uri]::new('${safePath}'));` + + ` $t = 0; while (-not $p.NaturalDuration.HasTimeSpan -and $t -lt 50) { Start-Sleep -Milliseconds 100; $t++ };` + + ` $p.Play();` + + ` if ($p.NaturalDuration.HasTimeSpan) { Start-Sleep -Milliseconds ([int]$p.NaturalDuration.TimeSpan.TotalMilliseconds + 300) } else { Start-Sleep -Seconds 3 };` + + ` $p.Close()` + + ` } catch { [console]::Beep(800, 300) }`; + } - return spawn('powershell', ['-Command', psScript], { + return spawn('powershell', ['-NoProfile', '-Command', psScript], { stdio: 'ignore', - shell: false + shell: false, + detached: true, // 让音效在本进程退出后也能播完(长音频不被打断) + windowsHide: true // 不闪黑窗 }); } @@ -96,9 +124,10 @@ class NotificationSystem { */ playBeep() { const psScript = '[console]::Beep(800, 500)'; - return spawn('powershell', ['-Command', psScript], { + return spawn('powershell', ['-NoProfile', '-Command', psScript], { stdio: 'ignore', - shell: false + shell: false, + windowsHide: true }); } @@ -257,7 +286,7 @@ if (require.main === module) { const options = getCommandLineArgs(); const taskInfo = buildMessageFromContext(options); - const notifier = new NotificationSystem(); + const notifier = new NotificationSystem(options); notifier.sendAllNotifications(taskInfo); } From 947d57e95aea3dd06ea4d1345e2b94f0f4ddce82 Mon Sep 17 00:00:00 2001 From: PAKingdom <68528277+PAKingdom@users.noreply.github.com> Date: Wed, 8 Jul 2026 05:15:30 +0800 Subject: [PATCH 3/6] =?UTF-8?q?Bark=20=E6=89=A9=E5=B1=95=EF=BC=9A=E5=8A=A0?= =?UTF-8?q?=E5=AF=86=20/=20=E8=87=AA=E5=AE=9A=E4=B9=89=E5=9B=BE=E6=A0=87?= =?UTF-8?q?=20/=20=E6=97=B6=E6=95=88=E6=80=A7=20/=20=E9=87=8D=E8=A6=81?= =?UTF-8?q?=E8=AD=A6=E5=91=8A=20/=20=E5=88=86=E7=BB=84=20/=20=E5=BD=92?= =?UTF-8?q?=E6=A1=A3=20/=20=E6=8C=81=E7=BB=AD=E5=93=8D=E9=93=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bark-notify.js:重写 payload 构建,支持 icon(默认 Claude 图标)、level(时效性/重要警告)、 group(项目名分组)、isArchive(归档)、call(持续响铃);新增 AES-CBC/ECB 端到端加密(crypto), 支持固定 IV(BARK_ENCRYPT_IV)或每次随机 - env-config.js:getBarkConfig 增加图标/级别/分组/归档/重要警告/响铃/加密(含 IV)等配置项 - notify-system.js:按事件(Stop/ask)解析归档、重要警告、持续响铃; 修复交互式终端下 --task 读 stdin 卡死(改为仅管道输入时读); mp3 缺失/加载失败时蜂鸣兜底,避免静默无声 - notification-manager.js:BarkNotifier 改用配置对象构造 - .env.example / SETUP.md:补充 Bark 扩展功能文档,并标注固定 IV 的隐私权衡 Co-Authored-By: Claude Opus 4.8 --- .env.example | 30 +++++++- SETUP.md | 30 ++++++-- bark-notify.js | 154 ++++++++++++++++++++++++++-------------- env-config.js | 23 +++++- notification-manager.js | 2 +- notify-system.js | 94 +++++++++++++++++------- 6 files changed, 245 insertions(+), 88 deletions(-) diff --git a/.env.example b/.env.example index 9ef4fa1..66cc3f4 100644 --- a/.env.example +++ b/.env.example @@ -13,10 +13,13 @@ NOTIFICATION_ENABLED=true SOUND_ENABLED=true # 声音音效(可选,支持 .wav / .mp3):留空=默认 Windows 通知音 -# 换音效就填任意 wav 或 mp3 路径,例如: +# SOUND_FILE —— 任务完成(Stop hook)时播放 +# SOUND_FILE_ASK —— Claude 问你 / 等你授权(Notification hook)时播放;留空则回退到 SOUND_FILE +# 例如: # SOUND_FILE=C:\Windows\Media\tada.wav -# SOUND_FILE=C:\Users\你\Music\alert.mp3 +# SOUND_FILE_ASK=C:\Windows\Media\chimes.wav # SOUND_FILE= +# SOUND_FILE_ASK= # Telegram Bot配置 # 获取方法: @@ -35,6 +38,29 @@ TELEGRAM_CHAT_ID=your_chat_id_here 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 +# 通知级别: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,请配置以下选项之一 # 支持的格式: diff --git a/SETUP.md b/SETUP.md index add1904..317be39 100644 --- a/SETUP.md +++ b/SETUP.md @@ -36,19 +36,39 @@ BARK_SERVER=https://api.day.app `BARK_SERVER` 默认官方服务器,自建 Bark 服务器时才需要改。测试:`node notify-system.js --task "测试"`。 +### Bark 扩展功能(可选) + +都在 `.env` 里配置,按事件(Stop=任务完成 / ask=Claude 等你)区分: + +| 变量 | 作用 | 默认 | +|------|------|------| +| `BARK_ICON` | 通知图标 URL | Claude 图标 | +| `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` 里设 `SOUND_FILE`,留空则用默认 Windows 通知音。**支持 `.wav` 和 `.mp3`**(`.wav` 走 SoundPlayer,`.mp3`/`.m4a`/`.wma` 走 MediaPlayer)。系统自带音效在 `C:\Windows\Media\`,例如: +想换音效:在 `.env` 里设音效路径,留空则用默认 Windows 通知音。**支持 `.wav` 和 `.mp3`**(`.wav` 走 SoundPlayer,`.mp3`/`.m4a`/`.wma` 走 MediaPlayer)。可给两类事件分别配音效: ```bash -SOUND_FILE=C:\Windows\Media\tada.wav # 欢快的“ta-da” -SOUND_FILE=C:\Windows\Media\chimes.wav # 清脆风铃 -SOUND_FILE=C:\Users\你\Music\alert.mp3 # 你自己的 mp3 +SOUND_FILE=C:\Windows\Media\tada.wav # 任务完成(Stop hook) +SOUND_FILE_ASK=C:\Windows\Media\chimes.wav # Claude 问你/等你(Notification hook) ``` -也可以填任意 wav/mp3 文件路径(建议短音效,1~3 秒)。(原来的机器人语音已换成音效播放。) +`SOUND_FILE_ASK` 留空则回退到 `SOUND_FILE`。系统自带音效在 `C:\Windows\Media\`(如 `tada.wav`、`chimes.wav`),也可填任意 wav/mp3(建议短音效,1~3 秒)。 + +hook 命令里 `--event ask` 表示这是「等你」事件;`--sound <路径>` 可临时覆盖音效。(原来的机器人语音已换成音效播放。) ## 故障排除 diff --git a/bark-notify.js b/bark-notify.js index d5e02fd..2130b02 100644 --- a/bark-notify.js +++ b/bark-notify.js @@ -1,76 +1,120 @@ /** * 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 (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 {string} deviceKey - Bark 设备 key(App 首页复制) - * @param {string} server - Bark 服务器地址,默认 https://api.day.app + * @param {Object} cfg 已解析的 Bark 配置(含 key/server/encryptKey 等) */ - constructor(deviceKey, server = 'https://api.day.app') { - this.deviceKey = deviceKey; - // 去掉结尾斜杠,避免拼出双斜杠 - this.server = (server || 'https://api.day.app').replace(/\/+$/, ''); + constructor(cfg = {}) { + this.cfg = cfg || {}; + this.deviceKey = this.cfg.key || ''; + this.server = (this.cfg.server || 'https://api.day.app').replace(/\/+$/, ''); } /** - * 发送推送到 Bark - * @param {string} title - 通知标题 - * @param {string} body - 通知内容 - * @param {Object} options - 额外选项(group / sound / icon / level 等) - * @returns {Promise} 发送是否成功 + * 发送 payload(配置了 encryptKey 则自动加密) + * @returns {Promise} */ - async send(title, body, options = {}) { - const payload = { - title: title, - body: body, - group: 'Claude Code', - ...options - }; - - return this._sendPayload(payload); + 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} body: {title, body, ...} - * @param {Object} payload - 请求载荷 - * @returns {Promise} 发送是否成功 + * 发送 HTTP 请求到 Bark:POST ${server}/${deviceKey} */ - _sendPayload(payload) { + _request(data, contentType) { return new Promise((resolve) => { - const data = JSON.stringify(payload); 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': 'application/json; charset=utf-8', + '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('data', (chunk) => { responseData += chunk; }); res.on('end', () => { try { const result = JSON.parse(responseData); @@ -101,10 +145,10 @@ class BarkNotifier { /** * 任务完成通知函数 - * @param {string} taskInfo - 任务信息 - * @param {Object} barkConfig - Bark 配置 { key, server } - * @param {string} projectName - 项目名称 - * @returns {Promise} 发送是否成功 + * @param {string} taskInfo 任务信息 + * @param {Object} barkConfig 已按事件解析的 Bark 配置 { key, server, icon, level, ... } + * @param {string} projectName 项目名称 + * @returns {Promise} */ async function notifyTaskCompletion(taskInfo = 'Claude Code任务已完成', barkConfig = {}, projectName = '') { const deviceKey = barkConfig.key || process.env.BARK_KEY || ''; @@ -112,28 +156,25 @@ async function notifyTaskCompletion(taskInfo = 'Claude Code任务已完成', bar if (!deviceKey || deviceKey.includes('your_bark_device_key_here')) { console.log('⚠️ 请先配置 Bark 设备 key'); - console.log('📝 配置方法:'); - console.log('1. 安装 Bark App(iOS)'); - console.log('2. 打开 App 首页,复制「设备 key」(如 https://api.day.app/AbCd1234.../ 里的 AbCd1234...)'); - console.log('3. 在 .env 中设置 BARK_KEY'); + console.log('📝 打开 Bark App 首页复制「设备 key」,在 .env 中设置 BARK_KEY'); return false; } - const notifier = new BarkNotifier(deviceKey, server); + const cfg = { ...barkConfig, key: deviceKey, server }; + const notifier = new BarkNotifier(cfg); - const title = projectName ? `${projectName}` : 'Claude Code'; + 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(title, body); - + const success = await notifier.send(payload); if (success) { console.log('🎉 任务完成通知已推送到 Bark!'); console.log('📱 您的 iPhone 将收到推送通知'); } else { - console.log('❌ Bark 通知发送失败,请检查 BARK_KEY / BARK_SERVER 配置'); + console.log('❌ Bark 通知发送失败,请检查 BARK_KEY / BARK_SERVER / 加密配置'); } - return success; } catch (error) { console.error('❌ 发送 Bark 通知时发生错误:', error.message); @@ -147,30 +188,35 @@ async function notifyTaskCompletion(taskInfo = 'Claude Code任务已完成', bar 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++; // 跳过下一个参数,因为它已经被当作值处理了 + 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, {}, options.project || ''); + notifyTaskCompletion(taskInfo, bark, options.project || ''); } module.exports = { BarkNotifier, + buildBarkPayload, + encryptPayload, notifyTaskCompletion }; diff --git a/env-config.js b/env-config.js index c56e438..53f13f3 100644 --- a/env-config.js +++ b/env-config.js @@ -68,7 +68,25 @@ class EnvConfig { return { server: process.env.BARK_SERVER || 'https://api.day.app', key: key, - enabled: !!key && !key.includes('your_bark_device_key_here') + enabled: !!key && !key.includes('your_bark_device_key_here'), + // 自定义图标(默认 Claude 图标) + icon: process.env.BARK_ICON || 'https://claude.ai/apple-touch-icon.png', + // 通知级别:active / timeSensitive(时效性) / passive + level: process.env.BARK_LEVEL || 'timeSensitive', + // 消息分组(默认启用,用项目名分组) + groupEnabled: process.env.BARK_GROUP_ENABLED !== 'false', + // 自动保存到通知历史:Stop 默认存、AskUserQuestion 默认不存 + archiveStop: process.env.BARK_ARCHIVE_STOP !== 'false', + archiveAsk: process.env.BARK_ARCHIVE_ASK === 'true', + // 重要警告(critical):off / stop / ask / all,默认 off + criticalScope: (process.env.BARK_CRITICAL || 'off').toLowerCase(), + criticalVolume: parseInt(process.env.BARK_CRITICAL_VOLUME || '5', 10), + // 持续响铃(call,~30s):off / stop / ask / all,默认 off + callScope: (process.env.BARK_CALL || 'off').toLowerCase(), + // 端到端加密(可选):设了密钥即开启;IV 留空则每次随机生成 + encryptKey: process.env.BARK_ENCRYPT_KEY || '', + encryptIv: process.env.BARK_ENCRYPT_IV || '', + encryptMode: (process.env.BARK_ENCRYPT_MODE || 'CBC').toUpperCase() }; } @@ -78,7 +96,8 @@ class EnvConfig { getSoundConfig() { return { enabled: process.env.SOUND_ENABLED !== 'false', - file: process.env.SOUND_FILE || '', + file: process.env.SOUND_FILE || '', // 任务完成(Stop)音效 + fileAsk: process.env.SOUND_FILE_ASK || '', // Claude 等你(Notification)音效 backup: true }; } diff --git a/notification-manager.js b/notification-manager.js index a4be980..ec5082e 100644 --- a/notification-manager.js +++ b/notification-manager.js @@ -51,7 +51,7 @@ class NotificationManager { if (this.config.notification.bark && this.config.notification.bark.enabled) { notifiers.bark = { enabled: true, - notifier: new BarkNotifier(this.config.notification.bark.key, this.config.notification.bark.server), + notifier: new BarkNotifier(this.config.notification.bark), send: async (taskInfo) => { const { notifyTaskCompletion } = require('./bark-notify'); return await notifyTaskCompletion(taskInfo, this.config.notification.bark, this.projectName); diff --git a/notify-system.js b/notify-system.js index 62ec99d..ea033ee 100644 --- a/notify-system.js +++ b/notify-system.js @@ -26,21 +26,38 @@ class NotificationSystem { loadConfig() { const envVars = envConfig.getAllConfig(); const sound = { ...envVars.sound }; - // 命令行 --sound 覆盖 .env 的 SOUND_FILE(可给不同 hook 配不同音效) + // 音效优先级:--sound 显式路径 > ask 事件用 SOUND_FILE_ASK > 默认 SOUND_FILE if (typeof this.options.sound === 'string' && this.options.sound) { sound.file = this.options.sound; + } else if (this.options.ask && envVars.sound.fileAsk) { + sound.file = envVars.sound.fileAsk; } return { notification: { type: envVars.feishu.enabled ? 'feishu' : 'sound', feishu: envVars.feishu, telegram: envVars.telegram, - bark: envVars.bark, + bark: this.resolveBark(envVars.bark), sound: sound } }; } + /** + * 按当前事件(ask / stop)解析 Bark 的按事件差异项: + * 归档(isArchive)、重要警告(critical)、持续响铃(call) + */ + resolveBark(bark) { + const eventKey = this.options.ask ? 'ask' : 'stop'; + const inScope = (scope) => scope === 'all' || scope === eventKey; + return { + ...bark, + isArchive: (eventKey === 'stop' ? bark.archiveStop : bark.archiveAsk) ? '1' : '0', + critical: inScope(bark.criticalScope), + call: inScope(bark.callScope) + }; + } + /** * 获取项目名称 * 优先级: package.json > git仓库名 > 目录名 @@ -100,14 +117,16 @@ class NotificationSystem { psScript = `try { (New-Object Media.SoundPlayer '${safePath}').PlaySync() } catch { [console]::Beep(800, 300) }`; } else { // .mp3/.m4a/.wma 等:用 MediaPlayer 播放(等到时长可读后播完再退出) + // MediaPlayer.Open 是异步的,文件缺失/损坏不会抛异常,故:先 Test-Path 兜底, + // 且时长始终读不到时也蜂鸣兜底,避免静默失败(无声也无 beep) psScript = `try {` + + ` if (-not (Test-Path -LiteralPath '${safePath}')) { [console]::Beep(800, 300) } else {` + ` Add-Type -AssemblyName PresentationCore;` + ` $p = New-Object System.Windows.Media.MediaPlayer;` + ` $p.Open([uri]::new('${safePath}'));` + ` $t = 0; while (-not $p.NaturalDuration.HasTimeSpan -and $t -lt 50) { Start-Sleep -Milliseconds 100; $t++ };` + - ` $p.Play();` + - ` if ($p.NaturalDuration.HasTimeSpan) { Start-Sleep -Milliseconds ([int]$p.NaturalDuration.TimeSpan.TotalMilliseconds + 300) } else { Start-Sleep -Seconds 3 };` + - ` $p.Close()` + + ` if ($p.NaturalDuration.HasTimeSpan) { $p.Play(); Start-Sleep -Milliseconds ([int]$p.NaturalDuration.TimeSpan.TotalMilliseconds + 300) } else { [console]::Beep(800, 300) };` + + ` $p.Close() }` + ` } catch { [console]::Beep(800, 300) }`; } @@ -250,32 +269,54 @@ function readStdinSync() { } } +/** + * 读取并解析 stdin 里的 Claude hook 上下文(只能读一次,故集中解析) + * @returns {Object} 解析后的上下文对象;非 JSON 或无输入时返回 {} + */ +function readStdinContext() { + const raw = readStdinSync(); + if (!raw) return {}; + try { + return JSON.parse(raw); + } catch { + return {}; + } +} + +/** + * 判断是否为「Claude 在等你」类事件(Notification hook) + * 依次看:命令行 --event ask > stdin hook_event_name > stdin 含 message 字段的启发式 + */ +function isAskEvent(options, ctx) { + if (options.event === 'ask') return true; + if (ctx && ctx.hook_event_name === 'Notification') return true; + if (ctx && typeof ctx.message === 'string' && !ctx.last_assistant_message) return true; + return false; +} + /** * 从 Claude 上下文生成通知消息 */ -function buildMessageFromContext(options) { +function buildMessageFromContext(options, ctx, ask) { // 1. 命令行显式指定了消息,直接用 if (options.message || options.task) { return options.message || options.task; } - // 2. 尝试从 stdin 读取 Claude Stop hook 的 JSON 上下文 - const stdin = readStdinSync(); - if (stdin) { - try { - const ctx = JSON.parse(stdin); - if (ctx.last_assistant_message) { - const text = ctx.last_assistant_message - .split('\n') - .filter(line => line.trim() && !line.startsWith('#')) - .slice(0, 5) - .join(' ') - .slice(0, 4000); - return text || '任务完成'; - } - } catch { - // stdin 不是 JSON,忽略 - } + // 2. Notification(等你)事件:用 stdin 的 message,退回默认提示 + if (ask) { + return (ctx && ctx.message) || '⏳ Claude 在等你回复 / 授权'; + } + + // 3. Stop:尝试从上下文提取最后一条助手消息 + if (ctx && ctx.last_assistant_message) { + const text = ctx.last_assistant_message + .split('\n') + .filter(line => line.trim() && !line.startsWith('#')) + .slice(0, 5) + .join(' ') + .slice(0, 4000); + return text || '任务完成'; } return '任务完成'; @@ -284,7 +325,12 @@ function buildMessageFromContext(options) { // 如果直接运行此脚本 if (require.main === module) { const options = getCommandLineArgs(); - const taskInfo = buildMessageFromContext(options); + // 只在 stdin 被管道输入时读取(hook 会传 JSON);交互式终端(TTY)下不读, + // 否则 readFileSync(0) 会阻塞等 EOF,导致 `notify-system.js --task "测试"` 卡死 + const ctx = process.stdin.isTTY ? {} : readStdinContext(); + const ask = isAskEvent(options, ctx); + options.ask = ask; // 供 loadConfig 选择 SOUND_FILE_ASK + const taskInfo = buildMessageFromContext(options, ctx, ask); const notifier = new NotificationSystem(options); notifier.sendAllNotifications(taskInfo); From 7e80263b3eaaf3423bff3e09b505f517d44b8fb5 Mon Sep 17 00:00:00 2001 From: PAKingdom <68528277+PAKingdom@users.noreply.github.com> Date: Wed, 8 Jul 2026 18:44:45 +0800 Subject: [PATCH 4/6] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20hook=20=E7=8E=AF?= =?UTF-8?q?=E5=A2=83=E6=97=A0=E5=A3=B0=E9=9F=B3=EF=BC=9Apowershell=20?= =?UTF-8?q?=E6=94=B9=E7=94=A8=E7=BB=9D=E5=AF=B9=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop/Notification hook 运行时 PATH 常不含 System32,spawn('powershell') 会 ENOENT,导致「bark 能收到但 Windows 不发声」(连蜂鸣兜底也因同样原因失败)。 改用 %SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe 绝对路径定位。 Co-Authored-By: Claude Opus 4.8 --- notify-system.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/notify-system.js b/notify-system.js index ea033ee..7e2f653 100644 --- a/notify-system.js +++ b/notify-system.js @@ -9,6 +9,11 @@ const { spawn } = require('child_process'); const { envConfig } = require('./env-config'); const { NotificationManager } = require('./notification-manager'); +// hook 运行环境的 PATH 可能不含 System32,直接 spawn('powershell') 会 ENOENT, +// 导致「bark 能收到但 Windows 没声音」。故用绝对路径定位 powershell.exe。 +const POWERSHELL = path.join(process.env.SystemRoot || 'C:\\Windows', + 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); + /** * 通知系统管理器 */ @@ -130,7 +135,7 @@ class NotificationSystem { ` } catch { [console]::Beep(800, 300) }`; } - return spawn('powershell', ['-NoProfile', '-Command', psScript], { + return spawn(POWERSHELL, ['-NoProfile', '-Command', psScript], { stdio: 'ignore', shell: false, detached: true, // 让音效在本进程退出后也能播完(长音频不被打断) @@ -143,7 +148,7 @@ class NotificationSystem { */ playBeep() { const psScript = '[console]::Beep(800, 500)'; - return spawn('powershell', ['-NoProfile', '-Command', psScript], { + return spawn(POWERSHELL, ['-NoProfile', '-Command', psScript], { stdio: 'ignore', shell: false, windowsHide: true From e00d3d738ffdf0250ac2eac9419f402d10761f57 Mon Sep 17 00:00:00 2001 From: PAKingdom <68528277+PAKingdom@users.noreply.github.com> Date: Wed, 8 Jul 2026 18:53:08 +0800 Subject: [PATCH 5/6] =?UTF-8?q?Bark=20=E6=96=B0=E5=A2=9E=E6=8E=A8=E9=80=81?= =?UTF-8?q?=E9=93=83=E5=A3=B0(BARK=5FSOUND)=EF=BC=8C=E5=86=85=E7=BD=AE=20i?= =?UTF-8?q?OS=20=E9=A2=84=E7=BD=AE=E9=9F=B3=E6=95=88=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bark-notify.js:payload 支持 sound 字段(iOS 预置或自定义音效名) - env-config.js:getBarkConfig 增加 BARK_SOUND(默认空=不启用) - .env.example / SETUP.md:列出全部 iOS 预置音效名与用法(默认注释关闭) Co-Authored-By: Claude Opus 4.8 --- .env.example | 12 ++++++++++++ SETUP.md | 1 + bark-notify.js | 1 + env-config.js | 2 ++ 4 files changed, 16 insertions(+) diff --git a/.env.example b/.env.example index 66cc3f4..98fb010 100644 --- a/.env.example +++ b/.env.example @@ -41,6 +41,18 @@ 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 关闭 diff --git a/SETUP.md b/SETUP.md index 317be39..f43409f 100644 --- a/SETUP.md +++ b/SETUP.md @@ -43,6 +43,7 @@ BARK_SERVER=https://api.day.app | 变量 | 作用 | 默认 | |------|------|------| | `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 不存 | diff --git a/bark-notify.js b/bark-notify.js index 2130b02..1c42585 100644 --- a/bark-notify.js +++ b/bark-notify.js @@ -22,6 +22,7 @@ function buildBarkPayload(title, body, cfg = {}, projectName = '') { 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; diff --git a/env-config.js b/env-config.js index 53f13f3..7e8630b 100644 --- a/env-config.js +++ b/env-config.js @@ -71,6 +71,8 @@ class EnvConfig { enabled: !!key && !key.includes('your_bark_device_key_here'), // 自定义图标(默认 Claude 图标) icon: process.env.BARK_ICON || 'https://claude.ai/apple-touch-icon.png', + // 推送铃声(iOS 预置音效名或 App 里导入的自定义音效名);留空=Bark 默认 + sound: process.env.BARK_SOUND || '', // 通知级别:active / timeSensitive(时效性) / passive level: process.env.BARK_LEVEL || 'timeSensitive', // 消息分组(默认启用,用项目名分组) From 347e23cafd4903e8a5532e2125319026dcc76d0e Mon Sep 17 00:00:00 2001 From: PAKingdom <68528277+PAKingdom@users.noreply.github.com> Date: Thu, 9 Jul 2026 01:47:26 +0800 Subject: [PATCH 6/6] =?UTF-8?q?=E5=A3=B0=E9=9F=B3=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E9=98=BB=E5=A1=9E=E6=92=AD=E6=94=BE=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20hook=20=E8=A7=A6=E5=8F=91=E6=97=B6?= =?UTF-8?q?=E4=B8=8D=E5=8F=91=E5=A3=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop/Notification hook 触发时,声音原本是 detached 后台进程 + 3 秒定时退出; hook 进程被回收时该子进程会被一并 kill、还没播完就没了(bark 走 https 不受影响, 故表现为「收到 bark 但 Windows 没声音」)。改为 spawnSync 同步阻塞、播完再退出, 声音在 hook 进程存活期间即播完。实测 Stop 与 Notification 两个 hook status=0 均正常发声。 - notify-system.js:playWindowsSound 拆为 buildSoundPsScript;sendSoundNotification 改用 spawnSync 阻塞播放(powershell 仍用绝对路径),异常退回蜂鸣; sendAllNotifications 播完即退出,移除 detached 与 3 秒定时;删除无用 playBeep Co-Authored-By: Claude Opus 4.8 --- notify-system.js | 99 ++++++++++++++++-------------------------------- 1 file changed, 32 insertions(+), 67 deletions(-) diff --git a/notify-system.js b/notify-system.js index 7e2f653..c9c6c49 100644 --- a/notify-system.js +++ b/notify-system.js @@ -5,7 +5,7 @@ const fs = require('fs'); const path = require('path'); -const { spawn } = require('child_process'); +const { spawnSync } = require('child_process'); const { envConfig } = require('./env-config'); const { NotificationManager } = require('./notification-manager'); @@ -110,84 +110,54 @@ class NotificationSystem { /** * 播放Windows系统声音 */ - playWindowsSound() { + buildSoundPsScript() { // SOUND_FILE 未配置时用默认 Windows 通知音 const defaultSound = 'C:\\Windows\\Media\\Windows Notify System Generic.wav'; const soundFile = this.config.notification.sound.file || defaultSound; const safePath = soundFile.replace(/'/g, "''"); // 转义 PowerShell 单引号 - let psScript; if (/\.wav$/i.test(soundFile)) { // .wav:SoundPlayer 同步播放,快且稳 - psScript = `try { (New-Object Media.SoundPlayer '${safePath}').PlaySync() } catch { [console]::Beep(800, 300) }`; - } else { - // .mp3/.m4a/.wma 等:用 MediaPlayer 播放(等到时长可读后播完再退出) - // MediaPlayer.Open 是异步的,文件缺失/损坏不会抛异常,故:先 Test-Path 兜底, - // 且时长始终读不到时也蜂鸣兜底,避免静默失败(无声也无 beep) - psScript = `try {` + - ` if (-not (Test-Path -LiteralPath '${safePath}')) { [console]::Beep(800, 300) } else {` + - ` Add-Type -AssemblyName PresentationCore;` + - ` $p = New-Object System.Windows.Media.MediaPlayer;` + - ` $p.Open([uri]::new('${safePath}'));` + - ` $t = 0; while (-not $p.NaturalDuration.HasTimeSpan -and $t -lt 50) { Start-Sleep -Milliseconds 100; $t++ };` + - ` if ($p.NaturalDuration.HasTimeSpan) { $p.Play(); Start-Sleep -Milliseconds ([int]$p.NaturalDuration.TimeSpan.TotalMilliseconds + 300) } else { [console]::Beep(800, 300) };` + - ` $p.Close() }` + - ` } catch { [console]::Beep(800, 300) }`; + return `try { (New-Object Media.SoundPlayer '${safePath}').PlaySync() } catch { [console]::Beep(800, 300) }`; } - - return spawn(POWERSHELL, ['-NoProfile', '-Command', psScript], { - stdio: 'ignore', - shell: false, - detached: true, // 让音效在本进程退出后也能播完(长音频不被打断) - windowsHide: true // 不闪黑窗 - }); - } - - /** - * 播放蜂鸣声作为备用方案 - */ - playBeep() { - const psScript = '[console]::Beep(800, 500)'; - return spawn(POWERSHELL, ['-NoProfile', '-Command', psScript], { - stdio: 'ignore', - shell: false, - windowsHide: true - }); + // .mp3/.m4a/.wma 等:用 MediaPlayer 播放(等到时长可读后播完再退出) + // MediaPlayer.Open 是异步的,文件缺失/损坏不会抛异常,故:先 Test-Path 兜底, + // 且时长始终读不到时也蜂鸣兜底,避免静默失败(无声也无 beep) + return `try {` + + ` if (-not (Test-Path -LiteralPath '${safePath}')) { [console]::Beep(800, 300) } else {` + + ` Add-Type -AssemblyName PresentationCore;` + + ` $p = New-Object System.Windows.Media.MediaPlayer;` + + ` $p.Open([uri]::new('${safePath}'));` + + ` $t = 0; while (-not $p.NaturalDuration.HasTimeSpan -and $t -lt 50) { Start-Sleep -Milliseconds 100; $t++ };` + + ` if ($p.NaturalDuration.HasTimeSpan) { $p.Play(); Start-Sleep -Milliseconds ([int]$p.NaturalDuration.TimeSpan.TotalMilliseconds + 300) } else { [console]::Beep(800, 300) };` + + ` $p.Close() }` + + ` } catch { [console]::Beep(800, 300) }`; } /** * 发送声音提醒 */ - async sendSoundNotification() { + sendSoundNotification() { if (!this.config.notification.sound.enabled) { return; } console.log('🔊 播放声音提醒...'); - try { - const soundProcess = this.playWindowsSound(); - - soundProcess.on('error', (error) => { - if (this.config.notification.sound.backup) { - console.log('声音播放失败,使用蜂鸣声'); - this.playBeep(); - } - }); - - soundProcess.on('close', (code) => { - if (code !== 0 && this.config.notification.sound.backup) { - console.log('声音播放异常,使用蜂鸣声'); - this.playBeep(); - } - }); + // 同步阻塞播放:保证在 hook 进程存活期间就把声音播完, + // 不依赖 detached 子进程在本进程退出后存活(那样会被 hook 进程树回收而静默) + const psScript = this.buildSoundPsScript(); + const r = spawnSync(POWERSHELL, ['-NoProfile', '-Command', psScript], { + stdio: 'ignore', shell: false, windowsHide: true, timeout: 20000 + }); - } catch (error) { - if (this.config.notification.sound.backup) { - console.log('播放声音时发生错误,使用蜂鸣声'); - this.playBeep(); - } + // powershell 找不到等异常时退回蜂鸣 + if (r.error && this.config.notification.sound.backup) { + spawnSync(POWERSHELL, ['-NoProfile', '-Command', '[console]::Beep(800,500)'], + { stdio: 'ignore', shell: false, windowsHide: true, timeout: 5000 }); + console.log('声音播放失败,已尝试蜂鸣'); } + console.log('🔊 声音提醒已播放'); } /** @@ -222,22 +192,17 @@ class NotificationSystem { // 发送所有通知 const results = await this.notificationManager.sendAllNotifications(taskInfo); - // 添加声音通知 + // 声音通知:同步阻塞,播完再继续(不依赖 detached 子进程在本进程退出后存活) if (this.config.notification.sound.enabled) { this.sendSoundNotification(); - setTimeout(() => { - console.log('🔊 声音提醒已播放'); - }, 1000); } // 打印结果汇总 this.notificationManager.printNotificationSummary(results); - // 3秒后退出 - setTimeout(() => { - console.log('✨ 通知系统执行完成,程序退出'); - process.exit(0); - }, 3000); + // 声音已同步播完,直接退出 + console.log('✨ 通知系统执行完成,程序退出'); + process.exit(0); } }