UNPKG

@stackmemoryai/stackmemory

Version:

Project-scoped memory for AI coding tools. Durable context across sessions with MCP integration, frames, smart retrieval, Claude Code skills, and automatic hooks.

556 lines (555 loc) 17.4 kB
import { fileURLToPath as __fileURLToPath } from 'url'; import { dirname as __pathDirname } from 'path'; const __filename = __fileURLToPath(import.meta.url); const __dirname = __pathDirname(__filename); import { createServer } from "http"; import { parse as parseUrl } from "url"; import { existsSync, readFileSync } from "fs"; import { join } from "path"; import { homedir } from "os"; import { createHmac } from "crypto"; import { execFileSync } from "child_process"; import { processIncomingResponse, loadSMSConfig, cleanupExpiredPrompts, sendNotification } from "./sms-notify.js"; import { queueAction, executeActionSafe, cleanupOldActions } from "./sms-action-runner.js"; import { isCommand, processCommand, sendCommandResponse } from "./whatsapp-commands.js"; import { writeFileSecure, ensureSecureDir } from "./secure-fs.js"; import { logWebhookRequest, logRateLimit, logSignatureInvalid, logBodyTooLarge, logContentTypeInvalid, logActionAllowed, logActionBlocked, logCleanup } from "./security-logger.js"; const CLEANUP_INTERVAL_MS = 5 * 60 * 1e3; const MAX_SMS_BODY_LENGTH = 1e3; const MAX_PHONE_LENGTH = 50; const MAX_BODY_SIZE = 50 * 1024; const RATE_LIMIT_WINDOW_MS = 60 * 1e3; const RATE_LIMIT_MAX_REQUESTS = 30; const ACTION_TIMEOUT_MS = 6e4; async function executeActionWithTimeout(action, response) { return Promise.race([ executeActionSafe(action, response), new Promise( (_, reject) => setTimeout( () => reject(new Error(`Action timed out after ${ACTION_TIMEOUT_MS}ms`)), ACTION_TIMEOUT_MS ) ) ]).catch((error) => ({ success: false, error: error instanceof Error ? error.message : String(error) })); } const RATE_LIMIT_PATH = join(homedir(), ".stackmemory", "rate-limits.json"); let rateLimitCache = {}; let rateLimitCacheDirty = false; function loadRateLimits() { try { if (existsSync(RATE_LIMIT_PATH)) { const data = JSON.parse(readFileSync(RATE_LIMIT_PATH, "utf8")); const now = Date.now(); const cleaned = {}; for (const [ip, record] of Object.entries(data)) { const r = record; if (r.resetTime > now) { cleaned[ip] = r; } } return cleaned; } } catch { } return {}; } function saveRateLimits() { if (!rateLimitCacheDirty) return; try { ensureSecureDir(join(homedir(), ".stackmemory")); writeFileSecure(RATE_LIMIT_PATH, JSON.stringify(rateLimitCache)); rateLimitCacheDirty = false; } catch { } } setInterval(saveRateLimits, 3e4); rateLimitCache = loadRateLimits(); function checkRateLimit(ip) { const now = Date.now(); const record = rateLimitCache[ip]; if (!record || now > record.resetTime) { rateLimitCache[ip] = { count: 1, resetTime: now + RATE_LIMIT_WINDOW_MS }; rateLimitCacheDirty = true; return true; } if (record.count >= RATE_LIMIT_MAX_REQUESTS) { return false; } record.count++; rateLimitCacheDirty = true; return true; } function verifyTwilioSignature(url, params, signature) { const authToken = process.env["TWILIO_AUTH_TOKEN"]; if (!authToken) { const isDev = process.env["NODE_ENV"] === "development" || process.env["SKIP_TWILIO_VERIFICATION"] === "true"; if (isDev) { console.warn( "[sms-webhook] TWILIO_AUTH_TOKEN not set, skipping verification (dev mode)" ); return true; } console.error( "[sms-webhook] TWILIO_AUTH_TOKEN not set - rejecting request in production" ); return false; } const sortedKeys = Object.keys(params).sort(); let data = url; for (const key of sortedKeys) { data += key + params[key]; } const hmac = createHmac("sha1", authToken); hmac.update(data); const expectedSignature = hmac.digest("base64"); return signature === expectedSignature; } function parseFormData(body) { const params = new URLSearchParams(body); const result = {}; params.forEach((value, key) => { result[key] = value; }); return result; } function storeLatestResponse(promptId, response, action) { ensureSecureDir(join(homedir(), ".stackmemory")); const responsePath = join( homedir(), ".stackmemory", "sms-latest-response.json" ); writeFileSecure( responsePath, JSON.stringify({ promptId, response, action, timestamp: (/* @__PURE__ */ new Date()).toISOString() }) ); } function storeIncomingRequest(from, message) { ensureSecureDir(join(homedir(), ".stackmemory")); const requestPath = join( homedir(), ".stackmemory", "sms-incoming-request.json" ); writeFileSecure( requestPath, JSON.stringify({ from, message, timestamp: (/* @__PURE__ */ new Date()).toISOString(), processed: false }) ); } function getIncomingRequest() { const requestPath = join( homedir(), ".stackmemory", "sms-incoming-request.json" ); if (!existsSync(requestPath)) { return null; } try { const data = JSON.parse(readFileSync(requestPath, "utf-8")); if (data.processed) { return null; } return data; } catch { return null; } } function markRequestProcessed() { const requestPath = join( homedir(), ".stackmemory", "sms-incoming-request.json" ); if (!existsSync(requestPath)) { return; } try { const data = JSON.parse(readFileSync(requestPath, "utf-8")); data.processed = true; writeFileSecure(requestPath, JSON.stringify(data)); } catch { } } async function handleSMSWebhook(payload) { const { From, Body } = payload; if (Body && Body.length > MAX_SMS_BODY_LENGTH) { console.log(`[sms-webhook] Body too long: ${Body.length} chars`); return { response: "Message too long. Max 1000 characters." }; } if (From && From.length > MAX_PHONE_LENGTH) { console.log( `[sms-webhook] Phone number too long: ${From.length} chars (max ${MAX_PHONE_LENGTH}): ${From.substring(0, 30)}...` ); return { response: "Invalid phone number." }; } console.log(`[sms-webhook] Received from ${From}: ${Body}`); if (isCommand(Body)) { console.log(`[sms-webhook] Processing command: ${Body}`); const cmdResult = await processCommand(From, Body); if (cmdResult.handled) { if (cmdResult.response) { sendCommandResponse(cmdResult.response).catch(console.error); } return { response: cmdResult.response || "Command processed", action: cmdResult.action, queued: false }; } } const result = processIncomingResponse(From, Body); if (!result.matched) { if (result.prompt) { return { response: `Invalid response. Expected: ${result.prompt.options.map((o) => o.key).join(", ")}` }; } storeIncomingRequest(From, Body); console.log( `[sms-webhook] Stored new request from ${From}: ${Body.substring(0, 50)}...` ); return { response: "Got it! Your request has been queued." }; } storeLatestResponse( result.prompt?.id || "unknown", result.response || Body, result.action ); triggerResponseNotification(result.response || Body); if (result.action) { console.log(`[sms-webhook] Executing action: ${result.action}`); const actionResult = await executeActionWithTimeout( result.action, result.response || Body ); if (actionResult.success) { logActionAllowed("sms-webhook", result.action); console.log( `[sms-webhook] Action completed: ${(actionResult.output || "").substring(0, 200)}` ); return { response: `Done! Action executed successfully.`, action: result.action, queued: false }; } else { logActionBlocked( "sms-webhook", result.action, actionResult.error || "unknown" ); console.log(`[sms-webhook] Action failed: ${actionResult.error}`); queueAction( result.prompt?.id || "unknown", result.response || Body, result.action ); return { response: `Action failed, queued for retry: ${(actionResult.error || "").substring(0, 50)}`, action: result.action, queued: true }; } } return { response: `Received: ${result.response}. Next action will be triggered.` }; } function escapeAppleScript(str) { return str.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").substring(0, 200); } function triggerResponseNotification(response) { const safeMessage = escapeAppleScript(`SMS Response: ${response}`); try { execFileSync( "osascript", [ "-e", `display notification "${safeMessage}" with title "StackMemory" sound name "Glass"` ], { stdio: "ignore", timeout: 5e3 } ); } catch { } try { const signalPath = join(homedir(), ".stackmemory", "sms-signal.txt"); writeFileSecure( signalPath, JSON.stringify({ type: "sms_response", response, timestamp: (/* @__PURE__ */ new Date()).toISOString() }) ); } catch { } console.log(` *** SMS RESPONSE RECEIVED: "${response}" ***`); console.log(`*** Run: stackmemory notify run-actions *** `); } function twimlResponse(message) { return `<?xml version="1.0" encoding="UTF-8"?> <Response> <Message>${escapeXml(message)}</Message> </Response>`; } function escapeXml(str) { return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;"); } function startWebhookServer(port = 3456) { const server = createServer( async (req, res) => { const url = parseUrl(req.url || "/", true); if (url.pathname === "/health") { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ status: "ok" })); return; } if ((url.pathname === "/sms" || url.pathname === "/sms/incoming" || url.pathname === "/webhook") && req.method === "POST") { const clientIp = req.socket.remoteAddress || "unknown"; logWebhookRequest( "sms-webhook", req.method || "POST", url.pathname || "/sms", clientIp ); if (!checkRateLimit(clientIp)) { logRateLimit("sms-webhook", clientIp); res.writeHead(429, { "Content-Type": "text/xml", "Retry-After": "60" }); res.end(twimlResponse("Too many requests. Please try again later.")); return; } const contentType = req.headers["content-type"] || ""; if (!contentType.includes("application/x-www-form-urlencoded")) { logContentTypeInvalid("sms-webhook", contentType, clientIp); res.writeHead(400, { "Content-Type": "text/xml" }); res.end(twimlResponse("Invalid content type")); return; } let body = ""; let bodyTooLarge = false; req.on("data", (chunk) => { body += chunk; if (body.length > MAX_BODY_SIZE) { bodyTooLarge = true; logBodyTooLarge("sms-webhook", body.length, clientIp); req.destroy(); } }); req.on("end", async () => { if (bodyTooLarge) { res.writeHead(413, { "Content-Type": "text/xml" }); res.end(twimlResponse("Request too large")); return; } try { const payload = parseFormData( body ); const twilioSignature = req.headers["x-twilio-signature"]; const webhookUrl = `${req.headers["x-forwarded-proto"] || "http"}://${req.headers.host}${req.url}`; if (twilioSignature && !verifyTwilioSignature( webhookUrl, payload, twilioSignature )) { logSignatureInvalid("sms-webhook", clientIp); console.error("[sms-webhook] Invalid Twilio signature"); res.writeHead(401, { "Content-Type": "text/xml" }); res.end(twimlResponse("Unauthorized")); return; } const result = await handleSMSWebhook(payload); res.writeHead(200, { "Content-Type": "text/xml" }); res.end(twimlResponse(result.response)); } catch (err) { console.error("[sms-webhook] Error:", err); res.writeHead(500, { "Content-Type": "text/xml" }); res.end(twimlResponse("Error processing message")); } }); return; } if (url.pathname === "/sms/status" && req.method === "POST") { let body = ""; req.on("data", (chunk) => { body += chunk; }); req.on("end", () => { try { const payload = parseFormData(body); console.log( `[sms-webhook] Status update: ${payload["MessageSid"]} -> ${payload["MessageStatus"]}` ); const statusPath = join( homedir(), ".stackmemory", "sms-status.json" ); const statuses = existsSync(statusPath) ? JSON.parse(readFileSync(statusPath, "utf8")) : {}; statuses[payload["MessageSid"]] = payload["MessageStatus"]; writeFileSecure(statusPath, JSON.stringify(statuses, null, 2)); res.writeHead(200, { "Content-Type": "text/plain" }); res.end("OK"); } catch (err) { console.error("[sms-webhook] Status error:", err); res.writeHead(500); res.end("Error"); } }); return; } if (url.pathname === "/status") { const config = loadSMSConfig(); res.writeHead(200, { "Content-Type": "application/json" }); res.end( JSON.stringify({ enabled: config.enabled, pendingPrompts: config.pendingPrompts.length }) ); return; } if (url.pathname === "/request" && req.method === "GET") { const request = getIncomingRequest(); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ request })); return; } if (url.pathname === "/request/ack" && req.method === "POST") { markRequestProcessed(); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ success: true })); return; } if (url.pathname === "/send" && req.method === "POST") { let body = ""; req.on("data", (chunk) => { body += chunk; if (body.length > MAX_BODY_SIZE) { req.destroy(); } }); req.on("end", async () => { try { const payload = JSON.parse(body); const message = payload.message || payload.body || ""; const title = payload.title || "Notification"; const type = payload.type || "custom"; if (!message) { res.writeHead(400, { "Content-Type": "application/json" }); res.end( JSON.stringify({ success: false, error: "Message required" }) ); return; } const result = await sendNotification({ type, title, message }); res.writeHead(result.success ? 200 : 500, { "Content-Type": "application/json" }); res.end(JSON.stringify(result)); } catch (err) { console.error("[sms-webhook] Send error:", err); res.writeHead(500, { "Content-Type": "application/json" }); res.end( JSON.stringify({ success: false, error: err instanceof Error ? err.message : "Send failed" }) ); } }); return; } res.writeHead(404); res.end("Not found"); } ); server.listen(port, () => { console.log(`[sms-webhook] Server listening on port ${port}`); console.log( `[sms-webhook] Incoming messages: http://localhost:${port}/sms/incoming` ); console.log( `[sms-webhook] Status callback: http://localhost:${port}/sms/status` ); console.log(`[sms-webhook] Configure these URLs in Twilio console`); setInterval(() => { try { const expiredPrompts = cleanupExpiredPrompts(); const oldActions = cleanupOldActions(); if (expiredPrompts > 0 || oldActions > 0) { logCleanup("sms-webhook", expiredPrompts, oldActions); console.log( `[sms-webhook] Cleanup: ${expiredPrompts} expired prompts, ${oldActions} old actions` ); } } catch { } }, CLEANUP_INTERVAL_MS); console.log( `[sms-webhook] Cleanup interval: every ${CLEANUP_INTERVAL_MS / 1e3}s` ); }); } async function smsWebhookMiddleware(req, res) { const result = await handleSMSWebhook(req.body); res.type("text/xml"); res.send(twimlResponse(result.response)); } if (process.argv[1]?.endsWith("sms-webhook.js")) { const port = parseInt(process.env["SMS_WEBHOOK_PORT"] || "3456", 10); startWebhookServer(port); } export { getIncomingRequest, handleSMSWebhook, markRequestProcessed, smsWebhookMiddleware, startWebhookServer }; //# sourceMappingURL=sms-webhook.js.map