From 3b358bc8c735c71fe872adeb10c0596198725494 Mon Sep 17 00:00:00 2001 From: SAHEED2010 Date: Tue, 9 Jun 2026 05:33:47 +1300 Subject: [PATCH 1/4] fix(whatsapp): use private system prompt for natural replies --- .../whatsapp/whatsapp-intent.service.spec.ts | 107 +++++++++++++- .../whatsapp/whatsapp-intent.service.ts | 37 ++++- .../channels/whatsapp/whatsapp.constants.ts | 40 ++++- .../whatsapp/whatsapp.service.spec.ts | 115 ++++++++++++++- .../src/channels/whatsapp/whatsapp.service.ts | 138 ++++++++++++++++-- 5 files changed, 417 insertions(+), 20 deletions(-) diff --git a/apps/backend/src/channels/whatsapp/whatsapp-intent.service.spec.ts b/apps/backend/src/channels/whatsapp/whatsapp-intent.service.spec.ts index 6d40d17..27b437a 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp-intent.service.spec.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp-intent.service.spec.ts @@ -1,7 +1,11 @@ import { ConfigService } from "@nestjs/config"; import { GeminiClient } from "../../integrations/ai/gemini.client"; -import { GEMINI_FUNCTION_DECLARATIONS } from "./whatsapp.constants"; +import { + GEMINI_FUNCTION_DECLARATIONS, + MINIMAL_WHATSAPP_SYSTEM_PROMPT, +} from "./whatsapp.constants"; import { WhatsAppIntentService } from "./whatsapp-intent.service"; +import { Logger } from "@nestjs/common"; describe("WhatsAppIntentService", () => { const configService = { @@ -13,9 +17,11 @@ describe("WhatsAppIntentService", () => { }; let service: WhatsAppIntentService; + let loggerLogSpy: jest.SpyInstance; beforeEach(() => { jest.clearAllMocks(); + loggerLogSpy = jest.spyOn(Logger.prototype, "log").mockImplementation(); configService.get.mockReturnValue("Base WhatsApp prompt."); geminiClient.isConfigured.mockReturnValue(true); service = new WhatsAppIntentService( @@ -24,6 +30,10 @@ describe("WhatsAppIntentService", () => { ); }); + afterEach(() => { + loggerLogSpy.mockRestore(); + }); + it("forces Gemini intent parsing to return a function call", async () => { geminiClient.parseFunctionCall.mockResolvedValue({ name: "search_products", @@ -47,4 +57,99 @@ describe("WhatsAppIntentService", () => { geminiClient.parseFunctionCall.mock.calls[0][0].systemPrompt, ).toContain("Base WhatsApp prompt."); }); + + it("uses WHATSAPP_SYSTEM_PROMPT when namespaced prompt is missing", async () => { + configService.get.mockImplementation((key: string) => + key === "WHATSAPP_SYSTEM_PROMPT" ? "Raw env prompt fixture." : undefined, + ); + service = new WhatsAppIntentService( + configService as unknown as ConfigService, + geminiClient as unknown as GeminiClient, + ); + geminiClient.parseFunctionCall.mockResolvedValue({ + name: "answer_twizrr_question", + args: { + topic: "twizrr_explainer", + answer: "Twizrr helps shoppers discover products from Nigerian stores.", + }, + }); + + await service.parseIntent("what is Twizrr?"); + + expect( + geminiClient.parseFunctionCall.mock.calls[0][0].systemPrompt, + ).toContain("Raw env prompt fixture."); + }); + + it("uses the minimal fallback prompt when env prompt is missing", async () => { + configService.get.mockReturnValue(undefined); + service = new WhatsAppIntentService( + configService as unknown as ConfigService, + geminiClient as unknown as GeminiClient, + ); + geminiClient.parseFunctionCall.mockResolvedValue({ + name: "answer_twizrr_question", + args: { + topic: "assistant_identity", + answer: "I am Twizrr's WhatsApp shopping assistant.", + }, + }); + + await service.parseIntent("who are you?"); + + expect( + geminiClient.parseFunctionCall.mock.calls[0][0].systemPrompt, + ).toContain(MINIMAL_WHATSAPP_SYSTEM_PROMPT); + }); + + it("does not log prompt content while reporting prompt presence", () => { + expect(loggerLogSpy).toHaveBeenCalledWith( + "WhatsApp system prompt loaded from env: true", + ); + expect(loggerLogSpy).not.toHaveBeenCalledWith( + expect.stringContaining("Base WhatsApp prompt."), + ); + }); + + it("parses Gemini natural answer function calls", async () => { + geminiClient.parseFunctionCall.mockResolvedValue({ + name: "answer_twizrr_question", + args: { + topic: "creator_company", + answer: + "Twizrr is built by CODEDDEVS TECHNOLOGY LTD. It helps shoppers discover products from Nigerian stores.", + }, + }); + + const intent = await service.parseIntent("who created Twizrr?"); + + expect(intent).toEqual({ + functionName: "answer_twizrr_question", + params: { + topic: "creator_company", + answer: + "Twizrr is built by CODEDDEVS TECHNOLOGY LTD. It helps shoppers discover products from Nigerian stores.", + }, + }); + }); + + it.each([ + "show me iphone 15", + "find bags under 50k", + "can I get phones here", + ])( + "routes product-like fallback text to product search: %s", + async (text) => { + geminiClient.isConfigured.mockReturnValue(false); + service = new WhatsAppIntentService( + configService as unknown as ConfigService, + geminiClient as unknown as GeminiClient, + ); + + await expect(service.parseIntent(text)).resolves.toEqual({ + functionName: "search_products", + params: { query: text }, + }); + }, + ); }); diff --git a/apps/backend/src/channels/whatsapp/whatsapp-intent.service.ts b/apps/backend/src/channels/whatsapp/whatsapp-intent.service.ts index dda324a..8ebc014 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp-intent.service.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp-intent.service.ts @@ -3,6 +3,7 @@ import { ConfigService } from "@nestjs/config"; import { NUMBER_INTENT_MAP, GEMINI_FUNCTION_DECLARATIONS, + MINIMAL_WHATSAPP_SYSTEM_PROMPT, } from "./whatsapp.constants"; import { GeminiClient } from "../../integrations/ai/gemini.client"; @@ -26,11 +27,17 @@ const FORCE_FUNCTION_CALL_PROMPT = @Injectable() export class WhatsAppIntentService { private readonly logger = new Logger(WhatsAppIntentService.name); + private readonly privatePromptConfigured: boolean; constructor( private configService: ConfigService, private geminiClient: GeminiClient, ) { + this.privatePromptConfigured = Boolean(this.getPrivateSystemPrompt()); + this.logger.log( + `WhatsApp system prompt loaded from env: ${this.privatePromptConfigured}`, + ); + if (!this.geminiClient.isConfigured()) { this.logger.warn( "Gemini API key not configured - AI intent parsing disabled, number menu still works", @@ -93,6 +100,8 @@ export class WhatsAppIntentService { if ( lower.includes("buy") || + lower.includes("do you have") || + lower.includes("can i get") || lower.includes("search") || lower.includes("find") || lower.includes("product") || @@ -101,6 +110,19 @@ export class WhatsAppIntentService { return { functionName: "search_products", params: { query: text } }; } + if ( + lower.includes("what is twizrr") || + lower.includes("who are you") || + lower.includes("who created") || + lower.includes("who built") || + lower.includes("buyer protection") || + lower.includes("money safe") || + lower.includes("account linking") || + lower.includes("send a picture") + ) { + return { functionName: "answer_twizrr_question", params: {} }; + } + if ( ["hi", "hello", "hey", "good morning", "good evening"].includes(lower) ) { @@ -111,8 +133,7 @@ export class WhatsAppIntentService { } private async parseWithGemini(messageText: string): Promise { - const systemPrompt = - this.configService.get("whatsapp.systemPrompt") || ""; + const systemPrompt = this.getSystemPrompt(); const functionCallingSystemPrompt = systemPrompt ? `${systemPrompt}\n\n${FORCE_FUNCTION_CALL_PROMPT}` : FORCE_FUNCTION_CALL_PROMPT; @@ -133,4 +154,16 @@ export class WhatsAppIntentService { params: functionCall.args, }; } + + private getSystemPrompt(): string { + return this.getPrivateSystemPrompt() || MINIMAL_WHATSAPP_SYSTEM_PROMPT; + } + + private getPrivateSystemPrompt(): string { + return ( + this.configService.get("whatsapp.systemPrompt") || + this.configService.get("WHATSAPP_SYSTEM_PROMPT") || + "" + ); + } } diff --git a/apps/backend/src/channels/whatsapp/whatsapp.constants.ts b/apps/backend/src/channels/whatsapp/whatsapp.constants.ts index d2e4953..10f4ec2 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp.constants.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp.constants.ts @@ -27,6 +27,11 @@ export const EMAIL_NOT_FOUND = `We couldn't find an account associated with that export const INVALID_OTP = `The code you entered is incorrect. Please check your email and try again.`; export const OTP_EXPIRED = `Your verification code has expired. Please provide your email again to receive a new one.`; export const GENERIC_ERROR = `We encountered a problem while processing your request. Please try again or type "menu" to see available options.`; +export const SAFE_NATURAL_ANSWER_FALLBACK = + "I can help with shopping on Twizrr, product search, buyer protection, account linking, and order-related questions once your account is linked."; + +export const MINIMAL_WHATSAPP_SYSTEM_PROMPT = + "You are Twizrr's WhatsApp shopping assistant. Help shoppers with Twizrr shopping only. Keep replies short, safe, and grounded. Do not invent product, payment, order, or company details."; export enum SessionState { AWAITING_EMAIL = "AWAITING_EMAIL", @@ -43,7 +48,7 @@ export const GEMINI_FUNCTION_DECLARATIONS = [ { name: "search_products", description: - "Search for products the shopper wants to buy. Triggered by product names, categories, descriptions, or shopping requests.", + "Search for products the shopper wants to buy. Triggered by product names, categories, descriptions, price/location shopping requests, or questions about whether a product category is available.", parameters: { type: "object" as const, properties: { @@ -89,6 +94,39 @@ export const GEMINI_FUNCTION_DECLARATIONS = [ "Show the shopper assistant menu when intent is unclear or the shopper asks for help/menu.", parameters: { type: "object" as const, properties: {} }, }, + { + name: "answer_twizrr_question", + description: + "Answer a Twizrr-related shopping, help, trust, buyer protection, account linking, image search, order tracking explanation, delivery confirmation explanation, support, assistant identity, or company identity question using only approved Twizrr knowledge from the system prompt.", + parameters: { + type: "object" as const, + properties: { + topic: { + type: "string", + enum: [ + "assistant_identity", + "twizrr_explainer", + "creator_company", + "how_it_works", + "buyer_protection", + "account_linking", + "image_search", + "order_tracking", + "delivery_confirmation", + "support", + "other_twizrr_help", + ], + description: "The Twizrr help topic being answered.", + }, + answer: { + type: "string", + description: + "Short WhatsApp-ready plain text answer grounded only in the system prompt. Do not invent details.", + }, + }, + required: ["topic", "answer"], + }, + }, { name: "support_handoff", description: diff --git a/apps/backend/src/channels/whatsapp/whatsapp.service.spec.ts b/apps/backend/src/channels/whatsapp/whatsapp.service.spec.ts index bc418e3..d41ffc1 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp.service.spec.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp.service.spec.ts @@ -5,7 +5,10 @@ import { WHATSAPP_CONSENT_DECLINED_MESSAGE, WHATSAPP_CONSENT_MESSAGE, } from "./whatsapp-session.service"; -import { FRIENDLY_FALLBACK } from "./whatsapp.constants"; +import { + FRIENDLY_FALLBACK, + SAFE_NATURAL_ANSWER_FALLBACK, +} from "./whatsapp.constants"; describe("WhatsAppService", () => { const configService = { get: jest.fn() }; @@ -210,6 +213,91 @@ describe("WhatsAppService", () => { ).toHaveBeenCalledWith("2348012345678", "red shoes"); }); + it("allows consented unlinked buy phrasing as product discovery", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + consentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "search_products", + params: { query: "iphone 15" }, + }); + + await service.processMessage( + "2348012345678", + "I want to buy iphone 15", + "message-1", + ); + + expect(authService.handleLinkingFlow).not.toHaveBeenCalled(); + expect( + productDiscoveryService.sendGenericProductSearch, + ).toHaveBeenCalledWith("2348012345678", "iphone 15"); + expect(interactiveService.sendTextMessage).not.toHaveBeenCalledWith( + "2348012345678", + expect.stringContaining("Please link your twizrr account"), + ); + }); + + it("sends validated Gemini natural answers for Twizrr questions", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + consentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "answer_twizrr_question", + params: { + topic: "creator_company", + answer: + "Twizrr is built by CODEDDEVS TECHNOLOGY LTD. It helps shoppers discover products from Nigerian stores.", + }, + }); + + await service.processMessage( + "2348012345678", + "who created Twizrr?", + "message-1", + ); + + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + expect.stringContaining("CODEDDEVS TECHNOLOGY LTD"), + ); + expect( + productDiscoveryService.sendGenericProductSearch, + ).not.toHaveBeenCalled(); + }); + + it.each([ + "", + " ".repeat(8), + "x".repeat(701), + '{"topic":"creator_company","answer":"Twizrr"}', + "This exposes physicalStoreId to shoppers.", + "Twizrr was founded by Aliameen.", + "This phone is available now for NGN 100000.", + "Happy to help \u{1F600}", + ])("falls back when Gemini natural answer is unsafe: %s", async (answer) => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + consentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "answer_twizrr_question", + params: { + topic: "other_twizrr_help", + answer, + }, + }); + + await service.processMessage("2348012345678", "what is this?", "message-1"); + + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + SAFE_NATURAL_ANSWER_FALLBACK, + ); + }); + it("allows consented unlinked image search", async () => { authService.resolvePhone.mockResolvedValue(null); whatsappSessionService.recordInbound.mockResolvedValue({ @@ -315,6 +403,31 @@ describe("WhatsAppService", () => { ); }); + it("asks consented unlinked users to link before cart actions", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + consentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "search_products", + params: { query: "add this to cart" }, + }); + + await service.processMessage( + "2348012345678", + "add this to cart", + "message-1", + ); + + expect( + productDiscoveryService.sendGenericProductSearch, + ).not.toHaveBeenCalled(); + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + expect.stringContaining("Please link your twizrr account"), + ); + }); + it("asks consented unlinked users to link before order tracking", async () => { authService.resolvePhone.mockResolvedValue(null); whatsappSessionService.recordInbound.mockResolvedValue({ diff --git a/apps/backend/src/channels/whatsapp/whatsapp.service.ts b/apps/backend/src/channels/whatsapp/whatsapp.service.ts index 0cbd8e8..2ae0799 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp.service.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp.service.ts @@ -6,7 +6,10 @@ import { WhatsAppAuthService } from "./whatsapp-auth.service"; import { WhatsAppIntentService } from "./whatsapp-intent.service"; import { ImageSearchService } from "./image-search.service"; import { WhatsAppInteractiveService } from "./whatsapp-interactive.service"; -import { FRIENDLY_FALLBACK } from "./whatsapp.constants"; +import { + FRIENDLY_FALLBACK, + SAFE_NATURAL_ANSWER_FALLBACK, +} from "./whatsapp.constants"; import { WhatsAppProductDiscoveryService } from "./whatsapp-product-discovery.service"; import { WHATSAPP_CONSENT_ACCEPT_ID, @@ -127,7 +130,8 @@ export class WhatsAppService { this.isAccountActionRequest(messageText || "", intent.functionName) && intent.functionName !== "get_order_status" && intent.functionName !== "track_order" && - intent.functionName !== "confirm_delivery" + intent.functionName !== "confirm_delivery" && + intent.functionName !== "answer_twizrr_question" ) { if (!(await this.requireLinkedAccount(phone, userId))) { return; @@ -163,6 +167,13 @@ export class WhatsAppService { await this.sendShopperMenu(phone); return; + case "answer_twizrr_question": + await this.interactiveService.sendTextMessage( + phone, + this.getSafeNaturalAnswer(intent.params), + ); + return; + case "friendly_fallback": default: await this.interactiveService.sendTextMessage( @@ -196,19 +207,35 @@ export class WhatsAppService { } const lower = messageText.toLowerCase(); - return [ - "buy", - "checkout", - "cart", - "wishlist", - "save", - "saved", - "payment", - "pay", - "order", - "delivery confirmation", - "confirm delivery", - ].some((keyword) => lower.includes(keyword)); + const actionKeywords = + functionName === "search_products" + ? [ + "checkout", + "cart", + "wishlist", + "save", + "saved", + "payment", + "pay", + "order", + "delivery confirmation", + "confirm delivery", + ] + : [ + "buy", + "checkout", + "cart", + "wishlist", + "save", + "saved", + "payment", + "pay", + "order", + "delivery confirmation", + "confirm delivery", + ]; + + return actionKeywords.some((keyword) => lower.includes(keyword)); } private getSearchQuery( @@ -219,6 +246,87 @@ export class WhatsAppService { return typeof query === "string" && query.trim() ? query : fallback || ""; } + private getSafeNaturalAnswer(params: Record): string { + const answer = params.answer; + + if (typeof answer !== "string") { + return SAFE_NATURAL_ANSWER_FALLBACK; + } + + const trimmed = answer.trim(); + return this.isSafeNaturalAnswer(trimmed) + ? trimmed + : SAFE_NATURAL_ANSWER_FALLBACK; + } + + private isSafeNaturalAnswer(answer: string): boolean { + if (!answer || answer.length > 700) { + return false; + } + + if (this.looksLikeJson(answer) || this.containsEmoji(answer)) { + return false; + } + + return ( + !this.containsForbiddenInternalTerm(answer) && + !this.containsUnapprovedFounderClaim(answer) && + !this.containsUnsupportedCommerceClaim(answer) + ); + } + + private looksLikeJson(answer: string): boolean { + const trimmed = answer.trim(); + return ( + (trimmed.startsWith("{") && trimmed.endsWith("}")) || + (trimmed.startsWith("[") && trimmed.endsWith("]")) || + /"answer"\s*:|"topic"\s*:/.test(trimmed) + ); + } + + private containsForbiddenInternalTerm(answer: string): boolean { + return [ + "physicalStoreId", + "linkedOrderId", + "sourcedProductId", + "dropshipperCostKobo", + "digitalMarginKobo", + "paystackRecipientCode", + "api key", + "token", + "database url", + ].some((term) => answer.toLowerCase().includes(term.toLowerCase())); + } + + private containsUnapprovedFounderClaim(answer: string): boolean { + const lower = answer.toLowerCase(); + if ( + /\b(aliameen|mustakheem|abdulraseed)\b/i.test(answer) || + /\bfounder\b/.test(lower) + ) { + return true; + } + + return ( + /\b(created by|built by|made by|owned by)\b/.test(lower) && + !answer.includes("CODEDDEVS TECHNOLOGY LTD") + ); + } + + private containsUnsupportedCommerceClaim(answer: string): boolean { + return [ + /\b(in stock|out of stock|available now|we have|we sell)\b/i, + /\b(costs|price is|selling for|available for)\b/i, + /(\u20a6|\bngn\b|\bnaira\b|\bkobo\b)/i, + /\b(arrives on|delivered by|delivery date is)\b/i, + /\bpayment (is|has been) (confirmed|received|failed|pending)\b/i, + ].some((pattern) => pattern.test(answer)); + } + + private containsEmoji(answer: string): boolean { + return /[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}]/u.test(answer); + } + private async showTypingIndicator(messageId?: string): Promise { if (!messageId?.trim()) { return; From 77105f353284689727a2b75fe6b05c05c3333406 Mon Sep 17 00:00:00 2001 From: SAHEED2010 Date: Tue, 9 Jun 2026 05:46:54 +1300 Subject: [PATCH 2/4] Update apps/backend/src/channels/whatsapp/whatsapp.constants.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- apps/backend/src/channels/whatsapp/whatsapp.constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/channels/whatsapp/whatsapp.constants.ts b/apps/backend/src/channels/whatsapp/whatsapp.constants.ts index 10f4ec2..cffd9d3 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp.constants.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp.constants.ts @@ -31,7 +31,7 @@ export const SAFE_NATURAL_ANSWER_FALLBACK = "I can help with shopping on Twizrr, product search, buyer protection, account linking, and order-related questions once your account is linked."; export const MINIMAL_WHATSAPP_SYSTEM_PROMPT = - "You are Twizrr's WhatsApp shopping assistant. Help shoppers with Twizrr shopping only. Keep replies short, safe, and grounded. Do not invent product, payment, order, or company details."; + "You are Twizrr's WhatsApp shopping assistant. Help shoppers with Twizrr shopping only. You use NO emojis - plain text only. No markdown or bullet points. Keep replies under 150 words. Keep replies short, safe, and grounded. Do not invent product, payment, order, or company details."; export enum SessionState { AWAITING_EMAIL = "AWAITING_EMAIL", From 70957de62230e07c3e945c1bd1d02c0468491b2e Mon Sep 17 00:00:00 2001 From: SAHEED2010 Date: Tue, 9 Jun 2026 06:44:17 +1300 Subject: [PATCH 3/4] fix(whatsapp): enforce plain text natural answer policy Co-Authored-By: Claude Opus 4.8 --- .../whatsapp/whatsapp.service.spec.ts | 36 +++++++++++++++++++ .../src/channels/whatsapp/whatsapp.service.ts | 23 +++++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/channels/whatsapp/whatsapp.service.spec.ts b/apps/backend/src/channels/whatsapp/whatsapp.service.spec.ts index d41ffc1..e1dd6cf 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp.service.spec.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp.service.spec.ts @@ -277,6 +277,15 @@ describe("WhatsAppService", () => { "Twizrr was founded by Aliameen.", "This phone is available now for NGN 100000.", "Happy to help \u{1F600}", + "- Buyer Protection helps you shop safely", + "* Buyer Protection helps you shop safely", + "• Buyer Protection helps you shop safely", + "1. Search products", + "1) Search products", + "# Twizrr", + "**Twizrr** helps shoppers", + "`internal`", + "> quoted text", ])("falls back when Gemini natural answer is unsafe: %s", async (answer) => { authService.resolvePhone.mockResolvedValue(null); whatsappSessionService.recordInbound.mockResolvedValue({ @@ -298,6 +307,33 @@ describe("WhatsAppService", () => { ); }); + it("sends short plain-text Gemini natural answers unchanged", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + consentGiven: true, + }); + const plainAnswer = + "Twizrr helps you find products from Nigerian stores and shop with Buyer Protection."; + intentService.parseIntent.mockResolvedValue({ + functionName: "answer_twizrr_question", + params: { + topic: "other_twizrr_help", + answer: plainAnswer, + }, + }); + + await service.processMessage( + "2348012345678", + "what is Twizrr?", + "message-1", + ); + + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + plainAnswer, + ); + }); + it("allows consented unlinked image search", async () => { authService.resolvePhone.mockResolvedValue(null); whatsappSessionService.recordInbound.mockResolvedValue({ diff --git a/apps/backend/src/channels/whatsapp/whatsapp.service.ts b/apps/backend/src/channels/whatsapp/whatsapp.service.ts index 2ae0799..a0c35d0 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp.service.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp.service.ts @@ -264,7 +264,15 @@ export class WhatsAppService { return false; } - if (this.looksLikeJson(answer) || this.containsEmoji(answer)) { + if (this.countWords(answer) > 150) { + return false; + } + + if ( + this.looksLikeJson(answer) || + this.containsEmoji(answer) || + this.containsMarkdownOrBullets(answer) + ) { return false; } @@ -275,6 +283,19 @@ export class WhatsAppService { ); } + private countWords(answer: string): number { + const words = answer.trim().split(/\s+/).filter(Boolean); + return words.length; + } + + private containsMarkdownOrBullets(answer: string): boolean { + return ( + /^\s*[-*•]\s+/m.test(answer) || + /^\s*\d+[.)]\s+/m.test(answer) || + /[`*_#~>]/.test(answer) + ); + } + private looksLikeJson(answer: string): boolean { const trimmed = answer.trim(); return ( From f145d9a36f49dafe0f724f7b630b7d64de2fdc38 Mon Sep 17 00:00:00 2001 From: AliameenXBT Date: Tue, 9 Jun 2026 05:58:16 +0100 Subject: [PATCH 4/4] fix(whatsapp): handle support handoff intent --- .../channels/whatsapp/whatsapp.constants.ts | 2 ++ .../whatsapp/whatsapp.service.spec.ts | 25 +++++++++++++++++++ .../src/channels/whatsapp/whatsapp.service.ts | 8 ++++++ 3 files changed, 35 insertions(+) diff --git a/apps/backend/src/channels/whatsapp/whatsapp.constants.ts b/apps/backend/src/channels/whatsapp/whatsapp.constants.ts index cffd9d3..a950ff0 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp.constants.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp.constants.ts @@ -29,6 +29,8 @@ export const OTP_EXPIRED = `Your verification code has expired. Please provide y export const GENERIC_ERROR = `We encountered a problem while processing your request. Please try again or type "menu" to see available options.`; export const SAFE_NATURAL_ANSWER_FALLBACK = "I can help with shopping on Twizrr, product search, buyer protection, account linking, and order-related questions once your account is linked."; +export const SUPPORT_HANDOFF_RESPONSE = + "I can help with shopping questions here. For account or order support, please visit twizrr support in the app or on the web."; export const MINIMAL_WHATSAPP_SYSTEM_PROMPT = "You are Twizrr's WhatsApp shopping assistant. Help shoppers with Twizrr shopping only. You use NO emojis - plain text only. No markdown or bullet points. Keep replies under 150 words. Keep replies short, safe, and grounded. Do not invent product, payment, order, or company details."; diff --git a/apps/backend/src/channels/whatsapp/whatsapp.service.spec.ts b/apps/backend/src/channels/whatsapp/whatsapp.service.spec.ts index e1dd6cf..25e4d9c 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp.service.spec.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp.service.spec.ts @@ -334,6 +334,31 @@ describe("WhatsAppService", () => { ); }); + it("sends a deterministic support response when Gemini selects support handoff", async () => { + authService.resolvePhone.mockResolvedValue(null); + whatsappSessionService.recordInbound.mockResolvedValue({ + consentGiven: true, + }); + intentService.parseIntent.mockResolvedValue({ + functionName: "support_handoff", + params: {}, + }); + + await service.processMessage( + "2348012345678", + "talk to support", + "message-1", + ); + + expect(interactiveService.sendTextMessage).toHaveBeenCalledWith( + "2348012345678", + "I can help with shopping questions here. For account or order support, please visit twizrr support in the app or on the web.", + ); + expect( + productDiscoveryService.sendGenericProductSearch, + ).not.toHaveBeenCalled(); + }); + it("allows consented unlinked image search", async () => { authService.resolvePhone.mockResolvedValue(null); whatsappSessionService.recordInbound.mockResolvedValue({ diff --git a/apps/backend/src/channels/whatsapp/whatsapp.service.ts b/apps/backend/src/channels/whatsapp/whatsapp.service.ts index a0c35d0..7d3c874 100644 --- a/apps/backend/src/channels/whatsapp/whatsapp.service.ts +++ b/apps/backend/src/channels/whatsapp/whatsapp.service.ts @@ -9,6 +9,7 @@ import { WhatsAppInteractiveService } from "./whatsapp-interactive.service"; import { FRIENDLY_FALLBACK, SAFE_NATURAL_ANSWER_FALLBACK, + SUPPORT_HANDOFF_RESPONSE, } from "./whatsapp.constants"; import { WhatsAppProductDiscoveryService } from "./whatsapp-product-discovery.service"; import { @@ -174,6 +175,13 @@ export class WhatsAppService { ); return; + case "support_handoff": + await this.interactiveService.sendTextMessage( + phone, + SUPPORT_HANDOFF_RESPONSE, + ); + return; + case "friendly_fallback": default: await this.interactiveService.sendTextMessage(