iDevBuddy commited on
Commit
5f138d4
Β·
1 Parent(s): bd28470

feat: Add Slack Events integration, Dockerfiles, and Hugging Face deployment config

Browse files
Dockerfile ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:20-alpine AS builder
2
+ WORKDIR /app
3
+ COPY package*.json ./
4
+ RUN npm ci
5
+ COPY . .
6
+ RUN npm run build
7
+
8
+ FROM node:20-alpine
9
+ WORKDIR /app
10
+ COPY package*.json ./
11
+ RUN npm ci --only=production
12
+ COPY --from=builder /app/dist ./dist
13
+ EXPOSE 7860
14
+ CMD ["node", "dist/index.js"]
Dockerfile.node ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:20-alpine AS builder
2
+ WORKDIR /app
3
+ COPY package*.json ./
4
+ RUN npm ci
5
+ COPY . .
6
+ RUN npm run build
7
+
8
+ FROM node:20-alpine
9
+ WORKDIR /app
10
+ COPY package*.json ./
11
+ RUN npm ci --only=production
12
+ COPY --from=builder /app/dist ./dist
13
+ # If we need the .env in production, it will be loaded from env_file in docker-compose
14
+ EXPOSE 3000
15
+ CMD ["node", "dist/index.js"]
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json CHANGED
@@ -6,7 +6,7 @@
6
  "scripts": {
7
  "build": "tsc",
8
  "dev": "ts-node-dev --respawn --transpile-only src/index.ts",
9
- "trigger:dev": "npx trigger.dev@latest dev",
10
  "typecheck": "tsc --noEmit",
11
  "lint": "eslint . --ext .ts"
12
  },
 
6
  "scripts": {
7
  "build": "tsc",
8
  "dev": "ts-node-dev --respawn --transpile-only src/index.ts",
9
+ "trigger:dev": "npx trigger.dev@3.3.17 dev",
10
  "typecheck": "tsc --noEmit",
11
  "lint": "eslint . --ext .ts"
12
  },
src/discovery/trigger-tasks/manual-discovery.ts CHANGED
@@ -118,12 +118,21 @@ async function processManualCompany(
118
  return "new";
119
  }
120
 
121
- const decisionMakers = linkedin?.decisionMakers ?? [];
122
- const contactsSaved = await enrichContacts(saved.id, domain, decisionMakers);
 
 
 
 
 
 
 
 
 
123
 
124
  await db.from("companies").update({ status: "profiled" }).eq("id", saved.id);
125
 
