feat: Slack alerts for chat widget escalations + support@ email polling
- notifications/slack.ts: added sendChatEscalationAlert (fires when visitor asks about pricing, demo, help, or a human) and sendSupportEmailAlert - email-poller.ts: polls support@squaremcp.com every 5 min via IMAP, deduplicates with Redis (support📧alerted_uids), fires Slack alert for each new unseen message - index.ts: detectEscalation() scans last user message for trigger phrases; chat endpoint fires alert fire-and-forget after responding; startEmailPoller() called on server boot Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -22,7 +22,7 @@ spec:
|
|||||||
fsGroup: 1000
|
fsGroup: 1000
|
||||||
containers:
|
containers:
|
||||||
- name: hermes-mcp
|
- name: hermes-mcp
|
||||||
image: localhost:32000/hermes-mcp@sha256:54488f625b5a065f3cfb30d9d0afe269dac65aadd8206652d27da034daf1dee4
|
image: localhost:32000/hermes-mcp@sha256:0bd3355cc4b5a3727e56cd62364127deb8801d41f4e3b9dc48e9051e11f52b8f
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
securityContext:
|
securityContext:
|
||||||
allowPrivilegeEscalation: false
|
allowPrivilegeEscalation: false
|
||||||
|
|||||||
42
src/email-poller.ts
Normal file
42
src/email-poller.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { searchMessages } from './imap.js';
|
||||||
|
import { sendSupportEmailAlert } from './notifications/slack.js';
|
||||||
|
import redis from './redis.js';
|
||||||
|
|
||||||
|
const REDIS_KEY = 'support:email:alerted_uids';
|
||||||
|
const POLL_INTERVAL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
async function pollSupportInbox() {
|
||||||
|
try {
|
||||||
|
const messages = await searchMessages('', 20, 'sqcp_support');
|
||||||
|
if (messages.length === 0) return;
|
||||||
|
|
||||||
|
const alreadyAlerted = await redis.sMembers(REDIS_KEY);
|
||||||
|
const alerted = new Set(alreadyAlerted);
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (msg.seen) continue;
|
||||||
|
const key = String(msg.uid);
|
||||||
|
if (alerted.has(key)) continue;
|
||||||
|
|
||||||
|
await sendSupportEmailAlert({
|
||||||
|
uid: msg.uid,
|
||||||
|
subject: msg.subject,
|
||||||
|
from: msg.from,
|
||||||
|
date: msg.date,
|
||||||
|
});
|
||||||
|
|
||||||
|
await redis.sAdd(REDIS_KEY, key);
|
||||||
|
// Keep the set from growing unbounded — trim to last 500 UIDs
|
||||||
|
await redis.expire(REDIS_KEY, 60 * 60 * 24 * 30); // 30-day TTL
|
||||||
|
console.log(`[email-poller] alerted on uid=${msg.uid} from=${msg.from}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[email-poller] poll error:', (err as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startEmailPoller() {
|
||||||
|
console.log('[email-poller] starting — polling support@squaremcp.com every 5 min');
|
||||||
|
pollSupportInbox();
|
||||||
|
setInterval(pollSupportInbox, POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
31
src/index.ts
31
src/index.ts
@@ -37,6 +37,8 @@ import { deliverWebhook, isValidWebhookUrl } from './webhooks/delivery.js';
|
|||||||
import { notifyNewPilotRequest } from './notifications/index.js';
|
import { notifyNewPilotRequest } from './notifications/index.js';
|
||||||
import redis from './redis.js';
|
import redis from './redis.js';
|
||||||
import { handleChat, type ChatMessage } from './chat.js';
|
import { handleChat, type ChatMessage } from './chat.js';
|
||||||
|
import { startEmailPoller } from './email-poller.js';
|
||||||
|
import { sendChatEscalationAlert } from './notifications/slack.js';
|
||||||
|
|
||||||
process.on('uncaughtException', (err) => {
|
process.on('uncaughtException', (err) => {
|
||||||
console.error('FATAL uncaughtException:', err);
|
console.error('FATAL uncaughtException:', err);
|
||||||
@@ -1997,6 +1999,24 @@ app.post('/api/facebook/video', requireAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── Chat widget endpoint ────────────────────────────────────────
|
// ── Chat widget endpoint ────────────────────────────────────────
|
||||||
|
const ESCALATION_PATTERNS = [
|
||||||
|
/\b(human|person|agent|someone|anybody|real person)\b/i,
|
||||||
|
/\bspeak\s+to\b|\btalk\s+to\b|\bcontact\s+(you|someone|support|sales)\b/i,
|
||||||
|
/\b(pricing|price|cost|how much|plans?|subscribe|sign\s*up|get\s+started|trial|demo)\b/i,
|
||||||
|
/\b(help me|need help|can you help|support)\b/i,
|
||||||
|
/\bemail\s+(you|garfield|support|sales)\b/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
function detectEscalation(messages: ChatMessage[]): string | null {
|
||||||
|
const lastUser = [...messages].reverse().find(m => m.role === 'user');
|
||||||
|
if (!lastUser) return null;
|
||||||
|
for (const pattern of ESCALATION_PATTERNS) {
|
||||||
|
const match = lastUser.content.match(pattern);
|
||||||
|
if (match) return match[0];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
app.post('/api/chat', async (req, res) => {
|
app.post('/api/chat', async (req, res) => {
|
||||||
const { messages } = req.body as { messages?: ChatMessage[] };
|
const { messages } = req.body as { messages?: ChatMessage[] };
|
||||||
if (!Array.isArray(messages) || messages.length === 0) {
|
if (!Array.isArray(messages) || messages.length === 0) {
|
||||||
@@ -2006,6 +2026,15 @@ app.post('/api/chat', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const { reply, toolsUsed } = await handleChat(messages);
|
const { reply, toolsUsed } = await handleChat(messages);
|
||||||
res.json({ reply, toolsUsed });
|
res.json({ reply, toolsUsed });
|
||||||
|
|
||||||
|
const trigger = detectEscalation(messages);
|
||||||
|
if (trigger) {
|
||||||
|
sendChatEscalationAlert({
|
||||||
|
trigger,
|
||||||
|
conversation: messages,
|
||||||
|
userAgent: (req as { get: (h: string) => string | undefined }).get('user-agent'),
|
||||||
|
}).catch(err => console.error('[chat] escalation alert error:', err));
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[chat] error:', (err as Error).message);
|
console.error('[chat] error:', (err as Error).message);
|
||||||
res.status(500).json({ error: 'Chat unavailable' });
|
res.status(500).json({ error: 'Chat unavailable' });
|
||||||
@@ -2153,6 +2182,8 @@ async function main() {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
startEmailPoller();
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Hermes MCP server running on port ${PORT}`);
|
console.log(`Hermes MCP server running on port ${PORT}`);
|
||||||
console.log(` Streamable HTTP: ${SERVER_URL}/mcp`);
|
console.log(` Streamable HTTP: ${SERVER_URL}/mcp`);
|
||||||
|
|||||||
@@ -1,5 +1,28 @@
|
|||||||
const SLACK_WEBHOOK_URL = process.env['SLACK_PILOT_WEBHOOK_URL'] ?? '';
|
const SLACK_WEBHOOK_URL = process.env['SLACK_PILOT_WEBHOOK_URL'] ?? '';
|
||||||
|
|
||||||
|
async function postToSlack(blocks: object[]): Promise<boolean> {
|
||||||
|
if (!SLACK_WEBHOOK_URL) {
|
||||||
|
console.warn('[notification:slack] SLACK_PILOT_WEBHOOK_URL not set, skipping');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(SLACK_WEBHOOK_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ blocks }),
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error(`[notification:slack] HTTP ${res.status}: ${await res.text()}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[notification:slack] fetch error:', (err as Error).message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface PilotAlertPayload {
|
export interface PilotAlertPayload {
|
||||||
requestId: string;
|
requestId: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -72,24 +95,70 @@ export async function sendSlackAlert(payload: PilotAlertPayload): Promise<boolea
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
try {
|
const ok = await postToSlack(blocks);
|
||||||
const res = await fetch(SLACK_WEBHOOK_URL, {
|
if (ok) console.log(`[notification:slack] pilot alert sent for request ${payload.requestId}`);
|
||||||
method: 'POST',
|
return ok;
|
||||||
headers: { 'Content-Type': 'application/json' },
|
}
|
||||||
body: JSON.stringify({ blocks }),
|
|
||||||
signal: AbortSignal.timeout(10_000),
|
export interface ChatEscalationPayload {
|
||||||
});
|
trigger: string;
|
||||||
|
conversation: Array<{ role: string; content: string }>;
|
||||||
if (!res.ok) {
|
userAgent?: string;
|
||||||
const text = await res.text();
|
}
|
||||||
console.error(`[notification:slack] HTTP ${res.status}: ${text}`);
|
|
||||||
return false;
|
export async function sendChatEscalationAlert(payload: ChatEscalationPayload): Promise<boolean> {
|
||||||
}
|
const preview = payload.conversation
|
||||||
|
.slice(-6)
|
||||||
console.log(`[notification:slack] alert sent for request ${payload.requestId}`);
|
.map(m => `*${m.role === 'user' ? 'Visitor' : 'Bot'}:* ${m.content.slice(0, 300)}`)
|
||||||
return true;
|
.join('\n');
|
||||||
} catch (err) {
|
|
||||||
console.error('[notification:slack] fetch error:', (err as Error).message);
|
const blocks = [
|
||||||
return false;
|
{
|
||||||
}
|
type: 'header',
|
||||||
|
text: { type: 'plain_text', text: '💬 Chat Widget — Visitor Needs Help', emoji: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'section',
|
||||||
|
text: { type: 'mrkdwn', text: `*Trigger phrase:* \`${payload.trigger}\`` },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'section',
|
||||||
|
text: { type: 'mrkdwn', text: `*Recent conversation:*\n${preview}` },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'context',
|
||||||
|
elements: [{ type: 'mrkdwn', text: payload.userAgent ? `UA: ${payload.userAgent.slice(0, 120)}` : 'UA: unknown' }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return postToSlack(blocks);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupportEmailPayload {
|
||||||
|
uid: number;
|
||||||
|
subject: string;
|
||||||
|
from: string;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendSupportEmailAlert(payload: SupportEmailPayload): Promise<boolean> {
|
||||||
|
const blocks = [
|
||||||
|
{
|
||||||
|
type: 'header',
|
||||||
|
text: { type: 'plain_text', text: '📧 New Email → support@squaremcp.com', emoji: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'section',
|
||||||
|
fields: [
|
||||||
|
{ type: 'mrkdwn', text: `*From:*\n${payload.from}` },
|
||||||
|
{ type: 'mrkdwn', text: `*Date:*\n${payload.date}` },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'section',
|
||||||
|
text: { type: 'mrkdwn', text: `*Subject:*\n${payload.subject}` },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return postToSlack(blocks);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user