claude-notification-plugin 1.0.15 → 1.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,329 +1,340 @@
1
- #!/usr/bin/env node
2
-
3
- import fs from 'fs';
4
- import os from 'os';
5
- import path from 'path';
6
- import process from 'process';
7
- import notifier from 'node-notifier';
8
- import player from 'play-sound';
9
- import say from 'say';
10
-
11
- const audio = player({});
12
-
13
- // ----------------------
14
- // CONFIG
15
- // ----------------------
16
-
17
- function loadConfig () {
18
- const configPath = path.join(os.homedir(), '.claude', 'notifier.config.json');
19
-
20
- const config = {
21
- telegram: {
22
- enabled: true,
23
- token: '',
24
- chatId: '',
25
- deleteAfterHours: 24,
26
- },
27
- windowsNotification: {
28
- enabled: true,
29
- },
30
- sound: {
31
- enabled: true,
32
- file: 'C:/Windows/Media/notify.wav',
33
- },
34
- voice: {
35
- enabled: true,
36
- },
37
- minSeconds: 15,
38
- notifyOnWaiting: true,
39
- };
40
-
41
- if (fs.existsSync(configPath)) {
42
- try {
43
- const raw = fs.readFileSync(configPath, 'utf-8');
44
- const user = JSON.parse(raw);
45
- if (user.telegram) {
46
- config.telegram = { ...config.telegram, ...user.telegram };
47
- }
48
- if (user.windowsNotification) {
49
- config.windowsNotification = { ...config.windowsNotification, ...user.windowsNotification };
50
- }
51
- if (user.sound) {
52
- config.sound = { ...config.sound, ...user.sound };
53
- }
54
- if (user.voice) {
55
- config.voice = { ...config.voice, ...user.voice };
56
- }
57
- if (typeof user.minSeconds === 'number') {
58
- config.minSeconds = user.minSeconds;
59
- }
60
- if (typeof user.notifyOnWaiting === 'boolean') {
61
- config.notifyOnWaiting = user.notifyOnWaiting;
62
- }
63
- } catch {
64
- // ignore malformed config
65
- }
66
- }
67
-
68
- if (process.env.TELEGRAM_TOKEN) {
69
- config.telegram.token = process.env.TELEGRAM_TOKEN;
70
- }
71
- if (process.env.TELEGRAM_CHAT_ID) {
72
- config.telegram.chatId = process.env.TELEGRAM_CHAT_ID;
73
- }
74
-
75
- // Per-channel env overrides (0 = off, 1 = on)
76
- if (process.env.CLAUDE_NOTIFY_TELEGRAM !== undefined) {
77
- config.telegram.enabled = process.env.CLAUDE_NOTIFY_TELEGRAM === '1';
78
- }
79
- if (process.env.CLAUDE_NOTIFY_WINDOWS !== undefined) {
80
- config.windowsNotification.enabled = process.env.CLAUDE_NOTIFY_WINDOWS === '1';
81
- }
82
- if (process.env.CLAUDE_NOTIFY_SOUND !== undefined) {
83
- config.sound.enabled = process.env.CLAUDE_NOTIFY_SOUND === '1';
84
- }
85
- if (process.env.CLAUDE_NOTIFY_VOICE !== undefined) {
86
- config.voice.enabled = process.env.CLAUDE_NOTIFY_VOICE === '1';
87
- }
88
- if (process.env.CLAUDE_NOTIFY_WAITING !== undefined) {
89
- config.notifyOnWaiting = process.env.CLAUDE_NOTIFY_WAITING === '1';
90
- }
91
-
92
- return config;
93
- }
94
-
95
- // ----------------------
96
- // PROJECT-LEVEL DISABLE
97
- // ----------------------
98
-
99
- function isNotifierDisabled () {
100
- return process.env.DISABLE_CLAUDE_NOTIFIER === '1'
101
- || process.env.DISABLE_CLAUDE_NOTIFIER === 'true';
102
- }
103
-
104
- // ----------------------
105
- // STATE FILE
106
- // ----------------------
107
-
108
- const STATE_FILE = path.join(
109
- os.homedir(),
110
- '.claude',
111
- '.notifier_state.json',
112
- );
113
-
114
- function loadState () {
115
- if (fs.existsSync(STATE_FILE)) {
116
- try {
117
- return JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'));
118
- } catch {
119
- return {};
120
- }
121
- }
122
- return {};
123
- }
124
-
125
- function saveState (state) {
126
- const dir = path.dirname(STATE_FILE);
127
- fs.mkdirSync(dir, { recursive: true });
128
- fs.writeFileSync(STATE_FILE, JSON.stringify(state));
129
- }
130
-
131
- // ----------------------
132
- // TELEGRAM
133
- // ----------------------
134
-
135
- async function sendTelegram (config, state) {
136
- if (!config.telegram.enabled || !config.telegram.token || !config.telegram.chatId) {
137
- return;
138
- }
139
-
140
- const baseUrl = `https://api.telegram.org/bot${config.telegram.token}`;
141
-
142
- // Send new message and store its id
143
- try {
144
- const res = await fetch(`${baseUrl}/sendMessage`, {
145
- method: 'POST',
146
- headers: { 'Content-Type': 'application/json' },
147
- body: JSON.stringify({
148
- chat_id: config.telegram.chatId,
149
- text: state._telegramText,
150
- }),
151
- });
152
- const data = await res.json();
153
- if (data.ok && data.result?.message_id) {
154
- if (!state.sentMessages) {
155
- state.sentMessages = [];
156
- }
157
- state.sentMessages.push({
158
- id: data.result.message_id,
159
- ts: Date.now(),
160
- });
161
- }
162
- } catch {
163
- // silent fail
164
- }
165
-
166
- // Delete old messages
167
- const maxAge = (config.telegram.deleteAfterHours || 24) * 3600_000;
168
- if (state.sentMessages?.length) {
169
- const now = Date.now();
170
- const keep = [];
171
- for (const msg of state.sentMessages) {
172
- if (now - msg.ts > maxAge) {
173
- try {
174
- await fetch(`${baseUrl}/deleteMessage`, {
175
- method: 'POST',
176
- headers: { 'Content-Type': 'application/json' },
177
- body: JSON.stringify({
178
- chat_id: config.telegram.chatId,
179
- message_id: msg.id,
180
- }),
181
- });
182
- } catch {
183
- // silent fail
184
- }
185
- } else {
186
- keep.push(msg);
187
- }
188
- }
189
- state.sentMessages = keep;
190
- }
191
- }
192
-
193
- // ----------------------
194
- // WINDOWS NOTIFICATION
195
- // ----------------------
196
-
197
- function sendWindowsNotification (config, message) {
198
- if (!config.windowsNotification.enabled) {
199
- return;
200
- }
201
- notifier.notify({
202
- title: 'Claude Code',
203
- message,
204
- sound: true,
205
- wait: false,
206
- });
207
- }
208
-
209
- // ----------------------
210
- // SOUND & VOICE
211
- // ----------------------
212
-
213
- function playSound (config) {
214
- if (!config.sound.enabled) {
215
- return;
216
- }
217
- try {
218
- audio.play(config.sound.file);
219
- } catch {
220
- // silent fail
221
- }
222
- }
223
-
224
- const voicePhrases = {
225
- en: (d) => `Claude finished coding in ${d} seconds`,
226
- ru: (d) => `Клод завершил работу за ${d} секунд`,
227
- de: (d) => `Claude hat die Arbeit in ${d} Sekunden abgeschlossen`,
228
- fr: (d) => `Claude a termine en ${d} secondes`,
229
- es: (d) => `Claude termino en ${d} segundos`,
230
- pt: (d) => `Claude terminou em ${d} segundos`,
231
- ja: (d) => `Claudeは${d}秒でコーディングを完了しました`,
232
- ko: (d) => `Claude ${d} 만에 코딩을 완료했습니다`,
233
- };
234
-
235
- function getVoicePhrase (duration) {
236
- const locale = Intl.DateTimeFormat().resolvedOptions().locale || 'en';
237
- const lang = locale.split('-')[0].toLowerCase();
238
- const fn = voicePhrases[lang] || voicePhrases.en;
239
- return fn(duration);
240
- }
241
-
242
- function speakResult (config, duration) {
243
- if (!config.voice.enabled) {
244
- return;
245
- }
246
- try {
247
- say.speak(getVoicePhrase(duration));
248
- } catch {
249
- // silent fail
250
- }
251
- }
252
-
253
- // ----------------------
254
- // READ HOOK INPUT
255
- // ----------------------
256
-
257
- let input = '';
258
-
259
- process.stdin.on('data', (chunk) => {
260
- input += chunk;
261
- });
262
-
263
- process.stdin.on('end', async () => {
264
- const config = loadConfig();
265
-
266
- let event = {};
267
- try {
268
- event = JSON.parse(input);
269
- } catch {
270
- // ignore
271
- }
272
-
273
- const eventType = event.hook_event_name || 'unknown';
274
- const cwd = event.cwd || process.cwd();
275
- const project = path.basename(cwd);
276
-
277
- if (isNotifierDisabled()) {
278
- process.exit(0);
279
- }
280
-
281
- const state = loadState();
282
-
283
- // ----------------------
284
- // START TIMER
285
- // ----------------------
286
-
287
- if (eventType === 'UserPromptSubmit') {
288
- state.start = Date.now();
289
- saveState(state);
290
- process.exit(0);
291
- }
292
-
293
- // ----------------------
294
- // STOP / NOTIFICATION EVENT
295
- // ----------------------
296
-
297
- if (eventType !== 'Stop' && eventType !== 'Notification') {
298
- process.exit(0);
299
- }
300
-
301
- if (eventType === 'Notification' && !config.notifyOnWaiting) {
302
- process.exit(0);
303
- }
304
-
305
- let duration = 0;
306
- if (state.start) {
307
- duration = Math.round((Date.now() - state.start) / 1000);
308
- }
309
-
310
- if (duration < config.minSeconds) {
311
- process.exit(0);
312
- }
313
-
314
- const title = eventType === 'Notification'
315
- ? 'Claude waiting for input'
316
- : 'Claude finished coding';
317
-
318
- const message =
319
- `${title}\n\nProject: ${project}\nDuration: ${duration}s\nTrigger: ${eventType}`;
320
-
321
- state._telegramText = `\u{1F916} ${message}`;
322
- await sendTelegram(config, state);
323
- delete state._telegramText;
324
- saveState(state);
325
-
326
- sendWindowsNotification(config, message);
327
- playSound(config);
328
- speakResult(config, duration);
329
- });
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'fs';
4
+ import os from 'os';
5
+ import path from 'path';
6
+ import process from 'process';
7
+ import notifier from 'node-notifier';
8
+ import player from 'play-sound';
9
+ import say from 'say';
10
+
11
+ const audio = player({});
12
+
13
+ // ----------------------
14
+ // CONFIG
15
+ // ----------------------
16
+
17
+ function loadConfig () {
18
+ const configPath = path.join(os.homedir(), '.claude', 'notifier.config.json');
19
+
20
+ const config = {
21
+ telegram: {
22
+ enabled: true,
23
+ token: '',
24
+ chatId: '',
25
+ deleteAfterHours: 24,
26
+ },
27
+ windowsNotification: {
28
+ enabled: true,
29
+ },
30
+ sound: {
31
+ enabled: true,
32
+ file: 'C:/Windows/Media/notify.wav',
33
+ },
34
+ voice: {
35
+ enabled: true,
36
+ },
37
+ minSeconds: 15,
38
+ notifyOnWaiting: false,
39
+ debug: false,
40
+ };
41
+
42
+ if (fs.existsSync(configPath)) {
43
+ try {
44
+ const raw = fs.readFileSync(configPath, 'utf-8');
45
+ const user = JSON.parse(raw);
46
+ if (user.telegram) {
47
+ config.telegram = { ...config.telegram, ...user.telegram };
48
+ }
49
+ if (user.windowsNotification) {
50
+ config.windowsNotification = { ...config.windowsNotification, ...user.windowsNotification };
51
+ }
52
+ if (user.sound) {
53
+ config.sound = { ...config.sound, ...user.sound };
54
+ }
55
+ if (user.voice) {
56
+ config.voice = { ...config.voice, ...user.voice };
57
+ }
58
+ if (typeof user.minSeconds === 'number') {
59
+ config.minSeconds = user.minSeconds;
60
+ }
61
+ if (typeof user.notifyOnWaiting === 'boolean') {
62
+ config.notifyOnWaiting = user.notifyOnWaiting;
63
+ }
64
+ if (typeof user.debug === 'boolean') {
65
+ config.debug = user.debug;
66
+ }
67
+ } catch {
68
+ // ignore malformed config
69
+ }
70
+ }
71
+
72
+ if (process.env.TELEGRAM_TOKEN) {
73
+ config.telegram.token = process.env.TELEGRAM_TOKEN;
74
+ }
75
+ if (process.env.TELEGRAM_CHAT_ID) {
76
+ config.telegram.chatId = process.env.TELEGRAM_CHAT_ID;
77
+ }
78
+
79
+ // Per-channel env overrides (0 = off, 1 = on)
80
+ if (process.env.CLAUDE_NOTIFY_TELEGRAM !== undefined) {
81
+ config.telegram.enabled = process.env.CLAUDE_NOTIFY_TELEGRAM === '1';
82
+ }
83
+ if (process.env.CLAUDE_NOTIFY_WINDOWS !== undefined) {
84
+ config.windowsNotification.enabled = process.env.CLAUDE_NOTIFY_WINDOWS === '1';
85
+ }
86
+ if (process.env.CLAUDE_NOTIFY_SOUND !== undefined) {
87
+ config.sound.enabled = process.env.CLAUDE_NOTIFY_SOUND === '1';
88
+ }
89
+ if (process.env.CLAUDE_NOTIFY_VOICE !== undefined) {
90
+ config.voice.enabled = process.env.CLAUDE_NOTIFY_VOICE === '1';
91
+ }
92
+ if (process.env.CLAUDE_NOTIFY_WAITING !== undefined) {
93
+ config.notifyOnWaiting = process.env.CLAUDE_NOTIFY_WAITING === '1';
94
+ }
95
+ if (process.env.CLAUDE_NOTIFY_DEBUG !== undefined) {
96
+ config.debug = process.env.CLAUDE_NOTIFY_DEBUG === '1';
97
+ }
98
+
99
+ return config;
100
+ }
101
+
102
+ // ----------------------
103
+ // PROJECT-LEVEL DISABLE
104
+ // ----------------------
105
+
106
+ function isNotifierDisabled () {
107
+ return process.env.DISABLE_CLAUDE_NOTIFIER === '1'
108
+ || process.env.DISABLE_CLAUDE_NOTIFIER === 'true';
109
+ }
110
+
111
+ // ----------------------
112
+ // STATE FILE
113
+ // ----------------------
114
+
115
+ const STATE_FILE = path.join(
116
+ os.homedir(),
117
+ '.claude',
118
+ '.notifier_state.json',
119
+ );
120
+
121
+ function loadState () {
122
+ if (fs.existsSync(STATE_FILE)) {
123
+ try {
124
+ return JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'));
125
+ } catch {
126
+ return {};
127
+ }
128
+ }
129
+ return {};
130
+ }
131
+
132
+ function saveState (state) {
133
+ const dir = path.dirname(STATE_FILE);
134
+ fs.mkdirSync(dir, { recursive: true });
135
+ fs.writeFileSync(STATE_FILE, JSON.stringify(state));
136
+ }
137
+
138
+ // ----------------------
139
+ // TELEGRAM
140
+ // ----------------------
141
+
142
+ async function sendTelegram (config, state) {
143
+ if (!config.telegram.enabled || !config.telegram.token || !config.telegram.chatId) {
144
+ return;
145
+ }
146
+
147
+ const baseUrl = `https://api.telegram.org/bot${config.telegram.token}`;
148
+
149
+ // Send new message and store its id
150
+ try {
151
+ const res = await fetch(`${baseUrl}/sendMessage`, {
152
+ method: 'POST',
153
+ headers: { 'Content-Type': 'application/json' },
154
+ body: JSON.stringify({
155
+ chat_id: config.telegram.chatId,
156
+ text: state._telegramText,
157
+ }),
158
+ });
159
+ const data = await res.json();
160
+ if (data.ok && data.result?.message_id) {
161
+ if (!state.sentMessages) {
162
+ state.sentMessages = [];
163
+ }
164
+ state.sentMessages.push({
165
+ id: data.result.message_id,
166
+ ts: Date.now(),
167
+ });
168
+ }
169
+ } catch {
170
+ // silent fail
171
+ }
172
+
173
+ // Delete old messages
174
+ const maxAge = (config.telegram.deleteAfterHours || 24) * 3600_000;
175
+ if (state.sentMessages?.length) {
176
+ const now = Date.now();
177
+ const keep = [];
178
+ for (const msg of state.sentMessages) {
179
+ if (now - msg.ts > maxAge) {
180
+ try {
181
+ await fetch(`${baseUrl}/deleteMessage`, {
182
+ method: 'POST',
183
+ headers: { 'Content-Type': 'application/json' },
184
+ body: JSON.stringify({
185
+ chat_id: config.telegram.chatId,
186
+ message_id: msg.id,
187
+ }),
188
+ });
189
+ } catch {
190
+ // silent fail
191
+ }
192
+ } else {
193
+ keep.push(msg);
194
+ }
195
+ }
196
+ state.sentMessages = keep;
197
+ }
198
+ }
199
+
200
+ // ----------------------
201
+ // WINDOWS NOTIFICATION
202
+ // ----------------------
203
+
204
+ function sendWindowsNotification (config, message) {
205
+ if (!config.windowsNotification.enabled) {
206
+ return;
207
+ }
208
+ notifier.notify({
209
+ title: 'Claude Code',
210
+ message,
211
+ sound: true,
212
+ wait: false,
213
+ });
214
+ }
215
+
216
+ // ----------------------
217
+ // SOUND & VOICE
218
+ // ----------------------
219
+
220
+ function playSound (config) {
221
+ if (!config.sound.enabled) {
222
+ return;
223
+ }
224
+ try {
225
+ audio.play(config.sound.file);
226
+ } catch {
227
+ // silent fail
228
+ }
229
+ }
230
+
231
+ const voicePhrases = {
232
+ en: (d) => `Claude finished coding in ${d} seconds`,
233
+ ru: (d) => `Клод завершил работу за ${d} секунд`,
234
+ de: (d) => `Claude hat die Arbeit in ${d} Sekunden abgeschlossen`,
235
+ fr: (d) => `Claude a termine en ${d} secondes`,
236
+ es: (d) => `Claude termino en ${d} segundos`,
237
+ pt: (d) => `Claude terminou em ${d} segundos`,
238
+ ja: (d) => `Claudeは${d}秒でコーディングを完了しました`,
239
+ ko: (d) => `Claude가 ${d}초 만에 코딩을 완료했습니다`,
240
+ };
241
+
242
+ function getVoicePhrase (duration) {
243
+ const locale = Intl.DateTimeFormat().resolvedOptions().locale || 'en';
244
+ const lang = locale.split('-')[0].toLowerCase();
245
+ const fn = voicePhrases[lang] || voicePhrases.en;
246
+ return fn(duration);
247
+ }
248
+
249
+ function speakResult (config, duration) {
250
+ if (!config.voice.enabled) {
251
+ return;
252
+ }
253
+ try {
254
+ say.speak(getVoicePhrase(duration));
255
+ } catch {
256
+ // silent fail
257
+ }
258
+ }
259
+
260
+ // ----------------------
261
+ // READ HOOK INPUT
262
+ // ----------------------
263
+
264
+ let input = '';
265
+
266
+ process.stdin.on('data', (chunk) => {
267
+ input += chunk;
268
+ });
269
+
270
+ process.stdin.on('end', async () => {
271
+ const config = loadConfig();
272
+
273
+ let event = {};
274
+ try {
275
+ event = JSON.parse(input);
276
+ } catch {
277
+ // ignore
278
+ }
279
+
280
+ const eventType = event.hook_event_name || 'unknown';
281
+ const cwd = event.cwd || process.cwd();
282
+ const project = path.basename(cwd);
283
+
284
+ if (isNotifierDisabled()) {
285
+ process.exit(0);
286
+ }
287
+
288
+ const state = loadState();
289
+
290
+ // ----------------------
291
+ // START TIMER
292
+ // ----------------------
293
+
294
+ if (eventType === 'UserPromptSubmit') {
295
+ state.start = Date.now();
296
+ saveState(state);
297
+ process.exit(0);
298
+ }
299
+
300
+ // ----------------------
301
+ // STOP / NOTIFICATION EVENT
302
+ // ----------------------
303
+
304
+ if (eventType !== 'Stop' && eventType !== 'Notification') {
305
+ process.exit(0);
306
+ }
307
+
308
+ if (eventType === 'Notification' && !config.notifyOnWaiting) {
309
+ process.exit(0);
310
+ }
311
+
312
+ let duration = 0;
313
+ if (state.start) {
314
+ duration = Math.round((Date.now() - state.start) / 1000);
315
+ }
316
+
317
+ if (duration < config.minSeconds) {
318
+ process.exit(0);
319
+ }
320
+
321
+ const title = eventType === 'Notification'
322
+ ? 'Claude waiting for input'
323
+ : 'Claude finished coding';
324
+
325
+ let message =
326
+ `${title}\n\nProject: ${project}\nDuration: ${duration}s\nTrigger: ${eventType}`;
327
+
328
+ if (config.debug && config.voice.enabled) {
329
+ message += `\nVoice: ${getVoicePhrase(duration)}`;
330
+ }
331
+
332
+ state._telegramText = `\u{1F916} ${message}`;
333
+ await sendTelegram(config, state);
334
+ delete state._telegramText;
335
+ saveState(state);
336
+
337
+ sendWindowsNotification(config, message);
338
+ playSound(config);
339
+ speakResult(config, duration);
340
+ });