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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ NOTIFICATION_ENABLED=true
# 是否启用声音提醒 (true/false)
SOUND_ENABLED=true

# macOS 原生通知(仅 macOS 生效)
# 任务完成时弹出通知中心横幅并播放提示音,默认开启,无需额外配置
# MAC_NOTIFICATION_ENABLED=false
# 提示音名称,见 /System/Library/Sounds(如 Glass、Ping、Hero、Submarine)
# MAC_NOTIFICATION_SOUND=Glass

# Telegram Bot配置
# 获取方法:
# 1. 与 @BotFather 对话创建机器人,获取 token
Expand Down
32 changes: 29 additions & 3 deletions 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、macOS 原生通知、Windows 声音提醒。

## 一句话配置

Expand All @@ -23,6 +23,12 @@ node notify-system.js --task "测试通知"

飞书 Webhook 获取:群设置 → 群机器人 → 添加自定义机器人 → 复制地址。

**macOS 原生通知**(弹横幅+提示音,默认开启,无需 webhook)需先安装一次:

```bash
brew install terminal-notifier
```

配置 Claude Code Hook,在 `~/.claude/settings.json` 中添加:

```json
Expand All @@ -31,9 +37,29 @@ node notify-system.js --task "测试通知"
"Stop": [{
"hooks": [{
"type": "command",
"command": "node /你的路径/ccdd/notify-system.js"
"command": "node /your_path/ccdd/notify-system.js"
}]
}]
}],
"Notification": [
{
"matcher": "permission_prompt",
"hooks": [
{
"type": "command",
"command": "node /your_path/ccdd/notify-system.js"
}
]
},
{
"matcher": "agent_needs_input",
"hooks": [
{
"type": "command",
"command": "node /your_path/ccdd/notify-system.js"
}
]
}
]
}
}
```
Expand Down
18 changes: 18 additions & 0 deletions SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,26 @@ TELEGRAM_CHAT_ID=你的chat_id

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

## macOS 原生通知

macOS 上默认开启,任务完成时弹出通知中心横幅并播放提示音,无需配置 webhook。

需要先安装 `terminal-notifier`(一次性):

```bash
brew install terminal-notifier
```

首次运行时若 macOS 弹出「"terminal-notifier" 想要发送通知」,点**允许**即可。
如果只进通知中心不弹横幅,去 **系统设置 → 通知 → terminal-notifier** 把样式设为**横幅/提醒**。

可选配置:
- `MAC_NOTIFICATION_ENABLED=false` — 关闭原生通知
- `MAC_NOTIFICATION_SOUND=Hero` — 换提示音(见 `/System/Library/Sounds`,如 Glass/Ping/Hero/Submarine)

## 故障排除

- 飞书收不到:检查 webhook 地址是否完整复制了
- 手环不震:确认飞书通知权限开着,手环和手机蓝牙连着
- 声音不响:Windows only,检查 PowerShell 能否正常运行
- macOS 没横幅/没声音:先确认已 `brew install terminal-notifier`;再去 **系统设置 → 通知 → terminal-notifier** 开启通知并设为横幅样式(osascript 的通知会被 Script Editor 权限静默丢弃,故本项目改用 terminal-notifier)
15 changes: 14 additions & 1 deletion env-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,14 +62,26 @@ class EnvConfig {

/**
* 获取声音通知配置
* Windows 声音基于 PowerShell,在 macOS 上无效,由 mac 原生通知接管声音
*/
getSoundConfig() {
return {
enabled: process.env.SOUND_ENABLED !== 'false',
enabled: process.platform !== 'darwin' && process.env.SOUND_ENABLED !== 'false',
backup: true
};
}

/**
* 获取 macOS 原生通知配置
* 仅在 macOS 平台生效,默认开启,可通过 MAC_NOTIFICATION_ENABLED=false 关闭
*/
getMacConfig() {
return {
enabled: process.platform === 'darwin' && process.env.MAC_NOTIFICATION_ENABLED !== 'false',
sound: process.env.MAC_NOTIFICATION_SOUND || 'Glass'
};
}

/**
* 获取通用通知配置
*/
Expand All @@ -87,6 +99,7 @@ class EnvConfig {
feishu: this.getFeishuConfig(),
telegram: this.getTelegramConfig(),
sound: this.getSoundConfig(),
mac: this.getMacConfig(),
notification: this.getNotificationConfig()
};
}
Expand Down
20 changes: 19 additions & 1 deletion notification-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

const { FeishuNotifier } = require('./feishu-notify');
const { TelegramNotifier } = require('./telegram-notify');
const { MacNotifier } = require('./notify-mac');

