Skip to content

Commit 4684ece

Browse files
feat(hooks): Add AskUserQuestion notifications + WhatsApp support
- Send WhatsApp/SMS when AskUserQuestion tool is used - Add dual-channel support (WhatsApp primary, SMS fallback) - Load .env files from multiple locations - Store pending prompts for response matching - Better error handling with response body logging
1 parent 3205e0b commit 4684ece

1 file changed

Lines changed: 178 additions & 29 deletions

File tree

templates/claude-hooks/notify-review-hook.js

Lines changed: 178 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
#!/usr/bin/env node
22
/**
3-
* Claude Code hook for SMS notifications on review-ready events
3+
* Claude Code hook for WhatsApp/SMS notifications
44
*
55
* Triggers notifications when:
6+
* - AskUserQuestion tool is used (allows remote response)
67
* - PR is created
78
* - Task is marked complete
89
* - User explicitly requests notification
@@ -15,7 +16,35 @@ const path = require('path');
1516
const os = require('os');
1617
const https = require('https');
1718

19+
// Load .env files (check multiple locations)
20+
const envPaths = [
21+
path.join(process.cwd(), '.env'),
22+
path.join(os.homedir(), 'Dev/stackmemory/.env'),
23+
path.join(os.homedir(), '.stackmemory/.env'),
24+
path.join(os.homedir(), '.env'),
25+
];
26+
for (const envPath of envPaths) {
27+
if (fs.existsSync(envPath)) {
28+
try {
29+
const content = fs.readFileSync(envPath, 'utf8');
30+
for (const line of content.split('\n')) {
31+
const match = line.match(/^([^#=]+)=(.*)$/);
32+
if (match && !process.env[match[1].trim()]) {
33+
process.env[match[1].trim()] = match[2]
34+
.trim()
35+
.replace(/^["']|["']$/g, '');
36+
}
37+
}
38+
} catch {}
39+
}
40+
}
41+
1842
const CONFIG_PATH = path.join(os.homedir(), '.stackmemory', 'sms-notify.json');
43+
const PENDING_PATH = path.join(
44+
os.homedir(),
45+
'.stackmemory',
46+
'sms-pending-prompts.json'
47+
);
1948

2049
function loadConfig() {
2150
try {
@@ -26,10 +55,75 @@ function loadConfig() {
2655
return { enabled: false };
2756
}
2857

58+
function savePendingPrompt(prompt) {
59+
try {
60+
const dir = path.join(os.homedir(), '.stackmemory');
61+
if (!fs.existsSync(dir)) {
62+
fs.mkdirSync(dir, { recursive: true });
63+
}
64+
let pending = { prompts: [] };
65+
if (fs.existsSync(PENDING_PATH)) {
66+
pending = JSON.parse(fs.readFileSync(PENDING_PATH, 'utf8'));
67+
}
68+
pending.prompts.push(prompt);
69+
// Keep only last 10 prompts
70+
if (pending.prompts.length > 10) {
71+
pending.prompts = pending.prompts.slice(-10);
72+
}
73+
fs.writeFileSync(PENDING_PATH, JSON.stringify(pending, null, 2));
74+
} catch (err) {
75+
console.error('[notify-hook] Failed to save pending prompt:', err.message);
76+
}
77+
}
78+
2979
function shouldNotify(toolName, toolInput, output) {
3080
const config = loadConfig();
3181
if (!config.enabled) return null;
3282

83+
// AskUserQuestion - send question via WhatsApp for remote response
84+
if (toolName === 'AskUserQuestion') {
85+
const questions = toolInput?.questions || [];
86+
if (questions.length === 0) return null;
87+
88+
// Format questions for WhatsApp
89+
const formattedQuestions = questions.map((q, qIdx) => {
90+
let text = q.question;
91+
if (q.options && q.options.length > 0) {
92+
text += '\n';
93+
q.options.forEach((opt, i) => {
94+
text += `${i + 1}. ${opt.label}`;
95+
if (opt.description) {
96+
text += ` - ${opt.description}`;
97+
}
98+
text += '\n';
99+
});
100+
text += `${q.options.length + 1}. Other (type your answer)`;
101+
}
102+
return { index: qIdx, text, options: q.options, header: q.header };
103+
});
104+
105+
// Store pending prompt for response matching
106+
const promptId = Math.random().toString(36).substring(2, 10);
107+
const pendingPrompt = {
108+
id: promptId,
109+
timestamp: new Date().toISOString(),
110+
questions: formattedQuestions,
111+
expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(), // 10 min
112+
};
113+
savePendingPrompt(pendingPrompt);
114+
115+
// Build message
116+
const message = formattedQuestions.map((q) => q.text).join('\n\n');
117+
118+
return {
119+
type: 'custom',
120+
title: 'Claude needs your input',
121+
message: message,
122+
promptId: promptId,
123+
isQuestion: true,
124+
};
125+
}
126+
33127
// Check for PR creation
34128
if (toolName === 'Bash') {
35129
const cmd = toolInput?.command || '';
@@ -73,30 +167,71 @@ function shouldNotify(toolName, toolInput, output) {
73167
return null;
74168
}
75169

76-
function sendNotification(notification) {
77-
const config = loadConfig();
170+
function getChannelNumbers(config) {
171+
const channel = config.channel || 'whatsapp';
78172

79-
if (
80-
!config.accountSid ||
81-
!config.authToken ||
82-
!config.fromNumber ||
83-
!config.toNumber
84-
) {
85-
// Try env vars
86-
const sid = process.env.TWILIO_ACCOUNT_SID;
87-
const token = process.env.TWILIO_AUTH_TOKEN;
88-
const from = process.env.TWILIO_FROM_NUMBER;
89-
const to = process.env.TWILIO_TO_NUMBER;
90-
91-
if (!sid || !token || !from || !to) {
92-
console.error('[notify-hook] Missing Twilio credentials');
93-
return;
173+
if (channel === 'whatsapp') {
174+
const from = config.whatsappFromNumber || config.fromNumber;
175+
const to = config.whatsappToNumber || config.toNumber;
176+
if (from && to) {
177+
return {
178+
from: from.startsWith('whatsapp:') ? from : `whatsapp:${from}`,
179+
to: to.startsWith('whatsapp:') ? to : `whatsapp:${to}`,
180+
channel: 'whatsapp',
181+
};
94182
}
183+
}
184+
185+
// Fallback to SMS
186+
const from = config.smsFromNumber || config.fromNumber;
187+
const to = config.smsToNumber || config.toNumber;
188+
if (from && to) {
189+
return { from, to, channel: 'sms' };
190+
}
191+
192+
return null;
193+
}
194+
195+
function sendNotification(notification) {
196+
let config = loadConfig();
197+
198+
// Apply env vars
199+
config.accountSid = config.accountSid || process.env.TWILIO_ACCOUNT_SID;
200+
config.authToken = config.authToken || process.env.TWILIO_AUTH_TOKEN;
201+
config.channel = config.channel || process.env.TWILIO_CHANNEL || 'whatsapp';
95202

96-
config.accountSid = sid;
97-
config.authToken = token;
98-
config.fromNumber = from;
99-
config.toNumber = to;
203+
// WhatsApp numbers
204+
config.whatsappFromNumber =
205+
config.whatsappFromNumber || process.env.TWILIO_WHATSAPP_FROM;
206+
config.whatsappToNumber =
207+
config.whatsappToNumber || process.env.TWILIO_WHATSAPP_TO;
208+
209+
// SMS numbers (fallback)
210+
config.smsFromNumber =
211+
config.smsFromNumber ||
212+
process.env.TWILIO_SMS_FROM ||
213+
process.env.TWILIO_FROM_NUMBER;
214+
config.smsToNumber =
215+
config.smsToNumber ||
216+
process.env.TWILIO_SMS_TO ||
217+
process.env.TWILIO_TO_NUMBER;
218+
219+
// Legacy support
220+
config.fromNumber = config.fromNumber || process.env.TWILIO_FROM_NUMBER;
221+
config.toNumber = config.toNumber || process.env.TWILIO_TO_NUMBER;
222+
223+
if (!config.accountSid || !config.authToken) {
224+
console.error('[notify-hook] Missing Twilio credentials');
225+
return;
226+
}
227+
228+
const numbers = getChannelNumbers(config);
229+
if (!numbers) {
230+
console.error(
231+
'[notify-hook] Missing phone numbers for channel:',
232+
config.channel
233+
);
234+
return;
100235
}
101236

102237
let message = `${notification.title}\n\n${notification.message}`;
@@ -109,9 +244,17 @@ function sendNotification(notification) {
109244
message += '\nReply with number to select';
110245
}
111246

247+
// For questions, add reply instruction
248+
if (notification.isQuestion) {
249+
message += '\n\nReply with your choice number or type your answer.';
250+
if (notification.promptId) {
251+
message += `\n[ID: ${notification.promptId}]`;
252+
}
253+
}
254+
112255
const postData = new URLSearchParams({
113-
From: config.fromNumber,
114-
To: config.toNumber,
256+
From: numbers.from,
257+
To: numbers.to,
115258
Body: message,
116259
}).toString();
117260

@@ -132,11 +275,17 @@ function sendNotification(notification) {
132275
};
133276

134277
const req = https.request(options, (res) => {
135-
if (res.statusCode === 201) {
136-
console.error(`[notify-hook] Sent: ${notification.title}`);
137-
} else {
138-
console.error(`[notify-hook] Failed: ${res.statusCode}`);
139-
}
278+
let body = '';
279+
res.on('data', (chunk) => (body += chunk));
280+
res.on('end', () => {
281+
if (res.statusCode === 201) {
282+
console.error(
283+
`[notify-hook] Sent via ${numbers.channel}: ${notification.title}`
284+
);
285+
} else {
286+
console.error(`[notify-hook] Failed (${res.statusCode}): ${body}`);
287+
}
288+
});
140289
});
141290

142291
req.on('error', (e) => {

0 commit comments

Comments
 (0)