126
- if (contactsSaved > 0) {
127
  const { profilingTask } = await import("../../profiling/trigger-tasks/profiling-router");
128
  await profilingTask.trigger({
129
  company_id: saved.id,
 
118
  return "new";
119
  }
120
 
121
+ const contactsSaved = await enrichContacts(
122
+ saved.id,
123
+ domain,
124
+ normalized.name,
125
+ normalized.employee_count,
126
+ industry,
127
+ website.text.slice(0, 300),
128
+ website.html,
129
+ normalized.linkedin_url,
130
+ "manual-" + saved.id
131
+ );
132
 
133
  await db.from("companies").update({ status: "profiled" }).eq("id", saved.id);
134
 
135
+ if (contactsSaved.length > 0) {
136
  const { profilingTask } = await import("../../profiling/trigger-tasks/profiling-router");
137
  await profilingTask.trigger({
138
  company_id: saved.id,
src/index.ts ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import http from "http";
2
+ import crypto from "crypto";
3
+ import querystring from "querystring";
4
+ import { handleSlackCommand, type SlackCommand } from "./slack/slack-commands";
5
+ import { getEnv } from "./shared/config/env";
6
+ import { logger } from "./shared/utils/logger";
7
+
8
+ // Initialize environment
9
+ const env = getEnv();
10
+ const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000;
11
+
12
+ /**
13
+ * Verify Slack request signature to ensure it comes from Slack.
14
+ */
15
+ function verifySlackSignature(
16
+ timestamp: string,
17
+ rawBody: string,
18
+ signature: string,
19
+ signingSecret: string
20
+ ): boolean {
21
+ if (!signingSecret) {
22
+ logger.warn("SLACK_SIGNING_SECRET is not configured. Skipping signature verification.");
23
+ return true;
24
+ }
25
+
26
+ // Prevent replay attacks (5 minute threshold)
27
+ const fiveMinutesAgo = Math.floor(Date.now() / 1000) - 60 * 5;
28
+ if (parseInt(timestamp, 10) < fiveMinutesAgo) {
29
+ logger.warn("Slack request timestamp is too old. Potential replay attack.");
30
+ return false;
31
+ }
32
+
33
+ const sigBaseString = `v0:${timestamp}:${rawBody}`;
34
+ const mySignature = "v0=" + crypto
35
+ .createHmac("sha256", signingSecret)
36
+ .update(sigBaseString, "utf8")
37
+ .digest("hex");
38
+
39
+ try {
40
+ return crypto.timingSafeEqual(Buffer.from(mySignature, "utf8"), Buffer.from(signature, "utf8"));
41
+ } catch (err) {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ // Start HTTP Server
47
+ const server = http.createServer((req, res) => {
48
+ if (req.method === "POST" && req.url === "/slack/commands") {
49
+ let body = "";
50
+
51
+ req.on("data", (chunk) => {
52
+ body += chunk.toString();
53
+ });
54
+
55
+ req.on("end", async () => {
56
+ const timestamp = req.headers["x-slack-request-timestamp"] as string;
57
+ const signature = req.headers["x-slack-signature"] as string;
58
+ const signingSecret = env.SLACK_SIGNING_SECRET;
59
+
60
+ // Verify signature
61
+ if (signingSecret && (!timestamp || !signature || !verifySlackSignature(timestamp, body, signature, signingSecret))) {
62
+ logger.error("Slack signature verification failed.");
63
+ res.writeHead(401, { "Content-Type": "application/json" });
64
+ res.end(JSON.stringify({ error: "Unauthorized request signature" }));
65
+ return;
66
+ }
67
+
68
+ try {
69
+ const parsed = querystring.parse(body);
70
+
71
+ const commandStr = parsed.command as string;
72
+ const textStr = (parsed.text as string) || "";
73
+ const userIdStr = parsed.user_id as string;
74
+ const channelIdStr = parsed.channel_id as string;
75
+
76
+ logger.info({ command: commandStr, text: textStr, userId: userIdStr }, "πŸ“¬ Received Slack command");
77
+
78
+ const cmd: SlackCommand = {
79
+ command: commandStr,
80
+ text: textStr,
81
+ userId: userIdStr,
82
+ channelId: channelIdStr,
83
+ };
84
+
85
+ // Call the command handler
86
+ const replyText = await handleSlackCommand(cmd);
87
+
88
+ // Send response back to Slack
89
+ res.writeHead(200, { "Content-Type": "application/json" });
90
+ res.end(
91
+ JSON.stringify({
92
+ response_type: "in_channel", // Make it visible in the channel
93
+ text: replyText,
94
+ })
95
+ );
96
+ } catch (err: any) {
97
+ logger.error({ err }, "Error processing Slack command");
98
+ res.writeHead(200, { "Content-Type": "application/json" });
99
+ res.end(
100
+ JSON.stringify({
101
+ response_type: "ephemeral",
102
+ text: `❌ Error executing command: ${err.message || err}`,
103
+ })
104
+ );
105
+ }
106
+ });
107
+ } else if (req.method === "POST" && req.url === "/slack/events") {
108
+ let body = "";
109
+
110
+ req.on("data", (chunk) => {
111
+ body += chunk.toString();
112
+ });
113
+
114
+ req.on("end", async () => {
115
+ const timestamp = req.headers["x-slack-request-timestamp"] as string;
116
+ const signature = req.headers["x-slack-signature"] as string;
117
+ const signingSecret = env.SLACK_SIGNING_SECRET;
118
+
119
+ // Verify signature
120
+ if (signingSecret && (!timestamp || !signature || !verifySlackSignature(timestamp, body, signature, signingSecret))) {
121
+ logger.error("Slack event signature verification failed.");
122
+ res.writeHead(401, { "Content-Type": "application/json" });
123
+ res.end(JSON.stringify({ error: "Unauthorized request signature" }));
124
+ return;
125
+ }
126
+
127
+ try {
128
+ const payload = JSON.parse(body);
129
+
130
+ // 1. Handle URL Verification Challenge from Slack
131
+ if (payload.type === "url_verification") {
132
+ logger.info("Handling Slack URL verification challenge");
133
+ res.writeHead(200, { "Content-Type": "application/json" });
134
+ res.end(JSON.stringify({ challenge: payload.challenge }));
135
+ return;
136
+ }
137
+
138
+ // 2. Handle Event Callback
139
+ if (payload.type === "event_callback") {
140
+ const event = payload.event;
141
+ if (event) {
142
+ // Ignore bot messages to prevent infinite loops
143
+ const isBot = event.bot_id || event.subtype === "bot_message" || !event.user;
144
+ if (isBot) {
145
+ res.writeHead(200);
146
+ res.end();
147
+ return;
148
+ }
149
+
150
+ const channelType = event.channel_type;
151
+ const eventType = event.type;
152
+
153
+ // We handle DMs (message.im or channel ID starts with 'D') or direct mentions (app_mention)
154
+ const isDM = eventType === "message" && (channelType === "im" || event.channel.startsWith("D"));
155
+ const isMention = eventType === "app_mention";
156
+
157
+ if (isDM || isMention) {
158
+ let text = event.text || "";
159
+
160
+ // Strip bot tag if it's a mention
161
+ if (isMention) {
162
+ text = text.replace(/<@U[A-Z0-9]+>/g, "").trim();
163
+ }
164
+
165
+ const userId = event.user;
166
+ const channelId = event.channel;
167
+ const threadTs = event.thread_ts || event.ts;
168
+
169
+ logger.info(
170
+ { eventType, channelId, userId, text },
171
+ "πŸ’¬ Received natural language chatbot event"
172
+ );
173
+
174
+ // Respond HTTP 200 OK to Slack immediately to prevent retries (timeout threshold is 3s)
175
+ res.writeHead(200);
176
+ res.end();
177
+
178
+ // Route to natural language agent in background
179
+ const { handleSlackChat } = await import("./slack/slack-agent");
180
+ handleSlackChat(text, userId, channelId, threadTs).catch((err) => {
181
+ logger.error({ err }, "Error in handleSlackChat background worker");
182
+ });
183
+ return;
184
+ }
185
+ }
186
+ }
187
+
188
+ // Default response for unhandled events
189
+ res.writeHead(200);
190
+ res.end();
191
+ } catch (err: any) {
192
+ logger.error({ err }, "Error processing Slack event");
193
+ res.writeHead(500, { "Content-Type": "application/json" });
194
+ res.end(JSON.stringify({ error: err.message || err }));
195
+ }
196
+ });
197
+ } else {
198
+ // Healthcheck or default route
199
+ res.writeHead(200, { "Content-Type": "application/json" });
200
+ res.end(JSON.stringify({ status: "healthy", service: "slack-command-listener" }));
201
+ }
202
+ });
203
+
204
+ server.listen(PORT, "0.0.0.0", () => {
205
+ logger.info(`πŸ”Œ Slack Command Listener server running on port ${PORT}`);
206
+ });
src/profiling/python-service/Dockerfile.python ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+ WORKDIR /app
3
+ COPY requirements.txt .
4
+ RUN pip install --no-cache-dir -r requirements.txt
5
+ COPY . .
6
+ EXPOSE 8000
7
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
src/profiling/python-service/config.py CHANGED
@@ -21,5 +21,6 @@ class Settings(BaseSettings):
21
 
22
  class Config:
23
  env_file = "../../../.env"
 
24
 
25
  settings = Settings()
 
21
 
22
  class Config:
23
  env_file = "../../../.env"
24
+ extra = "ignore"
25
 
26
  settings = Settings()
src/profiling/python-service/main.py CHANGED
@@ -145,4 +145,4 @@ async def profile_company(request: ProfileRequest, _auth: bool = Depends(verify_
145
 
146
  if __name__ == "__main__":
147
  import uvicorn
148
- uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
 
145
 
146
  if __name__ == "__main__":
147
  import uvicorn
148
+ uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=False)
src/profiling/python-service/nvidia_client.py CHANGED
@@ -229,10 +229,15 @@ def _safe_parse_json(text: str) -> Optional[dict]:
229
 
230
  async def _log_trace(trace_id, operation, model, result, success, company_id):
231
  try:
232
- from supabase import create_client
233
- sb = create_client(settings.SUPABASE_URL, settings.SUPABASE_SERVICE_ROLE_KEY)
234
-
235
- sb.table("llm_traces").insert({
 
 
 
 
 
236
  "trace_id": trace_id,
237
  "operation": operation,
238
  "model": model,
@@ -244,7 +249,9 @@ async def _log_trace(trace_id, operation, model, result, success, company_id):
244
  "success": success,
245
  "fallback_used": result.get("fallback_used", True) if result else True,
246
  "company_id": company_id,
247
- }).execute()
 
 
248
  except Exception as e:
249
  logger.debug(f"Trace log failed (non-critical): {e}")
250
 
 
229
 
230
  async def _log_trace(trace_id, operation, model, result, success, company_id):
231
  try:
232
+ import httpx
233
+ url = f"{settings.SUPABASE_URL}/rest/v1/llm_traces"
234
+ headers = {
235
+ "apikey": settings.SUPABASE_SERVICE_ROLE_KEY,
236
+ "Authorization": f"Bearer {settings.SUPABASE_SERVICE_ROLE_KEY}",
237
+ "Content-Type": "application/json",
238
+ "Prefer": "return=minimal"
239
+ }
240
+ payload = {
241
  "trace_id": trace_id,
242
  "operation": operation,
243
  "model": model,
 
249
  "success": success,
250
  "fallback_used": result.get("fallback_used", True) if result else True,
251
  "company_id": company_id,
252
+ }
253
+ async with httpx.AsyncClient() as client:
254
+ await client.post(url, json=payload, headers=headers)
255
  except Exception as e:
256
  logger.debug(f"Trace log failed (non-critical): {e}")
257
 
src/profiling/python-service/profiler.py CHANGED
@@ -10,7 +10,7 @@ Key differences from v1:
10
  """
11
 
12
  import logging
13
- from nvidia_client import call_with_consistency, MODELS
14
  from hallucination_guard import validate_profile_grounded
15
 
16
  logger = logging.getLogger(__name__)
 
10
  """
11
 
12
  import logging
13
+ from nvidia_client import call_with_consistency
14
  from hallucination_guard import validate_profile_grounded
15
 
16
  logger = logging.getLogger(__name__)
src/profiling/python-service/requirements.txt CHANGED
@@ -1,8 +1,7 @@
1
- fastapi==0.111.0
2
- uvicorn[standard]==0.30.0
3
- httpx==0.27.0
4
- pydantic==2.7.0
5
- pydantic-settings==2.2.0
6
- python-dotenv==1.0.1
7
- openai==1.30.0
8
- supabase==2.4.0
 
1
+ fastapi
2
+ uvicorn[standard]
3
+ httpx
4
+ pydantic
5
+ pydantic-settings
6
+ python-dotenv
7
+ openai
 
src/profiling/python-service/scorer.py CHANGED
@@ -12,7 +12,7 @@ So: LLM extracts signals, code does math.
12
  """
13
 
14
  import logging
15
- from nvidia_client import call_llm, MODELS
16
 
17
  logger = logging.getLogger(__name__)
18
 
@@ -113,7 +113,7 @@ async def _extract_signals(data, profile, contacts, trace_id) -> dict:
113
  operation="score",
114
  system_prompt=SYSTEM_PROMPT,
115
  user_prompt=prompt,
116
- model=MODELS["FAST"], # 8B model β€” signal extraction is simple
117
  temperature=0.1,
118
  max_tokens=400,
119
  json_mode=True,
 
12
  """
13
 
14
  import logging
15
+ from nvidia_client import call_llm
16
 
17
  logger = logging.getLogger(__name__)
18
 
 
113
  operation="score",
114
  system_prompt=SYSTEM_PROMPT,
115
  user_prompt=prompt,
116
+ model_index=2, # 8B model β€” signal extraction is simple
117
  temperature=0.1,
118
  max_tokens=400,
119
  json_mode=True,
src/profiling/trigger-tasks/profiling-router.ts CHANGED
@@ -23,38 +23,96 @@ export const profilingTask = task({
23
  logger.info({ company_id, domain }, "🧠 Profiling started");
24
 
25
  try {
26
- // ── Call Python AI Service ──────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  const response = await axios.post(
28
  `${env.PYTHON_AI_SERVICE_URL}/profile`,
29
- { company_id, domain, name, region, source },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  {
31
  headers: {
32
  "Content-Type": "application/json",
33
- "x-service-secret": env.PYTHON_AI_SERVICE_SECRET,
34
  },
35
  timeout: 120_000, // 2 min timeout for LLM
36
  }
37
  );
38
 
39
- const result = response.data;
 
 
 
 
40
  logger.info(
41
- { company_id, score: result.total_score, tier: result.tier },
42
  "βœ… Profiling complete"
43
  );
44
 
 
 
 
 
 
 
 
 
 
 
 
45
  // ── Route based on score tier ───────────────────────────
46
- await routeByTier(company_id, result, db, env);
47
 
48
  // ── Audit log ───────────────────────────────────────────
49
  auditLog("lead_profiled", "company", {
50
  company_id,
51
  domain,
52
- score: result.total_score,
53
- tier: result.tier,
54
- is_fallback: result.is_fallback,
55
  });
56
 
57
- return result;
58
  } catch (err: unknown) {
59
  // ── Python service unavailable β†’ fallback ───────────────
60
  if (axios.isAxiosError(err) && !err.response) {
@@ -146,12 +204,15 @@ async function notifySlack(
146
 
147
  const channelId = type === "review" ? env.SLACK_REVIEW_CHANNEL_ID : env.SLACK_ALERT_CHANNEL_ID;
148
 
149
- await axios.post("https://slack.com/api/chat.postMessage", {
150
  channel: channelId,
151
  ...message,
152
  }, {
153
  headers: { Authorization: `Bearer ${env.SLACK_BOT_TOKEN}` },
154
  });
 
 
 
155
  } catch (err) {
156
  logger.warn({ err }, "Slack notification failed β€” non-critical");
157
  }
 
23
  logger.info({ company_id, domain }, "🧠 Profiling started");
24
 
25
  try {
26
+ // 1. Fetch company data from Supabase
27
+ const { data: company, error: companyErr } = await db
28
+ .from("companies")
29
+ .select("*")
30
+ .eq("id", company_id)
31
+ .single();
32
+
33
+ if (companyErr || !company) {
34
+ throw new Error(`Company not found: ${companyErr?.message}`);
35
+ }
36
+
37
+ // 2. Fetch contacts from Supabase
38
+ const { data: contacts, error: contactsErr } = await db
39
+ .from("contacts")
40
+ .select("*")
41
+ .eq("company_id", company_id);
42
+
43
+ if (contactsErr) {
44
+ throw new Error(`Failed to fetch contacts: ${contactsErr.message}`);
45
+ }
46
+
47
+ // 3. Structure payload and call Python AI Service
48
  const response = await axios.post(
49
  `${env.PYTHON_AI_SERVICE_URL}/profile`,
50
+ {
51
+ company: {
52
+ id: company.id,
53
+ name: company.name,
54
+ industry: company.industry || "",
55
+ employee_count: company.employee_count,
56
+ description: company.description || "",
57
+ website_text: company.description || "", // Using description as website excerpt fallback
58
+ linkedin_description: "",
59
+ tech_stack: company.tech_stack || [],
60
+ ai_job_count: (company.growth_signals || []).filter((s: any) => s.type === "job_post").length,
61
+ pain_signals: company.pain_signals || [],
62
+ service_match: company.service_match,
63
+ },
64
+ contacts: (contacts || []).map((c: any) => ({
65
+ full_name: c.full_name,
66
+ email: c.email,
67
+ email_verified: c.email_verified,
68
+ linkedin_personal_url: c.linkedin_personal_url || c.linkedin_url,
69
+ social_profiles: c.social_profiles || {},
70
+ })),
71
+ trace_id: company.trace_id || `trace-${company_id.slice(0, 8)}`,
72
+ },
73
  {
74
  headers: {
75
  "Content-Type": "application/json",
76
+ "Authorization": `Bearer ${env.PYTHON_AI_SERVICE_SECRET}`,
77
  },
78
  timeout: 120_000, // 2 min timeout for LLM
79
  }
80
  );
81
 
82
+ const { profile, score, validation, meta } = response.data;
83
+ const totalScore = score?.total_score ?? 0;
84
+ const tier = score?.tier ?? "archive";
85
+ const needs_human_review = !(validation?.score_valid ?? true);
86
+
87
  logger.info(
88
+ { company_id, score: totalScore, tier },
89
  "βœ… Profiling complete"
90
  );
91
 
92
+ // Save profile and score in DB
93
+ await db.from("lead_profiles").upsert({
94
+ company_id,
95
+ ...profile,
96
+ }, { onConflict: "company_id" });
97
+
98
+ await db.from("lead_scores").upsert({
99
+ company_id,
100
+ ...score,
101
+ }, { onConflict: "company_id" });
102
+
103
  // ── Route based on score tier ───────────────────────────
104
+ await routeByTier(company_id, { total_score: totalScore, tier, needs_human_review }, db, env);
105
 
106
  // ── Audit log ───────────────────────────────────────────
107
  auditLog("lead_profiled", "company", {
108
  company_id,
109
  domain,
110
+ score: totalScore,
111
+ tier,
112
+ is_fallback: meta?.is_fallback ?? false,
113
  });
114
 
115
+ return response.data;
116
  } catch (err: unknown) {
117
  // ── Python service unavailable β†’ fallback ───────────────
118
  if (axios.isAxiosError(err) && !err.response) {
 
204
 
205
  const channelId = type === "review" ? env.SLACK_REVIEW_CHANNEL_ID : env.SLACK_ALERT_CHANNEL_ID;
206
 
207
+ const res = await axios.post("https://slack.com/api/chat.postMessage", {
208
  channel: channelId,
209
  ...message,
210
  }, {
211
  headers: { Authorization: `Bearer ${env.SLACK_BOT_TOKEN}` },
212
  });
213
+ if (res.data && res.data.ok === false) {
214
+ logger.warn({ error: res.data.error }, "Slack API responded with error. Make sure to invite the bot to the channel!");
215
+ }
216
  } catch (err) {
217
  logger.warn({ err }, "Slack notification failed β€” non-critical");
218
  }
src/shared/supabase/client.ts CHANGED
@@ -8,7 +8,6 @@ export function getSupabaseClient() {
8
  const env = getEnv();
9
  _client = createClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY, {
10
  auth: { persistSession: false },
11
- db: { schema: "public" },
12
  });
13
  }
14
  return _client;
 
8
  const env = getEnv();
9
  _client = createClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY, {
10
  auth: { persistSession: false },
 
11
  });
12
  }
13
  return _client;
src/slack/slack-agent.ts ADDED
@@ -0,0 +1,319 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { callLLM, MODELS } from "../shared/llm/nvidia-client";
2
+ import { getSupabaseClient } from "../shared/supabase/client";
3
+ import { manualDiscoveryTask } from "../discovery/trigger-tasks/manual-discovery";
4
+ import { logger } from "../shared/utils/logger";
5
+ import axios from "axios";
6
+ import { getEnv } from "../shared/config/env";
7
+
8
+ const env = getEnv();
9
+
10
+ // Helper to post messages back to Slack
11
+ async function replyToSlack(channelId: string, text: string, threadTs?: string): Promise<void> {
12
+ try {
13
+ await axios.post(
14
+ "https://slack.com/api/chat.postMessage",
15
+ {
16
+ channel: channelId,
17
+ text,
18
+ thread_ts: threadTs,
19
+ },
20
+ {
21
+ headers: { Authorization: `Bearer ${env.SLACK_BOT_TOKEN}` },
22
+ timeout: 5000,
23
+ }
24
+ );
25
+ } catch (err) {
26
+ logger.error({ err }, "Failed to reply to Slack");
27
+ }
28
+ }
29
+
30
+ // ─── Intent System Prompt ──────────────────────────────────────
31
+
32
+ const AGENT_SYSTEM_PROMPT = `You are "Lead Finder AI", an intelligent Slack Chatbot assistant.
33
+ Your job is to parse the user's natural language request (in English, Urdu, or Roman Urdu) and map it to a structured action.
34
+
35
+ You must respond ONLY with a valid JSON object matching this schema:
36
+ {
37
+ "intent": "discover" | "leads" | "lead_detail" | "status" | "pause" | "resume" | "quota" | "chat",
38
+ "params": {
39
+ "region": "US" | "UK" | "AU" | "UAE" | "SA" | "SG" (optional),
40
+ "industry": string (optional),
41
+ "maxCompanies": number (optional),
42
+ "companyName": string (optional),
43
+ "quotaAmount": number (optional),
44
+ "quotaPermanent": boolean (optional)
45
+ },
46
+ "explanation": "A very brief, friendly sentence in Roman Urdu explaining what you understood and what you are doing (e.g. 'Ji bilkul! Main abhi US ke dental leads dhoondta hoon.' or 'Bilkul, ye rahi aaj ki leads summary:')"
47
+ }
48
+
49
+ Intents mapping rules:
50
+ 1. "discover": Manual trigger of search/enrichment/scoring.
51
+ - Example: "aj US me SaaS leads dhoondo", "manual run UK dental", "dental leads US", "UK clinical leads nikal do", "discover dental UAE"
52
+ - Default region to US if not specified. Default maxCompanies to 10 if not specified.
53
+ 2. "leads": Today's qualified leads list.
54
+ - Example: "aj ki leads dikhao", "show today's leads", "aj kya mila?", "today leads summary", "leads"
55
+ 3. "lead_detail": Profile/score details about a specific company.
56
+ - Example: "clickup ki details do", "show lead clickup", "clickup ka batao", "lead detail of Google"
57
+ - Extract company name into companyName.
58
+ 4. "status": System config status (quota, pause/run mode, coordinates).
59
+ - Example: "status batao", "system kaisa chal raha?", "running status", "check status"
60
+ 5. "pause": Pauses automatic CRON daily runs.
61
+ - Example: "pause system", "automatic run rok do", "pause auto mode", "stop runs"
62
+ 6. "resume": Resumes automatic CRON daily runs.
63
+ - Example: "resume system", "auto runs start kar do", "resume run"
64
+ 7. "quota": Changes daily lead quota.
65
+ - Example: "quota 15 kar do", "set today's quota to 20", "set permanent quota to 50"
66
+ - Extract quotaAmount and quotaPermanent.
67
+ 8. "chat": General greetings, small talk, questions about how you work, or how to use you.
68
+ - Example: "hello", "hi", "tum kon ho?", "how do you work?", "help me"
69
+
70
+ Respond ONLY with raw JSON. Do not include markdown code block syntax or explanation outside JSON.`;
71
+
72
+ // ─── Main Chatbot handler ──────────────────────────────────────
73
+
74
+ export async function handleSlackChat(
75
+ userText: string,
76
+ userId: string,
77
+ channelId: string,
78
+ threadTs?: string
79
+ ): Promise<void> {
80
+ const cleanText = userText.trim();
81
+ logger.info({ userId, cleanText }, "πŸ€– AI Chatbot processing message");
82
+
83
+ // Call the LLM to parse the intent
84
+ const llmRes = await callLLM({
85
+ operation: "slack_intent_classification",
86
+ modelIndex: MODELS.LLAMA_70B, // Use LLaMA 70B for highly accurate intent mapping
87
+ systemPrompt: AGENT_SYSTEM_PROMPT,
88
+ userPrompt: cleanText,
89
+ jsonMode: true,
90
+ traceId: `slack-chat-${Date.now()}`,
91
+ });
92
+
93
+ if (!llmRes.parsed) {
94
+ await replyToSlack(
95
+ channelId,
96
+ "Maaf kijiye ga, mujhe aap ki baat samajh nahi aayi. Kya aap dobara keh sakte hain? (Greetings, /discover, /leads waghaira ke liye pooch sakte hain)",
97
+ threadTs
98
+ );
99
+ return;
100
+ }
101
+
102
+ const { intent, params, explanation } = llmRes.parsed as {
103
+ intent: string;
104
+ params: any;
105
+ explanation: string;
106
+ };
107
+
108
+ logger.info({ intent, params }, "🎯 Decoded intent");
109
+
110
+ // Respond with explanation first so user knows we are on it
111
+ if (explanation && intent !== "chat") {
112
+ await replyToSlack(channelId, `πŸ’¬ *Lead Finder AI:* ${explanation}`, threadTs);
113
+ }
114
+
115
+ const db = getSupabaseClient();
116
+
117
+ try {
118
+ switch (intent) {
119
+ case "discover": {
120
+ const region = params?.region || "US";
121
+ const industry = params?.industry || "SaaS";
122
+ const maxCompanies = params?.maxCompanies || 10;
123
+
124
+ await manualDiscoveryTask.trigger({
125
+ region: region.toUpperCase(),
126
+ industry,
127
+ maxCompanies,
128
+ triggeredBy: `slack-chat:${userId}`,
129
+ });
130
+
131
+ await replyToSlack(
132
+ channelId,
133
+ `πŸš€ **Manual Discovery Triggered!**\nβ€’ *Region:* ${region.toUpperCase()}\nβ€’ *Industry:* ${industry}\nβ€’ *Max Leads:* ${maxCompanies}\n\nJaise hi leads ready honge, main isi channel me card deliver kar dunga!`,
134
+ threadTs
135
+ );
136
+ break;
137
+ }
138
+
139
+ case "leads": {
140
+ const today = new Date();
141
+ today.setHours(0, 0, 0, 0);
142
+
143
+ const { data: leads } = await db
144
+ .from("lead_scores")
145
+ .select(`
146
+ total_score, tier,
147
+ companies (name, domain, industry, city, service_match),
148
+ contacts (full_name, email, email_verified, linkedin_personal_url)
149
+ `)
150
+ .gte("created_at", today.toISOString())
151
+ .order("total_score", { ascending: false });
152
+
153
+ if (!leads?.length) {
154
+ await replyToSlack(channelId, "πŸ“‹ Aaj ke din abhi tak koi leads qualified nahi huin.", threadTs);
155
+ break;
156
+ }
157
+
158
+ const lines = leads.map((l: any, i: number) => {
159
+ const emoji = l.tier === "hot" ? "πŸ”₯" : l.tier === "warm" ? "βœ…" : "πŸ“‹";
160
+ const email = l.contacts?.email_verified ? "πŸ“§βœ“" : l.contacts?.email ? "πŸ“§" : "β€”";
161
+ const li = l.contacts?.linkedin_personal_url ? "πŸ’Ό" : "β€”";
162
+ return `${emoji} *${l.total_score}* | *${l.companies?.name ?? "?"}* | ${l.companies?.industry ?? "?"} | ${l.companies?.city ?? "?"} | ${email} ${li}`;
163
+ });
164
+
165
+ const reply = `*Today's Leads Summary (${leads.length}):*\n\n` +
166
+ `Score | Company | Industry | City | Channels\n` +
167
+ `─`.repeat(40) + `\n` +
168
+ lines.join("\n") +
169
+ `\n\nAap kisi bhi company ki details pooch sakte hain (e.g. "ClickUp ki details do").`;
170
+
171
+ await replyToSlack(channelId, reply, threadTs);
172
+ break;
173
+ }
174
+
175
+ case "lead_detail": {
176
+ const companySearch = params?.companyName || "";
177
+ if (!companySearch) {
178
+ await replyToSlack(channelId, "Mujhe company ka naam batayein taake main detail nikal sakoon.", threadTs);
179
+ break;
180
+ }
181
+
182
+ const { data: companies } = await db
183
+ .from("companies")
184
+ .select("*")
185
+ .ilike("name", `%${companySearch.trim()}%`)
186
+ .limit(1);
187
+
188
+ if (!companies?.length) {
189
+ await replyToSlack(channelId, `❌ Mujhe "${companySearch}" naam ki koi company database me nahi mili.`, threadTs);
190
+ break;
191
+ }
192
+
193
+ const company = companies[0];
194
+ const { data: contacts } = await db.from("contacts").select("*").eq("company_id", company.id);
195
+ const { data: scores } = await db.from("lead_scores").select("*").eq("company_id", company.id).limit(1);
196
+ const { data: profiles } = await db.from("lead_profiles").select("*").eq("company_id", company.id).limit(1);
197
+
198
+ const score = scores?.[0];
199
+ const profile = profiles?.[0];
200
+ const contact = contacts?.[0];
201
+
202
+ const channels: string[] = [];
203
+ if (contact?.email) channels.push(`πŸ“§ ${contact.email} ${contact.email_verified ? "βœ“" : "(unverified)"}`);
204
+ if (contact?.linkedin_personal_url) channels.push(`πŸ’Ό <${contact.linkedin_personal_url}|LinkedIn>`);
205
+
206
+ const responseText = `*🏒 ${company.name}* (Domain: ${company.domain})\n` +
207
+ `β€’ *Location:* ${company.city ?? "?"}, ${company.country ?? "?"}\n` +
208
+ `β€’ *Industry:* ${company.industry ?? "?"} Β· *Employees:* ${company.employee_count ?? "?"}\n` +
209
+ `β€’ *Service Match:* ${company.service_match ?? "β€”"}\n\n` +
210
+ `*πŸ“Š AI Scoring: ${score?.total_score ?? "?"}/100 β€” ${score?.tier?.toUpperCase() ?? "?"}*\n` +
211
+ ` - Fit: ${score?.company_fit ?? "?"}/25 Β· AI Readiness: ${score?.ai_readiness ?? "?"}/20\n` +
212
+ ` - Service Fit: ${score?.service_match_score ?? "?"}/20 Β· Contact: ${score?.decision_maker ?? "?"}/20\n\n` +
213
+ `*🧠 Profile Summary:*\n_${profile?.profile_summary ?? "No profile summary available."}_\n\n` +
214
+ `*🎯 Personalized Outreach Angle:*\n_"${profile?.outreach_angle ?? "β€”"}"_\n\n` +
215
+ `*πŸ‘€ Decision Maker:* ${contact?.full_name ?? "?"} (${contact?.title ?? "?"})\n` +
216
+ ` - Channels: ${channels.join(" | ") || "None found"}`;
217
+
218
+ await replyToSlack(channelId, responseText, threadTs);
219
+ break;
220
+ }
221
+
222
+ case "status": {
223
+ const { data: autoConfig } = await db.from("system_config").select("value").eq("key", "auto_mode").single();
224
+ const paused = autoConfig?.value?.paused ?? false;
225
+
226
+ const { data: quotaConfig } = await db.from("system_config").select("value").eq("key", "daily_quota").single();
227
+ const quota = quotaConfig?.value;
228
+
229
+ const { data: territory } = await db.from("system_config").select("value").eq("key", "current_territory").single();
230
+ const pos = territory?.value;
231
+
232
+ const { data: todayRuns } = await db
233
+ .from("discovery_runs")
234
+ .select("status, leads_qualified")
235
+ .gte("ran_at", new Date(new Date().setHours(0, 0, 0, 0)).toISOString());
236
+
237
+ const todayLeads = todayRuns?.reduce((sum: number, r: any) => sum + (r.leads_qualified ?? 0), 0) ?? 0;
238
+
239
+ const reply = `βš™οΈ **System Status Report**\n` +
240
+ `β€’ *Auto Runs Status:* ${paused ? "⏸️ PAUSED" : "▢️ RUNNING"}\n` +
241
+ `β€’ *Daily Quota:* ${(quota as any)?.today_override ?? (quota as any)?.default ?? 10} leads/day\n` +
242
+ `β€’ *Qualified Today:* ${todayLeads} leads\n` +
243
+ `β€’ *Active Territory:* ${(pos as any)?.countryCode ?? "?"} city#${(pos as any)?.cityIndex ?? 0}\n` +
244
+ `β€’ *Runs Executed Today:* ${todayRuns?.length ?? 0}`;
245
+
246
+ await replyToSlack(channelId, reply, threadTs);
247
+ break;
248
+ }
249
+
250
+ case "pause": {
251
+ await db.from("system_config").update({
252
+ value: { enabled: true, paused: true, paused_by: "slack-chat" },
253
+ updated_at: new Date().toISOString(),
254
+ }).eq("key", "auto_mode");
255
+
256
+ await replyToSlack(channelId, "⏸️ **System Paused!** Daily automatic runs ko rok diya gaya hai. Jab tak aap resume nahi karenge, automatic process nahi chale ga.", threadTs);
257
+ break;
258
+ }
259
+
260
+ case "resume": {
261
+ await db.from("system_config").update({
262
+ value: { enabled: true, paused: false, paused_by: null },
263
+ updated_at: new Date().toISOString(),
264
+ }).eq("key", "auto_mode");
265
+
266
+ await replyToSlack(channelId, "▢️ **System Resumed!** Automatic runs dubara schedule par start ho gayi hain.", threadTs);
267
+ break;
268
+ }
269
+
270
+ case "quota": {
271
+ const num = params?.quotaAmount;
272
+ if (!num || isNaN(num) || num < 1 || num > 100) {
273
+ await replyToSlack(channelId, "Usage: 'quota 15 kar do' (max 100)", threadTs);
274
+ break;
275
+ }
276
+
277
+ const permanent = !!params?.quotaPermanent;
278
+ const key = "daily_quota";
279
+ const { data: config } = await db.from("system_config").select("value").eq("key", key).single();
280
+ const val = config?.value || { default: 10, today_override: null };
281
+
282
+ if (permanent) {
283
+ val.default = num;
284
+ val.today_override = null;
285
+ } else {
286
+ val.today_override = num;
287
+ }
288
+
289
+ await db.from("system_config").update({
290
+ value: val,
291
+ updated_at: new Date().toISOString()
292
+ }).eq("key", key);
293
+
294
+ await replyToSlack(
295
+ channelId,
296
+ permanent
297
+ ? `βœ… **Daily Quota permanently set to ${num} leads/day!**`
298
+ : `βœ… **Today's Quota set to ${num} leads!** Kal automatic default par wapas chala jaye ga.`,
299
+ threadTs
300
+ );
301
+ break;
302
+ }
303
+
304
+ case "chat": {
305
+ // Chatbot replies using LLM in Roman Urdu directly
306
+ await replyToSlack(channelId, `πŸ’¬ ${explanation}`, threadTs);
307
+ break;
308
+ }
309
+
310
+ default: {
311
+ await replyToSlack(channelId, "Mujhe aap ka naya command samajh nahi aaya. Kya aap explain kar sakte hain?", threadTs);
312
+ }
313
+ }
314
+ logger.info({ intent }, "πŸ€– Chatbot action completed successfully");
315
+ } catch (err: any) {
316
+ logger.error({ err, intent }, "Error executing Slack AI Chatbot action");
317
+ await replyToSlack(channelId, `❌ Action fail ho gaya: ${err.message || err}`, threadTs);
318
+ }
319
+ }
src/slack/slack-commands.ts CHANGED
@@ -16,10 +16,10 @@
16
  * /quota [number] always β†’ permanent change
17
  */
18
 
19
- import { getSupabaseClient } from "../../shared/supabase/client";
20
- import { setQuotaOverride, isSystemPaused } from "../../discovery/lib/territory-manager";
21
  import { sendClarifyingQuestions } from "./slack-service";
22
- import { logger } from "../../shared/utils/logger";
23
 
24
  export interface SlackCommand {
25
  command: string;
@@ -63,7 +63,7 @@ async function handleDiscover(args: string, cmd: SlackCommand): Promise<string>
63
 
64
  if (params.region && params.industry) {
65
  // Direct run β€” no questions needed
66
- const { manualDiscoveryTask } = await import("../../discovery/trigger-tasks/manual-discovery");
67
  await manualDiscoveryTask.trigger({
68
  region: params.region.toUpperCase(),
69
  industry: params.industry,
 
16
  * /quota [number] always β†’ permanent change
17
  */
18
 
19
+ import { getSupabaseClient } from "../shared/supabase/client";
20
+ import { setQuotaOverride, isSystemPaused } from "../discovery/lib/territory-manager";
21
  import { sendClarifyingQuestions } from "./slack-service";
22
+ import { logger } from "../shared/utils/logger";
23
 
24
  export interface SlackCommand {
25
  command: string;
 
63
 
64
  if (params.region && params.industry) {
65
  // Direct run β€” no questions needed
66
+ const { manualDiscoveryTask } = await import("../discovery/trigger-tasks/manual-discovery");
67
  await manualDiscoveryTask.trigger({
68
  region: params.region.toUpperCase(),
69
  industry: params.industry,
src/slack/slack-service.ts CHANGED
@@ -9,9 +9,9 @@
9
  */
10
 
11
  import axios from "axios";
12
- import { getEnv } from "../../shared/config/env";
13
- import { getSupabaseClient } from "../../shared/supabase/client";
14
- import { logger } from "../../shared/utils/logger";
15
 
16
  // ─── Slack API helper ────────────────────────────────────────
17
 
 
9
  */
10
 
11
  import axios from "axios";
12
+ import { getEnv } from "../shared/config/env";
13
+ import { getSupabaseClient } from "../shared/supabase/client";
14
+ import { logger } from "../shared/utils/logger";
15
 
16
  // ─── Slack API helper ────────────────────────────────────────
17
 
src/trigger.ts CHANGED
@@ -3,6 +3,6 @@
3
  * This file must export all tasks for Trigger.dev to discover them.
4
  */
5
 
6
- export { autoDiscoveryTask, autoDiscoverySchedule } from "./discovery/trigger-tasks/auto-discovery";
7
  export { manualDiscoveryTask } from "./discovery/trigger-tasks/manual-discovery";
8
  export { profilingTask } from "./profiling/trigger-tasks/profiling-router";
 
3
  * This file must export all tasks for Trigger.dev to discover them.
4
  */
5
 
6
+ export { dailyScheduler, dailyDigestTask } from "./discovery/trigger-tasks/auto-discovery";
7
  export { manualDiscoveryTask } from "./discovery/trigger-tasks/manual-discovery";
8
  export { profilingTask } from "./profiling/trigger-tasks/profiling-router";
trigger.config.ts CHANGED
@@ -1,7 +1,15 @@
1
  import type { TriggerConfig } from "@trigger.dev/sdk/v3";
 
 
 
 
2
 
3
  export const config: TriggerConfig = {
4
  project: process.env.TRIGGER_DEV_PROJECT_ID!,
 
 
 
 
5
  retries: {
6
  enabledInDev: true,
7
  default: {
@@ -13,3 +21,5 @@ export const config: TriggerConfig = {
13
  },
14
  dirs: ["./src/discovery/trigger-tasks"],
15
  };
 
 
 
1
  import type { TriggerConfig } from "@trigger.dev/sdk/v3";
2
+ import dotenv from "dotenv";
3
+
4
+ // Load environment variables from .env
5
+ dotenv.config();
6
 
7
  export const config: TriggerConfig = {
8
  project: process.env.TRIGGER_DEV_PROJECT_ID!,
9
+ maxDuration: 3600, // 1 hour max default duration for tasks
10
+ build: {
11
+ external: ["playwright", "playwright-core"],
12
+ },
13
  retries: {
14
  enabledInDev: true,
15
  default: {
 
21
  },
22
  dirs: ["./src/discovery/trigger-tasks"],
23
  };
24
+
25
+