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');
1516const os = require ( 'os' ) ;
1617const 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+
1842const 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
2049function 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+
2979function 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