DEV Community

Roberto Luna
Roberto Luna

Posted on

Integrating a Groq‑Powered WhatsApp AI Advisor into a NestJS API (pilot)

Integrating a Groq‑Powered WhatsApp AI Advisor into a NestJS API (pilot)

TL;DR: Added a WhatsApp‑AI endpoint that calls Groq with function‑calling to fetch real‑estate property data. The change touches the NestJS module, env handling, DB migration, Groq helper, and new controller/service files.


The Problem

Our backend needed a way for brokers to answer property‑related questions directly from WhatsApp without leaving the chat. The existing API only exposed CRUD for properties; there was no AI layer, and we had no way to invoke Groq’s function‑calling feature from a webhook that WhatsApp sends. The symptom was a missing route and missing environment variables, resulting in a 404 from the client and a GROQ_API_KEY not configured error when we tried a quick prototype.

What I Tried First

I started with a naïve approach:

  1. Direct HTTP call to Groq from the existing PropertiesService.
  2. Hard‑coded the prompt and ignored function‑calling.

The code compiled but failed at runtime:

Error: GROQ_API_KEY no configurada en el servidor.
Enter fullscreen mode Exit fullscreen mode

I also attempted to add the new route directly inside app.controller.ts, but the controller became cluttered and the module registration was incomplete, leading to NestJS throwing:

Nest can't resolve dependencies of the WhatsAppAiController (?). Please make sure that the argument at index [0] is available in the current context.
Enter fullscreen mode Exit fullscreen mode

Both attempts highlighted two gaps: missing environment configuration and missing modular separation.

The Implementation

1. Environment schema update (apps/api/src/common/env.ts)

@@ -42,6 +42,12 @@ const envSchema = z.object({
   // ── ClickUp — OPTIONAL ────────────────────────────────────────────────────
   CLICKUP_API_KEY: z.string().optional(),
   CLICKUP_LIST_ID: z.string
+  // ── WhatsApp AI — OPTIONAL ───────────────────────────────────────────────
+  WHATSAPP_AI_ENABLED: z.string().optional(),
+  GROQ_API_KEY: z.string(),
+  GROQ_ENDPOINT: z.string().default('https://api.groq.com/openai/v1/chat/completions'),
+  WHATSAPP_WEBHOOK_SECRET: z.string().optional(),
+  WHATSAPP_BROKER_NUMBER: z.string().optional(),
 });
Enter fullscreen mode Exit fullscreen mode

Adding the keys forces the server to crash early if the Groq key is missing, preventing silent failures.

2. DB migration (apps/api/src/db/db.ts)

A new table whatsapp_ai_sessions stores conversation IDs and timestamps to enforce rate‑limits per broker.

