/** * Slack Command Handler — Bidirectional Intelligence * * Handles incoming Slack slash commands and messages. * Uses LLM for natural language understanding when needed. * * Commands: * /discover → asks clarifying questions * /discover region:UK → direct run with params * /leads → show today's qualified leads * /lead [company] → full lead details * /status → system status * /pause → pause automatic runs * /resume → resume automatic runs * /quota [number] → set today's quota * /quota [number] always → permanent change */ import { getSupabaseClient } from "../shared/supabase/client"; import { setQuotaOverride, isSystemPaused } from "../discovery/lib/territory-manager"; import { sendClarifyingQuestions } from "./slack-service"; import { logger } from "../shared/utils/logger"; export interface SlackCommand { command: string; text: string; userId: string; channelId: string; } /** * Route incoming slash commands. */ export async function handleSlackCommand(cmd: SlackCommand): Promise { const { command, text } = cmd; const args = text.trim().toLowerCase(); switch (command) { case "/discover": return handleDiscover(args, cmd); case "/leads": return handleLeads(); case "/lead": return handleLeadDetail(text); case "/status": return handleStatus(); case "/pause": return handlePause(); case "/resume": return handleResume(); case "/quota": return handleQuota(text); default: return `Unknown command: ${command}`; } } // ─── /discover ─────────────────────────────────────────────── async function handleDiscover(args: string, cmd: SlackCommand): Promise { // Parse structured params if provided const params = parseParams(args); if (params.region && params.industry) { // Direct run — no questions needed const { manualDiscoveryTask } = await import("../discovery/trigger-tasks/manual-discovery"); await manualDiscoveryTask.trigger({ region: params.region.toUpperCase() as any, industry: params.industry, maxCompanies: parseInt(params.max ?? "20", 10), triggeredBy: `slack:${cmd.userId}`, }); return `🚀 Manual discovery started:\n• Region: ${params.region.toUpperCase()}\n• Industry: ${params.industry}\n• Max: ${params.max ?? 20}\nI'll notify you when complete.`; } if (args && !params.region) { // Natural language: "aj China pe kaam karo" // Ask clarifying questions await sendClarifyingQuestions(args, [ { question: "Which cities?", options: ["All major cities", "Capital only", "Let me specify..."], }, { question: "Which industry?", options: ["Healthcare (dental, medical)", "Manufacturing", "Technology/SaaS", "All service businesses"], }, { question: "How many leads?", options: ["5 (quick)", "10 (standard)", "20 (deep scan)"], }, ]); return "I've posted clarifying questions ☝️"; } // No args — interactive mode return "Usage:\n• `/discover region:UK industry:dental` — direct run\n• `/discover China pe kaam karo` — natural language\n• `/discover` — this help message"; } // ─── /leads ────────────────────────────────────────────────── async function handleLeads(): Promise { const db = getSupabaseClient(); const today = new Date(); today.setHours(0, 0, 0, 0); const { data: leads } = await db .from("lead_scores") .select(` total_score, tier, companies (name, domain, industry, city, service_match), contacts (full_name, email, email_verified, linkedin_personal_url) `) .gte("created_at", today.toISOString()) .order("total_score", { ascending: false }); if (!leads?.length) return "No leads found today yet."; const lines = leads.map((l: any, i: number) => { const emoji = l.tier === "hot" ? "🔥" : l.tier === "warm" ? "✅" : "📋"; const email = l.contacts?.email_verified ? "📧✓" : l.contacts?.email ? "📧" : "—"; const li = l.contacts?.linkedin_personal_url ? "💼" : "—"; return `${emoji} ${l.total_score} | ${l.companies?.name ?? "?"} | ${l.companies?.industry ?? "?"} | ${l.companies?.city ?? "?"} | ${email} ${li} | ${l.companies?.service_match ?? "—"}`; }); return `*Today's Leads (${leads.length}):*\n\n` + `Score | Company | Industry | City | Channels | Service\n` + `${"─".repeat(60)}\n` + lines.join("\n") + `\n\nType \`/lead [company name]\` for full details`; } // ─── /lead [company] ──────────────────────────────────────── async function handleLeadDetail(companySearch: string): Promise { if (!companySearch.trim()) return "Usage: `/lead ABC Dental`"; const db = getSupabaseClient(); const { data: companies } = await db .from("companies") .select("*") .ilike("name", `%${companySearch.trim()}%`) .limit(1); if (!companies?.length) return `No company found matching "${companySearch}"`; const company = companies[0]; const { data: contacts } = await db.from("contacts").select("*").eq("company_id", company.id) as any; const { data: scores } = await db.from("lead_scores").select("*").eq("company_id", company.id).limit(1) as any; const { data: profiles } = await db.from("lead_profiles").select("*").eq("company_id", company.id).limit(1) as any; const score = (scores as any)?.[0]; const profile = (profiles as any)?.[0]; const contact = (contacts as any)?.[0]; return `*${company.name}*\n` + `Domain: ${company.domain}\n` + `Industry: ${company.industry ?? "?"} · Employees: ${company.employee_count ?? "?"}\n` + `City: ${company.city ?? "?"} · ${company.country ?? "?"}\n` + `Service Match: ${company.service_match ?? "—"}\n` + `LinkedIn: ${company.linkedin_url ?? "—"}\n\n` + `*Score:* ${score?.total_score ?? "?"}/100 — ${score?.tier?.toUpperCase() ?? "?"}\n` + ` Fit: ${score?.company_fit ?? "?"}/25 · AI: ${score?.ai_readiness ?? "?"}/20 · Service: ${score?.service_match_score ?? "?"}/20\n` + ` Contact: ${score?.decision_maker ?? "?"}/20 · Timing: ${score?.timing_score ?? "?"}/15\n\n` + `*Profile:*\n${profile?.profile_summary ?? "No profile yet"}\n` + `Pain: ${(profile?.pain_points ?? []).join(", ")}\n` + `Angle: _${profile?.outreach_angle ?? "?"}_\n\n` + `*Contact:* ${contact?.full_name ?? "?"} — ${contact?.title ?? "?"}\n` + ` Email: ${contact?.email ?? "—"} ${contact?.email_verified ? "✓" : ""}\n` + ` LinkedIn: ${contact?.linkedin_personal_url ?? "—"}\n` + ` Social: ${JSON.stringify(contact?.social_profiles ?? {})}`; } // ─── /status ───────────────────────────────────────────────── async function handleStatus(): Promise { const db = getSupabaseClient(); const paused = await isSystemPaused(); const { data: quotaConfig } = await db.from("system_config").select("value").eq("key", "daily_quota").single(); const quota = quotaConfig?.value; const { data: territory } = await db.from("system_config").select("value").eq("key", "current_territory").single(); const pos = territory?.value; const { data: todayRuns } = await db .from("discovery_runs") .select("status, leads_qualified") .gte("ran_at", new Date(new Date().setHours(0, 0, 0, 0)).toISOString()); const todayLeads = todayRuns?.reduce((sum: number, r: any) => sum + (r.leads_qualified ?? 0), 0) ?? 0; return `*System Status*\n` + `State: ${paused ? "⏸️ PAUSED" : "▶️ RUNNING"}\n` + `Daily Quota: ${(quota as any)?.today_override ?? (quota as any)?.default ?? 10}\n` + `Leads Today: ${todayLeads}\n` + `Current Territory: ${(pos as any)?.countryCode ?? "?"} city#${(pos as any)?.cityIndex ?? 0}\n` + `Runs Today: ${todayRuns?.length ?? 0}`; } // ─── /pause, /resume ───────────────────────────────────────── async function handlePause(): Promise { const db = getSupabaseClient(); await db.from("system_config").update({ value: { enabled: true, paused: true, paused_by: "slack" }, updated_at: new Date().toISOString(), } as any).eq("key", "auto_mode"); return "⏸️ System paused. Automatic runs will not start.\nType `/resume` to restart."; } async function handleResume(): Promise { const db = getSupabaseClient(); await db.from("system_config").update({ value: { enabled: true, paused: false, paused_by: null }, updated_at: new Date().toISOString(), } as any).eq("key", "auto_mode"); return "▶️ System resumed. Next automatic run will proceed on schedule."; } // ─── /quota ────────────────────────────────────────────────── async function handleQuota(text: string): Promise { const parts = text.trim().split(/\s+/); const num = parseInt(parts[0], 10); if (isNaN(num) || num < 1 || num > 100) { return "Usage: `/quota 15` (today only) or `/quota 15 always` (permanent)"; } const permanent = parts[1] === "always" || parts[1] === "permanent"; await setQuotaOverride(num, permanent); return permanent ? `✅ Daily quota permanently set to ${num} leads/day` : `✅ Today's quota set to ${num} leads. Tomorrow back to default.`; } // ─── Helpers ───────────────────────────────────────────────── function parseParams(text: string): Record { const params: Record = {}; const matches = text.matchAll(/(\w+):(\S+)/g); for (const match of matches) { params[match[1]] = match[2]; } return params; }