/**
* 通知管理器类
Expand Down Expand Up @@ -46,6 +47,18 @@ class NotificationManager {
};
}

// macOS 原生通知器
if (this.config.notification.mac && this.config.notification.mac.enabled) {
notifiers.mac = {
enabled: true,
notifier: new MacNotifier(this.config.notification.mac.sound),
send: async (taskInfo) => {
const { notifyTaskCompletion } = require('./notify-mac');
return await notifyTaskCompletion(taskInfo, this.projectName, this.config.notification.mac.sound);
}
};
}

return notifiers;
}

Expand Down Expand Up @@ -97,6 +110,7 @@ class NotificationManager {
const typeNames = {
feishu: '飞书通知',
telegram: 'Telegram通知',
mac: 'macOS通知',
sound: '声音提醒'
};
return typeNames[type] || type;
Expand All @@ -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 === 'mac' ? '💻' : '🔊';
console.log(` ${icon} ${typeName}:${status}`);
});

Expand All @@ -127,6 +141,9 @@ class NotificationManager {
if (this.notifiers.telegram) {
console.log(' 📲 Telegram将收到推送通知');
}
if (this.notifiers.mac) {
console.log(' 💻 macOS 将弹出通知横幅并播放提示音');
}
console.log('');
}

Expand All @@ -137,6 +154,7 @@ class NotificationManager {
const icons = [];
if (this.notifiers.feishu) icons.push('📱');
if (this.notifiers.telegram) icons.push('📲');
if (this.notifiers.mac) icons.push('💻');
return icons.join(' ');
}
}
Expand Down
123 changes: 123 additions & 0 deletions notify-mac.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
* macOS 原生通知脚本
* 通过 terminal-notifier 投递系统通知,弹出横幅并播放系统提示音
* 仅在 macOS (darwin) 平台生效
*
* 为什么用 terminal-notifier 而非 osascript:
* osascript 的 `display notification` 把通知归属给 "Script Editor" 宿主 App,
* 一旦 Script Editor 通知权限被关,通知会被系统静默丢弃且退出码仍为 0(假成功)。
* terminal-notifier 以自身身份投递,权限清晰,后台 hook 触发也可靠。
*/

const { spawn } = require('child_process');

/**
* macOS 通知器
*/
class MacNotifier {
/**
* 构造函数
* @param {string} sound - 系统提示音名称,见 /System/Library/Sounds(如 Glass、Ping、Hero),或 'default'
*/
constructor(sound = 'Glass') {
this.sound = sound;
}

/**
* 折叠换行为空格并截断,避免通知内容过长
* @param {string} str - 原始字符串
* @param {number} max - 最大长度
* @returns {string}
*/
clean(str, max) {
return String(str).replace(/[\r\n]+/g, ' ').trim().slice(0, max);
}

/**
* 弹出一条 macOS 通知
* 参数经 spawn 数组直传 argv,不经过 shell,天然免注入,无需转义
* @param {string} title - 通知标题
* @param {string} body - 通知正文
* @returns {Promise<boolean>} 发送是否成功
*/
notify(title, body) {
const safeTitle = this.clean(title, 120) || 'Claude Code';
const safeBody = this.clean(body, 400) || '任务完成';

return new Promise((resolve) => {
const proc = spawn('terminal-notifier', [
'-title', safeTitle,
'-message', safeBody,
'-sound', this.sound
], { stdio: 'ignore' });

proc.on('error', (error) => {
console.error('❌ macOS 通知发送失败(terminal-notifier 未安装?请执行 brew install terminal-notifier):', error.message);
resolve(false);
});

proc.on('close', (code) => {
if (code === 0) {
console.log('✅ macOS 原生通知已弹出');
resolve(true);
} else {
console.error(`❌ macOS 通知发送失败 (退出码 ${code})`);
resolve(false);
}
});
});
}
}

/**
* 任务完成通知函数
* @param {string} taskInfo - 任务信息
* @param {string} projectName - 项目名称
* @param {string} sound - 系统提示音名称
* @returns {Promise<boolean>} 发送是否成功
*/
async function notifyTaskCompletion(taskInfo = 'Claude Code任务已完成', projectName = '', sound = 'Glass') {
if (process.platform !== 'darwin') {
console.log('⏭️ 非 macOS 平台,跳过原生通知');
return false;
}

const notifier = new MacNotifier(sound);
const title = projectName || 'Claude Code';
return await notifier.notify(title, taskInfo);
}

/**
* 获取命令行参数
*/
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任务已完成';
const projectName = options.project || '';

console.log('🚀 发送 macOS 原生通知...');
notifyTaskCompletion(taskInfo, projectName);
}

module.exports = {
MacNotifier,
notifyTaskCompletion
};
10 changes: 9 additions & 1 deletion notify-system.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class NotificationSystem {
type: envVars.feishu.enabled ? 'feishu' : 'sound',
feishu: envVars.feishu,
telegram: envVars.telegram,
mac: envVars.mac,
sound: envVars.sound
}
};
Expand Down Expand Up @@ -229,11 +230,18 @@ function buildMessageFromContext(options) {
return options.message || options.task;
}

// 2. 尝试从 stdin 读取 Claude Stop hook 的 JSON 上下文
// 2. 尝试从 stdin 读取 Claude hook 的 JSON 上下文
const stdin = readStdinSync();
if (stdin) {
try {
const ctx = JSON.parse(stdin);

// Notification hook:Claude 需要权限确认或等待输入,直接透传通知文案
if (ctx.hook_event_name === 'Notification' && ctx.message) {
return ctx.message;
}

// Stop hook:从最后一条消息提取任务摘要
if (ctx.last_assistant_message) {
const text = ctx.last_assistant_message
.split('\n')
Expand Down