@delorenj/claude-notifications
Version:
Delightful Notification for Claude Code
340 lines (297 loc) • 9.42 kB
JavaScript
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const os = require('os');
const http = require('http');
const https = require('https');
const { getConfig, getSoundPath, SOUND_TYPES } = require('../lib/config');
const config = getConfig();
const DEFAULT_WEBHOOK_TIMEOUT_MS = 1500;
// Check for command line arguments
const args = process.argv.slice(2);
const useBell = args.includes('--bell') || args.includes('-b');
const showConfig = args.includes('-c') || args.includes('--config');
function playSound() {
if (!config.sound) {
return;
}
// Determine which sound to play
let soundType = config.soundType;
if (useBell) {
soundType = SOUND_TYPES.BELL;
}
const soundFile = getSoundPath(soundType);
if (!fs.existsSync(soundFile)) {
console.warn(`Sound file not found: ${soundFile}`);
process.stdout.write('\x07'); // Fallback to system beep
return;
}
try {
if (process.platform === 'linux') {
try {
// Fix for PipeWire/PulseAudio suspended audio sinks
// Play sound twice: first play wakes up the sink, second actually produces audio
execSync(`paplay "${soundFile}" 2>/dev/null || true`, { stdio: 'ignore' });
execSync(`paplay "${soundFile}"`, { stdio: 'ignore' });
} catch (e) {
try {
execSync(`aplay "${soundFile}"`, { stdio: 'ignore' });
} catch (e2) {
try {
execSync(`play "${soundFile}"`, { stdio: 'ignore' });
} catch (e3) {
process.stdout.write('\x07');
}
}
}
} else if (process.platform === 'darwin') {
try {
execSync(`afplay "${soundFile}"`, { stdio: 'ignore' });
} catch (e) {
process.stdout.write('\x07');
}
} else {
process.stdout.write('\x07');
}
} catch (error) {
process.stdout.write('\x07');
}
}
function triggerWebhook() {
if (!config.webhook || !config.webhook.enabled || !config.webhook.url) {
return;
}
const { url } = config.webhook;
const format = (config.webhook.format || 'json').toLowerCase();
const timeoutMs = getWebhookTimeoutMs(config.webhook.timeoutMs);
const protocol = url.startsWith('https') ? https : http;
let data;
let headers;
if (format === 'ntfy') {
// ntfy native HTTP publish: POST plain-text body to https://server/<topic>.
// Headers are passed unprefixed (Title, Priority, Tags). ntfy accepts both
// the unprefixed and X-* forms. See https://docs.ntfy.sh/publish/
const body = (typeof config.webhook.body === 'string' && config.webhook.body.length > 0)
? config.webhook.body
: 'Claude is waiting for you...';
data = Buffer.from(body, 'utf8');
headers = {
'Content-Type': 'text/plain; charset=utf-8',
'Content-Length': data.length
};
if (config.webhook.headers && typeof config.webhook.headers === 'object') {
for (const [k, v] of Object.entries(config.webhook.headers)) {
if (v === null || v === undefined) continue;
headers[k] = String(v);
}
}
} else {
// Backwards-compatible JSON path. Unchanged behavior.
data = JSON.stringify({ message: 'Claude is waiting for you...' });
headers = {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(data)
};
}
const options = {
method: 'POST',
headers
};
let req;
let timeoutHandle;
let requestDone = false;
let timeoutTriggered = false;
const cleanup = () => {
requestDone = true;
if (timeoutHandle) {
clearTimeout(timeoutHandle);
timeoutHandle = null;
}
};
const abortForTimeout = () => {
if (requestDone || timeoutTriggered) {
return;
}
timeoutTriggered = true;
const timeoutError = new Error(`timed out after ${timeoutMs}ms`);
timeoutError.code = 'ETIMEDOUT';
req.destroy(timeoutError);
};
try {
req = protocol.request(url, options, (res) => {
// We don't really care about the response, but draining it lets Node exit.
res.on('end', cleanup);
res.on('close', cleanup);
res.resume();
});
} catch (error) {
console.error('Error triggering webhook:', formatWebhookError(error));
return;
}
timeoutHandle = setTimeout(abortForTimeout, timeoutMs);
if (typeof timeoutHandle.unref === 'function') {
timeoutHandle.unref();
}
if (typeof req.setTimeout === 'function') {
req.setTimeout(timeoutMs, abortForTimeout);
}
req.on('error', (error) => {
cleanup();
console.error('Error triggering webhook:', formatWebhookError(error));
});
req.on('close', cleanup);
try {
req.write(data);
req.end();
} catch (error) {
cleanup();
if (typeof req.destroy === 'function') {
req.destroy();
}
console.error('Error triggering webhook:', formatWebhookError(error));
}
}
function getWebhookTimeoutMs(value) {
const timeoutMs = Number(value);
if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
return timeoutMs;
}
return DEFAULT_WEBHOOK_TIMEOUT_MS;
}
function formatWebhookError(error) {
if (!error) {
return 'unknown error';
}
if (error.code === 'ETIMEDOUT') {
return error.message || 'timed out';
}
if (error.code && error.message) {
return `${error.code}: ${error.message}`;
}
return error.message || String(error);
}
function triggerZellijVisualization() {
// Check if Zellij visualization is enabled
if (!config.zellijVisualization || !config.zellijVisualization.enabled) {
return;
}
// Check if we're running inside a Zellij session
if (!process.env.ZELLIJ) {
return;
}
const {
pluginName,
notificationType,
title,
message,
priority
} = config.zellijVisualization;
// Construct the notification payload
const notification = {
type: notificationType,
message: message,
title: title,
source: 'claude-code',
priority: priority,
timestamp: Date.now()
};
const payload = JSON.stringify(notification);
try {
// Use zellij pipe to send notification to the plugin
execSync(`zellij pipe -p ${pluginName} -- '${payload}'`, {
stdio: 'ignore',
timeout: 2000 // 2 second timeout
});
} catch (error) {
// Silently fail - don't interrupt the notification flow
// User might not have the plugin installed
}
}
function showNotification() {
const notifier = require('node-notifier');
notifier.notify({
title: 'Claude Code',
message: 'Waiting for you...',
icon: path.join(os.homedir(), 'Pictures', 'claude.png'),
sound: false,
urgency: 'critical',
id: 'claude-code-notification',
replaceId: 'claude-code-notification'
});
}
function showConfigInfo() {
const configPath = path.join(os.homedir(), '.config', 'claude-notifications', 'settings.json');
const soundsDir = path.join(os.homedir(), '.config', 'claude-notifications', 'sounds');
console.log('🔍 Claude Notifications Config Debug Info:');
console.log('');
console.log('📁 Config file location:');
console.log(` ${configPath}`);
console.log(` Exists: ${fs.existsSync(configPath) ? '✅' : '❌'}`);
if (fs.existsSync(configPath)) {
try {
const configContent = fs.readFileSync(configPath, 'utf-8');
console.log(' Content:');
console.log(` ${configContent.split('\n').map(line => ` ${line}`).join('\n')}`);
} catch (error) {
console.log(` Error reading: ${error.message}`);
}
}
console.log('');
console.log('🔊 Sounds directory:');
console.log(` ${soundsDir}`);
console.log(` Exists: ${fs.existsSync(soundsDir) ? '✅' : '❌'}`);
if (fs.existsSync(soundsDir)) {
try {
const soundFiles = fs.readdirSync(soundsDir);
console.log(' Files:');
soundFiles.forEach(file => {
const filePath = path.join(soundsDir, file);
const stats = fs.statSync(filePath);
console.log(` - ${file} (${Math.round(stats.size / 1024)}KB)`);
});
} catch (error) {
console.log(` Error reading directory: ${error.message}`);
}
}
console.log('');
console.log('⚙️ Current config values:');
console.log(` sound: ${config.sound}`);
console.log(` soundType: ${config.soundType}`);
console.log(` desktopNotification: ${config.desktopNotification}`);
console.log(` webhook.enabled: ${config.webhook.enabled}`);
console.log('');
console.log('🎵 Sound file paths:');
const { SOUND_TYPES, getSoundPath } = require('../lib/config');
Object.values(SOUND_TYPES).forEach(soundType => {
const soundPath = getSoundPath(soundType);
console.log(` ${soundType}: ${soundPath}`);
console.log(` Exists: ${fs.existsSync(soundPath) ? '✅' : '❌'}`);
});
console.log('');
console.log('🔧 Command line args:');
console.log(` useBell: ${useBell}`);
console.log(` showConfig: ${showConfig}`);
}
function main() {
if (showConfig) {
showConfigInfo();
return;
}
// Always try to trigger Zellij visualization if enabled
triggerZellijVisualization();
if (config.webhook.enabled) {
triggerWebhook();
if (!config.webhook.replaceSound) {
playSound();
}
} else {
playSound();
if (config.desktopNotification) {
showNotification();
}
}
}
if (require.main === module) {
main();
}