@@ -1869,5 +1869,39 @@ export async function migrate(): Promise<void> {
   );
   create index if not exists idx_construction_inspections_proj on construction_inspections(construction_project_id, t
+  await db.exec(`
+    CREATE TABLE IF NOT EXISTS whatsapp_ai_sessions (
+      id SERIAL PRIMARY KEY,
+      broker_id VARCHAR NOT NULL,
+      session_id VARCHAR NOT NULL,
+      last_interaction TIMESTAMP NOT NULL DEFAULT now()
+    );
+  `);
+
+  await db.exec(`
+    CREATE INDEX IF NOT EXISTS idx_whatsapp_ai_sessions_broker
+    ON whatsapp_ai_sessions (broker_id);
+  `);
Enter fullscreen mode Exit fullscreen mode

The migration is idempotent; running it on existing environments adds the table without affecting other data.

3. Groq helper extension (apps/api/src/shared/groq.helper.ts)

We added a wrapper that supports function‑calling.

@@ -51,3 +51,34 @@ export function getGroqKey(): string {
   if (!key) throw new Error("GROQ_API_KEY no configurada en el servidor.");
   return key;
 }

+/** Variante con function-calling — usada por el asistente de WhatsApp */
+export async function callGroqWithTools(prompt: string, tools: any[]): Promise<any> {
+  const response = await fetch(env.GROQ_ENDPOINT, {
+    method: "POST",
+    headers: {
+      "Content-Type": "application/json",
+      Authorization: `Bearer ${getGroqKey()}`,
+    },
+    body: JSON.stringify({
+      model: "mixtral-8x7b-32768",
+      messages: [{ role: "user", content: prompt }],
+      tools,
+    }),
+  });
+
+  if (!response.ok) {
+    const txt = await response.text();
+    throw new Error(`Groq request failed: ${response.status} ${txt}`);
+  }
+
+  const data = await response.json();
+  return data;
+}
Enter fullscreen mode Exit fullscreen mode

The tools array defines the function schema that Groq can invoke (e.g., getPropertyById). This is critical for returning structured data instead of raw text.

4. New WhatsApp AI controller (apps/api/src/whatsapp-ai/whatsapp-ai.controller.ts)

import { Body, Controller, Get, Post, Query, Res } from "@nestjs/common";
import type { Response } from "express";
import { env } from "../common/env.js";
import { handleIncomingWebhook } from "./whatsapp-ai.service.js";

@Controller("whatsapp-ai")
export class WhatsAppAiController {
  @Post("webhook")
  async webhook(@Body() payload: any, @Res() res: Response) {
    // Verify secret if configured
    if (env.WHATSAPP_WEBHOOK_SECRET) {
      const signature = payload.headers["x-hub-signature-256"];
      // verification logic omitted for brevity
    }
    await handleIncomingWebhook(payload);
    res.sendStatus(200);
  }

  @Get("health")
  health() {
    return { status: "ok", enabled: !!env.WHATSAPP_AI_ENABLED };
  }
}
Enter fullscreen mode Exit fullscreen mode

The controller is deliberately thin; all business logic lives in the service.

5. New WhatsApp AI service (apps/api/src/whatsapp-ai/whatsapp-ai.service.ts)

import { query as db } from "../db/db.js";
import { env } from "../common/env.js";
import { callGroqWithTools, getGroqKey } from "../shared/groq.helper.js";

// Tool definition for Groq
const propertyTool = {
  type: "function",
  function: {
    name: "getPropertyById",
    description: "\"Retrieve a real estate property by its internal ID\","
    parameters: {
      type: "object",
      properties: {
        id: { type: "string", description: "\"Property identifier\" },"
      },
      required: ["id"],
    },
  },
};

export async function handleIncomingWebhook(payload: any) {
  const message = payload.message?.text?.body?.trim();
  if (!message) return;

  // Build prompt
  const prompt = `You are a virtual broker assistant. Answer the user query using the getPropertyById function when appropriate.\nUser: ${message}`;

  // Call Groq with function-calling enabled
  const groqResponse = await callGroqWithTools(prompt, [propertyTool]);

  // If Groq decided to call the function, we execute it
  if (groqResponse.choices[0].message?.tool_calls) {
    const toolCall = groqResponse.choices[0].message.tool_calls[0];
    if (toolCall.function.name === "getPropertyById") {
      const args = JSON.parse(toolCall.function.arguments);
      const property = await db<{ id: string; title: "string; price: number }>`"
        SELECT id, title, price FROM properties WHERE id = ${args.id}
      `;
      // Send response back to WhatsApp (simplified)
      await sendWhatsAppMessage(payload.from, formatProperty(property));
    }
  } else {
    // Fallback: plain text answer
    const answer = groqResponse.choices[0].message.content;
    await sendWhatsAppMessage(payload.from, answer);
  }
}

// Helper to send a WhatsApp message via the provider API
async function sendWhatsAppMessage(to: string, text: string) {
  // Implementation depends on the provider (Twilio, Meta Cloud API, etc.)
  // Placeholder:
  console.log(`Sending to ${to}: ${text}`);
}

// Simple formatter
function formatProperty(p: any): string {
  return `🏡 ${p.title}\n💲 ${p.price.toLocaleString("en-US")}\nID: ${p.id}`;
}
Enter fullscreen mode Exit fullscreen mode

Key points:

  • Function‑calling: The service inspects `tool_calls

Part of my Build in Public series — sharing the real process of building Building PlayaMXCRM from Playa del Carmen, México.

Repo: zaerohell/VS · 2026-08-13

#playadev #buildinpublic

Top comments (0)