diff --git a/.env.example b/.env.example index 02a7b22..e5c2929 100644 --- a/.env.example +++ b/.env.example @@ -9,7 +9,6 @@ TWILIO_ACCOUNT_SID= TWILIO_AUTH_TOKEN=FOR_SIGNATURE_VERIFICATION TWILIO_API_KEY= TWILIO_API_SECRET= -TWILIO_PHONE_NUMBER= TWILIO_MESSAGING_SERVICE_SID= TWILIO_SYNC_SERVICE_SID= TWILIO_VERIFY_SERVICE_SID= diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f107870..9369a6b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -31,7 +31,6 @@ jobs: TWILIO_AUTH_TOKEN: ${{ secrets.TWILIO_AUTH_TOKEN }} TWILIO_API_KEY: ${{ secrets.TWILIO_API_KEY }} TWILIO_API_SECRET: ${{ secrets.TWILIO_API_SECRET }} - TWILIO_PHONE_NUMBER: ${{ vars.TWILIO_PHONE_NUMBER }} TWILIO_MESSAGING_SERVICE_SID: ${{ vars.TWILIO_MESSAGING_SERVICE_SID }} TWILIO_SYNC_SERVICE_SID: ${{ vars.TWILIO_SYNC_SERVICE_SID }} TWILIO_VERIFY_SERVICE_SID: ${{ vars.TWILIO_VERIFY_SERVICE_SID }} @@ -62,7 +61,6 @@ jobs: TWILIO_AUTH_TOKEN=$TWILIO_AUTH_TOKEN TWILIO_API_KEY=$TWILIO_API_KEY TWILIO_API_SECRET=$TWILIO_API_SECRET - TWILIO_PHONE_NUMBER=$TWILIO_PHONE_NUMBER TWILIO_MESSAGING_SERVICE_SID=$TWILIO_MESSAGING_SERVICE_SID TWILIO_SYNC_SERVICE_SID=$TWILIO_SYNC_SERVICE_SID TWILIO_VERIFY_SERVICE_SID=$TWILIO_VERIFY_SERVICE_SID diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index c16dd02..950a745 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -26,7 +26,6 @@ TWILIO_API_SECRET="xxxxx" TWILIO_VERIFY_SERVICE_SID="VAxxxxx" TWILIO_SYNC_SERVICE_SID="ISxxxxx" TWILIO_MESSAGING_SERVICE_SID="MGxxxxx" -TWILIO_PHONE_NUMBER="+15551234567" ``` The deploy script uses `.env.local` for both build-time values, such as `NEXT_PUBLIC_*`, and runtime environment variables. diff --git a/__tests__/e2e/browse-events.spec.ts b/__tests__/e2e/browse-events.spec.ts index 1fcc363..ee52714 100644 --- a/__tests__/e2e/browse-events.spec.ts +++ b/__tests__/e2e/browse-events.spec.ts @@ -1,5 +1,6 @@ import { test, expect, type Page } from "@playwright/test"; import { Privilege } from "@/proxy"; +import { createEvent, deleteIfExists } from "../global-setup"; test.describe("[no login]", () => { test("should not be navigable", async ({ page }) => { @@ -64,10 +65,6 @@ test.describe("[mixologist]", () => { }); test.describe("[admin]", () => { - // These tests mutate the shared "test-event" fixture (item selection, mode); - // run them in order rather than in parallel to avoid clobbering each other. - test.describe.configure({ mode: "serial" }); - test("should be navigable to an existing event", async ({ page, context, @@ -132,41 +129,69 @@ test.describe("[admin]", () => { test("should not be able to select more than 9 menu items + navigate to smoothie", async ({ page, context, - }) => { - await context.addCookies([ - { - name: "privilege", - value: Privilege.ADMIN, - url: "http://localhost:3000", - }, - ]); - await context.setExtraHTTPHeaders({ - Authorization: `Basic ${btoa(process.env.ADMIN_LOGIN || ":")}`, - }); - - await page.goto("http://localhost:3000/event/test-event"); - - await page.waitForTimeout(2000); - - // TestEvent starts with 1 item selected (Espresso); select 9 more unselected - // items to reach the 10-item cap. Scoped to the literal aria-pressed="false" - // attribute (not the role=button pressed filter) — Chromium's accessibility - // tree reports pressed:false by default for any plain button, which would - // otherwise also match the header's "Log out" button and toast dismiss buttons. - const unselectedItem = page.locator('button[aria-pressed="false"]'); - for (let i = 0; i < 9; i++) { + }, testInfo) => { + // This test mutates item selection and mode, unlike its siblings which only + // read "test-event" — give it a private event (keyed by parallelIndex, with + // its own display name) so it never clobbers the shared fixture other spec + // files depend on, and doesn't produce a duplicate "TestEvent" heading on + // the home page while other tests are concurrently asserting against it. + const slug = `test-event-menu-cap-${testInfo.parallelIndex}`; + // Event names are capped at 20 chars by the API (src/app/api/event/route.ts). + // Must not contain "TestEvent" as a substring — other tests query + // getByRole(..., { name: "TestEvent" }) without exact:true, which matches + // on substring, so any name merely starting with "TestEvent" still collides. + const name = `MenuCapEvent${testInfo.parallelIndex}`; + const baseURL = testInfo.project.use.baseURL || "http://localhost:3000"; + await deleteIfExists(baseURL, slug); + const response = await createEvent(baseURL, slug, name); + expect(response.status).toBe(201); + + try { + await context.addCookies([ + { + name: "privilege", + value: Privilege.ADMIN, + url: "http://localhost:3000", + }, + ]); + await context.setExtraHTTPHeaders({ + Authorization: `Basic ${btoa(process.env.ADMIN_LOGIN || ":")}`, + }); + + await page.goto(`http://localhost:3000/event/${slug}`); + + // Wait for the freshly-created event's menu to actually be rendered + // (Espresso pre-selected) rather than a fixed sleep, since a brand-new + // event's Sync data may take longer to propagate under concurrent load. + await expect( + page.getByRole("button", { name: "Espresso Strong black coffee" }), + ).toHaveAttribute("aria-pressed", "true"); + + // TestEvent starts with 1 item selected (Espresso); select 9 more unselected + // items to reach the 10-item cap. Scoped to the literal aria-pressed="false" + // attribute (not the role=button pressed filter) — Chromium's accessibility + // tree reports pressed:false by default for any plain button, which would + // otherwise also match the header's "Log out" button and toast dismiss buttons. + const unselectedItem = page.locator('button[aria-pressed="false"]'); + for (let i = 0; i < 9; i++) { + await unselectedItem.first().click(); + // Wait for this click's selection save to land before firing the + // next — the save is a fire-and-forget PUT, and rapid unawaited + // requests can complete out of order under latency, regressing the + // count if a later click's save is overtaken by an earlier one. + await expect(page.getByText(`${i + 2} of 10 items selected`)).toBeVisible(); + } + + // selecting an 11th item should be blocked await unselectedItem.first().click(); - } + await expect( + page.getByText("Cannot select more items", { exact: true }), + ).toBeVisible(); - await expect(page.getByText("10 of 10 items selected")).toBeVisible(); - - // selecting an 11th item should be blocked - await unselectedItem.first().click(); - await expect( - page.getByText("Cannot select more items", { exact: true }), - ).toBeVisible(); - - await page.getByText("Smoothie").click(); + await page.getByText("Smoothie").click(); + } finally { + await deleteIfExists(baseURL, slug); + } }); test("should show warning for inactive number", async ({ page, context }) => { diff --git a/__tests__/global-setup.ts b/__tests__/global-setup.ts index d01cbc7..59633f6 100644 --- a/__tests__/global-setup.ts +++ b/__tests__/global-setup.ts @@ -1,9 +1,9 @@ import { expect, type FullConfig } from "@playwright/test"; import Axios from "axios"; -async function deleteIfExists(baseURL: string) { +export async function deleteIfExists(baseURL: string, slug: string = "test-event") { try { - await Axios.delete(`${baseURL}/api/event/test-event`, { + await Axios.delete(`${baseURL}/api/event/${slug}`, { headers: { "Content-Type": "application/json", Authorization: `Basic ${btoa(process.env.ADMIN_LOGIN || ":")}`, @@ -12,12 +12,16 @@ async function deleteIfExists(baseURL: string) { } catch (e) {} } -async function createEvent(baseURL: string) { +export async function createEvent( + baseURL: string, + slug: string = "test-event", + name: string = "TestEvent", +) { return Axios.post( `${baseURL}/api/event`, { - name: "TestEvent", - slug: "test-event", + name, + slug, state: "OPEN", senders: ["+4915199999999", "whatsapp:+447700161860"], selection: { diff --git a/deploy.sh b/deploy.sh index bc923c1..8b7a180 100755 --- a/deploy.sh +++ b/deploy.sh @@ -189,7 +189,6 @@ secret_keys=( TWILIO_API_KEY TWILIO_API_SECRET TWILIO_AUTH_TOKEN - TWILIO_PHONE_NUMBER TWILIO_MESSAGING_SERVICE_SID TWILIO_SYNC_SERVICE_SID TWILIO_VERIFY_SERVICE_SID diff --git a/package.json b/package.json index f7efe8b..f6ff718 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "license": "MIT", "scripts": { - "dev": "next dev", + "dev": "env NODE_OPTIONS=--require=dotenv/config DOTENV_CONFIG_PATH=./.env.local DOTENV_CONFIG_OVERRIDE=true next dev", "build": "next build", "start": "next start", "lint": "next lint", diff --git a/playwright.config.ts b/playwright.config.ts index 7905170..8611f3e 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -21,8 +21,7 @@ export default defineConfig({ forbidOnly: !!process.env.CI, /* Retry on CI only */ retries: process.env.CI ? 2 : 0, - /* Opt out of parallel tests on CI. */ - workers: process.env.CI ? 1 : undefined, + workers: process.env.CI ? 4 : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: "html", /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ diff --git a/sample.env b/sample.env index 63e8df5..7fabe17 100644 --- a/sample.env +++ b/sample.env @@ -9,7 +9,6 @@ TWILIO_ACCOUNT_SID= TWILIO_AUTH_TOKEN=FOR_SIGNATURE_VERIFICATION TWILIO_API_KEY= TWILIO_API_SECRET= -TWILIO_PHONE_NUMBER= TWILIO_MESSAGING_SERVICE_SID= TWILIO_SYNC_SERVICE_SID= TWILIO_VERIFY_SERVICE_SID= diff --git a/src/app/(layout-free)/event/[slug]/kiosk/layout.tsx b/src/app/(layout-free)/event/[slug]/kiosk/layout.tsx index 7d1562a..b078f4d 100644 --- a/src/app/(layout-free)/event/[slug]/kiosk/layout.tsx +++ b/src/app/(layout-free)/event/[slug]/kiosk/layout.tsx @@ -7,7 +7,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
-
{children}
+
{children}
{/* Use these parameters to adapt to a different screen size */}
); diff --git a/src/app/(layout-free)/event/[slug]/kiosk/page.tsx b/src/app/(layout-free)/event/[slug]/kiosk/page.tsx index 722ad1d..170b01c 100644 --- a/src/app/(layout-free)/event/[slug]/kiosk/page.tsx +++ b/src/app/(layout-free)/event/[slug]/kiosk/page.tsx @@ -30,7 +30,7 @@ export default async function KioskPage(props: { return (
-

+

Order your beverage here and pick it up at the Twilio booth.

{hasPermissions && ( diff --git a/src/app/(master-layout)/event/[slug]/orders/ordersList.tsx b/src/app/(master-layout)/event/[slug]/orders/ordersList.tsx index ac97806..fc9563d 100644 --- a/src/app/(master-layout)/event/[slug]/orders/ordersList.tsx +++ b/src/app/(master-layout)/event/[slug]/orders/ordersList.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { Privilege } from "@/proxy"; import { getCookie } from "cookies-next"; -import { sendMessage } from "@/lib/twilio"; +import { sendMessage, getPinnedSender } from "@/lib/twilio"; import { Badge } from "@/components/ui/badge"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -127,6 +127,12 @@ export default function OrdersList({ return data.key; } + // The sender the attendee last messaged in on — pinned so replies never + // come from a different Messaging Service-selected sender/channel. + function pinnedFrom(phone: string): Promise { + return getPinnedSender(process.env.NEXT_PUBLIC_ATTENDEES_MAP || "", phone); + } + function listComponent(orders: any[]) { return orders.map((order) => { const { data, index, dateUpdated } = order; @@ -184,10 +190,13 @@ export default function OrdersList({ onClick={async () => { startProcessing(index, "remind"); try { - const message = await getOrderReadyReminderMessage( - data.item, index, event.pickupLocation, event.language, - ); - sendMessage(toAddress(data), "", message.contentSid, message.contentVariables); + const [message, from] = await Promise.all([ + getOrderReadyReminderMessage( + data.item, index, event.pickupLocation, event.language, + ), + pinnedFrom(data.key), + ]); + sendMessage(toAddress(data), "", message.contentSid, message.contentVariables, from); updateOrder(index, { reminded: true }); toast({ title: "Customer Reminded", description: "Reminder sent." }); } finally { @@ -208,8 +217,13 @@ export default function OrdersList({ try { updateOrder(index, { status: "ready" }); if (!data?.manual) { - const message = await getOrderReadyMessage(data.item, index, event.pickupLocation); - sendMessage(toAddress(data), "", message.contentSid, message.contentVariables); + const [message, from] = await Promise.all([ + getOrderReadyMessage( + data.item, index, event.pickupLocation, event.language, + ), + pinnedFrom(data.key), + ]); + sendMessage(toAddress(data), "", message.contentSid, message.contentVariables, from); } toast({ title: "Order Ready", diff --git a/src/app/(master-layout)/event/[slug]/page.tsx b/src/app/(master-layout)/event/[slug]/page.tsx index b12ed5f..42900ec 100644 --- a/src/app/(master-layout)/event/[slug]/page.tsx +++ b/src/app/(master-layout)/event/[slug]/page.tsx @@ -411,14 +411,22 @@ function EventPage({ params }: { params: Promise<{ slug: string }> }) { { + onSelectionChange={(newSelection) => { + updateEvent({ ...internalEvent, selection: newSelection }); if (!isNewEvent) { - fetch(`/api/event/${internalEvent.slug}/selection`, { - method: "PUT", - body: JSON.stringify({ selection: newSelection }), - }); + // Debounce the save (like updateMenuItemField/ + // updateModifierField below) so rapid successive + // selections send a single PUT with the latest state, + // rather than one fire-and-forget PUT per click that can + // complete out of order and regress the saved selection. + clearTimeout(aiUpdateTimerRef.current); + aiUpdateTimerRef.current = setTimeout(() => { + fetch(`/api/event/${internalEvent.slug}/selection`, { + method: "PUT", + body: JSON.stringify({ selection: newSelection }), + }); + }, 1500); } - updateEvent({ ...internalEvent, selection: newSelection }); }} /> @@ -504,8 +512,8 @@ function EventPage({ params }: { params: Promise<{ slug: string }> }) {
diff --git a/src/app/api/[slug]/broadcast/route.ts b/src/app/api/[slug]/broadcast/route.ts index 5eaa32a..1e0b1e3 100644 --- a/src/app/api/[slug]/broadcast/route.ts +++ b/src/app/api/[slug]/broadcast/route.ts @@ -1,5 +1,5 @@ import { headers } from "next/headers"; -import { fetchSyncListItems, sendMessage } from "@/lib/twilio"; +import { fetchSyncListItems, sendMessage, createSyncMapItemIfNotExists } from "@/lib/twilio"; import { Privilege, getAuthenticatedRole } from "@/proxy"; export async function POST( @@ -46,11 +46,17 @@ export async function POST( listItem.data?.status === "queued" || listItem.data?.status === "ready", ); - queuedOrders.forEach((order) => { + queuedOrders.forEach(async (order) => { // @ts-ignore thinks is a object but actually it's a string const { key, channel } = order.data; const to = channel === "whatsapp" ? `whatsapp:${key}` : channel === "rcs" ? `rcs:${key}` : key; - sendMessage(to, message); + // Pin the sender the attendee last messaged in on, so the reply never + // comes from a different Messaging Service-selected sender/channel. + const { data: attendee } = await createSyncMapItemIfNotExists( + process.env.NEXT_PUBLIC_ATTENDEES_MAP || "", + key, + ); + sendMessage(to, message, undefined, undefined, (attendee as any)?.from || ""); }); return new Response(null, { status: 201 }); diff --git a/src/app/api/order/route.ts b/src/app/api/order/route.ts index c367463..9204d94 100644 --- a/src/app/api/order/route.ts +++ b/src/app/api/order/route.ts @@ -43,7 +43,10 @@ export async function POST(request: Request) { let item; try { - item = await pushToSyncList(data.event, data.order); + item = await pushToSyncList(data.event, { + ...data.order, + channel: "api", + }); } catch (e: any) { console.error(e); return new Response(e.message, { status: 500, statusText: e.message }); diff --git a/src/app/webhooks/messaging/ai-agent.ts b/src/app/webhooks/messaging/ai-agent.ts index e0ac255..eb141ab 100644 --- a/src/app/webhooks/messaging/ai-agent.ts +++ b/src/app/webhooks/messaging/ai-agent.ts @@ -18,6 +18,7 @@ import { verifyOrder, } from "../mixologist-helper"; import { getReadyToOrderMessage } from "@/scripts/fetchContentTemplates"; +import { eventLang } from "@/lib/stringTemplates"; import { redact, Stages, TwoWeeksInSeconds } from "@/lib/utils"; import type { Event, Order } from "@/types"; @@ -54,9 +55,13 @@ async function toolPlaceOrder( sender: string, ): Promise { const { item, modifiers = [], original_message } = args; + const language = eventLang(event); if (!verifyOrder(item, event, modifiers)) { - return `"${item}" is not on the menu. Valid items: ${event.selection.items.map((i) => i.title).join(", ")}.`; + const validItems = event.selection.items.map((i) => i.title).join(", "); + return language === "pt-BR" + ? `"${item}" não está no cardápio. Itens válidos: ${validItems}.` + : `"${item}" is not on the menu. Valid items: ${validItems}.`; } const { data: record } = await createSyncMapItemIfNotExists( @@ -66,7 +71,9 @@ async function toolPlaceOrder( const lastOrder = await fetchOrder(event.slug, (record as any)?.lastOrderNumber); if ((lastOrder?.data as any)?.status === "queued") { - return `You already have an active order (#${lastOrder!.index}) for a ${(lastOrder!.data as any).item}. Cancel or modify it first.`; + return language === "pt-BR" + ? `Você já tem um pedido ativo (#${lastOrder!.index}) de ${(lastOrder!.data as any).item}. Cancele ou altere antes de continuar.` + : `You already have an active order (#${lastOrder!.index}) for a ${(lastOrder!.data as any).item}. Cancel or modify it first.`; } const today = new Date().toISOString().split("T")[0]; @@ -76,7 +83,9 @@ async function toolPlaceOrder( const dailyCount = isNewDay ? 0 : Number(storedCount ?? 0); const unlimitedOrders = (process.env.UNLIMITED_ORDERS || "").split(","); if (dailyCount >= event.maxOrders && !unlimitedOrders.includes(phone)) { - return `You've reached the daily limit of ${event.maxOrders} orders.`; + return language === "pt-BR" + ? `Você atingiu o limite diário de ${event.maxOrders} pedidos.` + : `You've reached the daily limit of ${event.maxOrders} orders.`; } const channel = sender.startsWith("whatsapp:") ? "whatsapp" @@ -109,7 +118,9 @@ async function toolPlaceOrder( TwoWeeksInSeconds, ); - return `Order #${orderNumber} for a ${item}${modifiers.length > 0 ? ` with ${modifiers.join(", ")}` : ""} placed successfully.`; + return language === "pt-BR" + ? `Pedido #${orderNumber} de ${item}${modifiers.length > 0 ? ` com ${modifiers.join(", ")}` : ""} realizado com sucesso.` + : `Order #${orderNumber} for a ${item}${modifiers.length > 0 ? ` with ${modifiers.join(", ")}` : ""} placed successfully.`; } async function toolEditOrder( @@ -118,6 +129,7 @@ async function toolEditOrder( phone: string, ): Promise { const { action, item, modifiers = [], original_message } = args; + const language = eventLang(event); const { data: record } = await createSyncMapItemIfNotExists( NEXT_PUBLIC_ATTENDEES_MAP, @@ -126,16 +138,21 @@ async function toolEditOrder( const lastOrder = await fetchOrder(event.slug, (record as any)?.lastOrderNumber); if (!lastOrder || (lastOrder.data as any)?.status !== "queued") { + if (language === "pt-BR") { + return action === "cancel" ? "Não há pedido ativo para cancelar." : "Não há pedido ativo para alterar."; + } return action === "cancel" ? "No active order to cancel." : "No active order to edit."; } if (action === "cancel") { await cancelOrder(event, lastOrder.index, lastOrder.data as Order); - return `Order #${lastOrder.index} cancelled.`; + return language === "pt-BR" ? `Pedido #${lastOrder.index} cancelado.` : `Order #${lastOrder.index} cancelled.`; } if (!verifyOrder(item, event, modifiers)) { - return `"${item}" is not a valid menu item.`; + return language === "pt-BR" + ? `"${item}" não é um item válido do cardápio.` + : `"${item}" is not a valid menu item.`; } await updateOrder(event.slug, lastOrder.index, { @@ -146,11 +163,13 @@ async function toolEditOrder( status: "queued", }); - return `Order #${lastOrder.index} updated to ${item}${modifiers.length > 0 ? ` with ${modifiers.join(", ")}` : ""}.`; + return language === "pt-BR" + ? `Pedido #${lastOrder.index} alterado para ${item}${modifiers.length > 0 ? ` com ${modifiers.join(", ")}` : ""}.` + : `Order #${lastOrder.index} updated to ${item}${modifiers.length > 0 ? ` with ${modifiers.join(", ")}` : ""}.`; } -async function toolShowMenu(event: Event, sender: string): Promise { - const language = event.language ?? "en"; +async function toolShowMenu(event: Event, sender: string, from: string): Promise { + const language = eventLang(event); const message = await getReadyToOrderMessage( event, event.selection.items, @@ -158,11 +177,12 @@ async function toolShowMenu(event: Event, sender: string): Promise { false, language, ); - sendMessage(sender, "", message.contentSid, message.contentVariables); + sendMessage(sender, "", message.contentSid, message.contentVariables, from); return "Menu sent to the user."; } async function toolGetOrderStatus(event: Event, phone: string): Promise { + const language = eventLang(event); const { data: record } = await createSyncMapItemIfNotExists( NEXT_PUBLIC_ATTENDEES_MAP, phone, @@ -170,17 +190,27 @@ async function toolGetOrderStatus(event: Event, phone: string): Promise const lastOrderNumber = (record as any)?.lastOrderNumber as number; const lastOrder = await fetchOrder(event.slug, lastOrderNumber); - if (!lastOrder) return "No orders found."; + if (!lastOrder) return language === "pt-BR" ? "Nenhum pedido encontrado." : "No orders found."; + const item = (lastOrder.data as any).item; const status = (lastOrder.data as any)?.status; if (status !== "queued") { - return `Your last order (#${lastOrder.index}) for a ${(lastOrder.data as any).item} has status: ${status}.`; + if (language === "pt-BR") { + const statusLabel = { ready: "pronto", delivered: "entregue", cancelled: "cancelado" }[status as string] ?? status; + return `Seu último pedido (#${lastOrder.index}) de ${item} está com status: ${statusLabel}.`; + } + return `Your last order (#${lastOrder.index}) for a ${item} has status: ${status}.`; } const pos = await getQueuePosition(event.slug, lastOrder.index); + if (language === "pt-BR") { + return pos !== null + ? `Seu pedido (#${lastOrder.index}) de ${item} está na posição ${pos} da fila.` + : `Seu pedido (#${lastOrder.index}) de ${item} está sendo preparado.`; + } return pos !== null - ? `Your order (#${lastOrder.index}) for a ${(lastOrder.data as any).item} is queued at position ${pos}.` - : `Your order (#${lastOrder.index}) for a ${(lastOrder.data as any).item} is being prepared.`; + ? `Your order (#${lastOrder.index}) for a ${item} is queued at position ${pos}.` + : `Your order (#${lastOrder.index}) for a ${item} is being prepared.`; } export async function runAiAgent( @@ -188,6 +218,7 @@ export async function runAiAgent( event: Event, phone: string, sender: string, + from: string, ): Promise { const OPENAI_API_KEY = process.env.OPENAI_API_KEY; if (!OPENAI_API_KEY) { @@ -195,8 +226,12 @@ export async function runAiAgent( return null; } + const language = eventLang(event); + if (isInjectionAttempt(message)) { - return "I can only help you order, modify, or cancel a drink. What would you like?"; + return language === "pt-BR" + ? "Só posso ajudar com pedidos de bebida, alterações ou cancelamentos. O que você gostaria?" + : "I can only help you order, modify, or cancel a drink. What would you like?"; } // Fetch conversation record — history stored as [{role, content}] in Sync @@ -214,6 +249,7 @@ export async function runAiAgent( const modifierList = event.selection.modifiers.length > 0 ? event.selection.modifiers.map((m) => `'${m}'`).join(", ") : "none"; + const languageName = language === "pt-BR" ? "Brazilian Portuguese" : "English"; const systemPrompt = `You are a helpful barista that accepts ${event.selection.mode} orders. This is a marketing activation from Twilio used at a conference. You are free to tell the customers basic facts about Twilio but defer to the Twilio employees (Twilions) at the event if the customers have detailed questions. Menu: @@ -230,7 +266,7 @@ Rules: * If the user's message is ambiguous, ask one short clarifying question. * ORDERING RULE (HIGHEST PRIORITY): When the user wants to order, you MUST call the place_order tool. No exceptions. Do NOT refuse to call the tool based on order history, previous error messages in this conversation, or any assumption about limits. The tool is the sole authority on whether an order is allowed. If you previously told the user they reached a limit, that may be outdated — call the tool again anyway. * If the order tool returns an error, relay the error message exactly as returned. -* Always reply in the same language the user used in their previous message (if they wrote more than 6 words in that language). +* Always reply in ${languageName}, regardless of what language the user writes in. * When suggesting menu items, ALWAYS format them as a markdown list. * Never fabricate information on tool execution failures. Acknowledge errors without speculation. * If the users want to learn more about Twilio, point them to the Twilio employees at the booth. @@ -347,7 +383,7 @@ Rules: result = await toolGetOrderStatus(event, phone); break; case "show_menu": - result = await toolShowMenu(event, sender); + result = await toolShowMenu(event, sender, from); break; case "log_feedback": console.log(`[feedback] ${phone}: ${args.attempted_action}`); diff --git a/src/app/webhooks/messaging/profile-mode.ts b/src/app/webhooks/messaging/profile-mode.ts index b2d9d54..abfda86 100644 --- a/src/app/webhooks/messaging/profile-mode.ts +++ b/src/app/webhooks/messaging/profile-mode.ts @@ -44,6 +44,7 @@ async function sendReadyToOrder( sender: string, event: Event, isReturning: boolean, + from: string, ) { const message = await getReadyToOrderMessage( event, @@ -57,18 +58,25 @@ async function sendReadyToOrder( "", message.contentSid, message.contentVariables, + from, ); if (event.selection.modifiers.length > 1) { await sleep(1500); sendMessage( sender, getModifiersMessage(event.selection.modifiers, eventLang(event)), + undefined, + undefined, + from, ); } await sleep(2000); sendMessage( sender, getDataPolicy(event.selection.mode, eventLang(event)), + undefined, + undefined, + from, ); } @@ -79,6 +87,7 @@ export async function handleProfileMode( event: Event, incomingMessageBody: string, leadCollection: string, + from: string, ): Promise { const stage = attendeeRecord.stage as Stages; @@ -90,7 +99,7 @@ export async function handleProfileMode( { stage: Stages.VERIFIED_USER }, TwoWeeksInSeconds, ); - await sendReadyToOrder(sender, event, false); + await sendReadyToOrder(sender, event, false, from); return true; } @@ -104,7 +113,7 @@ export async function handleProfileMode( } if (stage === Stages.NEW_USER) { - sendMessage(sender, getPromptForEmail(eventLang(event))); + sendMessage(sender, getPromptForEmail(eventLang(event)), undefined, undefined, from); await updateSyncMapItem( NEXT_PUBLIC_ATTENDEES_MAP, phone, @@ -122,6 +131,9 @@ export async function handleProfileMode( sendMessage( sender, getInvalidEmailMessage(eventLang(event)), + undefined, + undefined, + from, ); return true; } @@ -135,10 +147,13 @@ export async function handleProfileMode( sendMessage( sender, getErrorDuringEmailVerificationMessage(error.message, eventLang(event)), + undefined, + undefined, + from, ); return true; } - sendMessage(sender, getSentEmailMessage(eventLang(event))); + sendMessage(sender, getSentEmailMessage(eventLang(event)), undefined, undefined, from); await updateSyncMapItem( NEXT_PUBLIC_ATTENDEES_MAP, phone, @@ -161,10 +176,13 @@ export async function handleProfileMode( sendMessage( sender, getErrorDuringEmailVerificationMessage(error.message, eventLang(event)), + undefined, + undefined, + from, ); return true; } - sendMessage(sender, getSentEmailMessage(eventLang(event))); + sendMessage(sender, getSentEmailMessage(eventLang(event)), undefined, undefined, from); await updateSyncMapItem( NEXT_PUBLIC_ATTENDEES_MAP, phone, @@ -178,6 +196,9 @@ export async function handleProfileMode( sendMessage( sender, getInvalidVerificationCodeMessage(eventLang(event)), + undefined, + undefined, + from, ); return true; } @@ -193,6 +214,9 @@ export async function handleProfileMode( sendMessage( sender, getInvalidVerificationCodeMessage(eventLang(event)), + undefined, + undefined, + from, ); return true; } @@ -217,13 +241,16 @@ export async function handleProfileMode( TwoWeeksInSeconds, ); - await sendReadyToOrder(sender, event, false); + await sendReadyToOrder(sender, event, false, from); return true; } catch (error) { console.error(error); sendMessage( sender, getInvalidVerificationCodeMessage(eventLang(event)), + undefined, + undefined, + from, ); return true; } diff --git a/src/app/webhooks/messaging/qr-mode.ts b/src/app/webhooks/messaging/qr-mode.ts index e23e8e8..d8452dc 100644 --- a/src/app/webhooks/messaging/qr-mode.ts +++ b/src/app/webhooks/messaging/qr-mode.ts @@ -129,6 +129,7 @@ async function sendReadyToOrder( sender: string, event: Event, isReturning: boolean, + from: string, ) { const message = await getReadyToOrderMessage( event, @@ -142,12 +143,16 @@ async function sendReadyToOrder( "", message.contentSid, message.contentVariables, + from, ); if (event.selection.modifiers.length > 1) { await sleep(1500); sendMessage( sender, getModifiersMessage(event.selection.modifiers, eventLang(event)), + undefined, + undefined, + from, ); } } @@ -160,6 +165,7 @@ export async function handleQrMode( incomingMessageBody: string, mediaUrl: string | null, sender: string, + from: string, ): Promise { // Step 1: check Memory store — known attendees go straight to menu const existingProfileId = await lookupProfileByPhone(memoryClient, phone); @@ -178,13 +184,19 @@ export async function handleQrMode( sendMessage( sender, firstName ? `Welcome back, ${firstName}! 👋` : "Welcome back! 👋", + undefined, + undefined, + from, ); await sleep(500); - await sendReadyToOrder(sender, event, true); + await sendReadyToOrder(sender, event, true, from); await sleep(2000); sendMessage( sender, getDataPolicy(event.selection.mode, eventLang(event)), + undefined, + undefined, + from, ); return; } @@ -194,17 +206,23 @@ export async function handleQrMode( sendMessage( sender, `To get started, please send a photo of your event badge QR code.\n\n_Your data will only be used to personalise your experience at this event and deleted afterwards._`, + undefined, + undefined, + from, ); return; } // Step 3: try to decode QR from the photo - sendMessage(sender, "Got your image! Scanning the QR code now..."); + sendMessage(sender, "Got your image! Scanning the QR code now...", undefined, undefined, from); const qrData = await decodeQrFromUrl(mediaUrl); if (!qrData) { sendMessage( sender, "I couldn't scan a QR code from that image. Please make sure your badge QR code is clearly visible, well-lit and in focus, then try again.", + undefined, + undefined, + from, ); return; } @@ -214,6 +232,9 @@ export async function handleQrMode( sendMessage( sender, "I scanned a QR code but it doesn't look like a WeAreDevelopers ticket. Are you sure you scanned the QR code on your badge and not another one? Please try again with your event badge.", + undefined, + undefined, + from, ); return; } @@ -224,6 +245,9 @@ export async function handleQrMode( sendMessage( sender, "I could read your badge QR code but couldn't retrieve your details from the event system. Please ask a Twilio team member for help.", + undefined, + undefined, + from, ); return; } @@ -263,13 +287,16 @@ export async function handleQrMode( const confirmation = firstName ? `That worked, ${firstName}! Which ${beverage} would you like to order?` : `That worked! Which ${beverage} would you like to order?`; - sendMessage(sender, confirmation); + sendMessage(sender, confirmation, undefined, undefined, from); await sleep(300); - await sendReadyToOrder(sender, event, true); // true = use _without_email template variant + await sendReadyToOrder(sender, event, true, from); // true = use _without_email template variant await sleep(2000); sendMessage( sender, getDataPolicy(event.selection.mode, eventLang(event)), + undefined, + undefined, + from, ); } diff --git a/src/app/webhooks/messaging/route.ts b/src/app/webhooks/messaging/route.ts index 9310443..1a2e19f 100644 --- a/src/app/webhooks/messaging/route.ts +++ b/src/app/webhooks/messaging/route.ts @@ -37,7 +37,6 @@ import { handleQrMode, createQrModeMemoryClient } from "./qr-mode"; import { handleProfileMode } from "./profile-mode"; import { deleteMemoryProfile } from "./memory"; import { runAiAgent } from "./ai-agent"; -import type { ConversationWebhookPayload } from "twilio-agent-connect"; const NEXT_PUBLIC_EVENTS_MAP = process.env.NEXT_PUBLIC_EVENTS_MAP || "", NEXT_PUBLIC_ATTENDEES_MAP = @@ -56,7 +55,7 @@ async function getActiveEvents() { } /** Send the "ready to order" sequence: menu + data policy + optional modifiers note. */ -async function sendReadyToOrderSequence(sender: string, event: Event) { +async function sendReadyToOrderSequence(sender: string, event: Event, from: string) { const message = await getReadyToOrderMessage( event, event.selection.items, @@ -64,10 +63,10 @@ async function sendReadyToOrderSequence(sender: string, event: Event) { true, eventLang(event), ); - sendMessage(sender, "", message.contentSid, message.contentVariables); + sendMessage(sender, "", message.contentSid, message.contentVariables, from); if (event.selection.modifiers.length > 1) { await sleep(500); - sendMessage(sender, getModifiersMessage(event.selection.modifiers, eventLang(event))); + sendMessage(sender, getModifiersMessage(event.selection.modifiers, eventLang(event)), undefined, undefined, from); } } @@ -84,11 +83,12 @@ async function selectEventForCustomer( incomingMessageBody: string, isReturning: boolean, attendeeRecord?: any, + from: string = "", ): Promise { const activeEvents = await getActiveEvents(); if (activeEvents.length === 0) { - sendMessage(sender, getNoActiveEventsMessage()); + sendMessage(sender, getNoActiveEventsMessage(), undefined, undefined, from); return twimlResponse(200); } @@ -119,7 +119,7 @@ async function selectEventForCustomer( const welcomeMsg = isReturning ? getWelcomeBackMessage(newEvent.selection.mode, newEvent.name, newEvent.welcomeMessage, eventLang(newEvent)) : getWelcomeMessage(newEvent.selection.mode, newEvent.welcomeMessage, newEvent.leadCollection, eventLang(newEvent)); - sendMessage(sender, welcomeMsg); + sendMessage(sender, welcomeMsg, undefined, undefined, from); const country = getCountryFromPhone(sender); await updateOrCreateSyncMapItem( @@ -137,9 +137,9 @@ async function selectEventForCustomer( if (isReturning || newEvent.leadCollection === "NONE") { await sleep(isReturning ? 500 : 2000); if (!isReturning) { - sendMessage(sender, getDataPolicy(newEvent.selection.mode, eventLang(newEvent))); + sendMessage(sender, getDataPolicy(newEvent.selection.mode, eventLang(newEvent)), undefined, undefined, from); } - await sendReadyToOrderSequence(sender, newEvent); + await sendReadyToOrderSequence(sender, newEvent, from); } return twimlResponse(201); @@ -158,7 +158,7 @@ async function selectEventForCustomer( const welcomeMsg = isReturning ? getWelcomeBackMessage(newEvent.selection.mode, newEvent.name, newEvent.welcomeMessage, eventLang(newEvent)) : getWelcomeMessage(newEvent.selection.mode, newEvent.welcomeMessage, newEvent.leadCollection, eventLang(newEvent)); - sendMessage(sender, welcomeMsg); + sendMessage(sender, welcomeMsg, undefined, undefined, from); const country = getCountryFromPhone(sender); await updateOrCreateSyncMapItem( @@ -177,71 +177,40 @@ async function selectEventForCustomer( if (!isReturning && newEvent.leadCollection !== "NONE") { return twimlResponse(201); } - await sendReadyToOrderSequence(sender, newEvent); + await sendReadyToOrderSequence(sender, newEvent, from); return twimlResponse(201); } // No match — show the event picker const message = await getEventRegistrationMessage(activeEvents); - sendMessage(sender, "", message.contentSid, message.contentVariables); + sendMessage(sender, "", message.contentSid, message.contentVariables, from); return twimlResponse(200); } /** - * Parse the incoming webhook — two formats: - * - * 1. Twilio Agent Connect (TAC) — JSON, Content-Type: application/json - * ConversationWebhookPayload: { eventType: "COMMUNICATION_CREATED", data: { author.address, content.text, content.url? } } - * - * 2. Raw Twilio Messaging Service — form data, Content-Type: application/x-www-form-urlencoded - * Fields: From, Body, NumMedia, MediaUrl0 + * Parse the incoming webhook — raw Twilio Messaging Service, form data, + * Content-Type: application/x-www-form-urlencoded. + * Fields: From, To, Body, NumMedia, MediaUrl0 */ async function parseWebhook(request: Request): Promise<{ sender: string; incomingMessageBody: string; mediaUrl: string | null; -} | null> { - const contentType = request.headers.get("content-type") || ""; - - if (contentType.includes("application/json")) { - // Conversation Orchestrator / TAC webhook format - const payload = await request.json() as ConversationWebhookPayload; - const eventType = payload.eventType; - - if (eventType && eventType !== "COMMUNICATION_CREATED") { - return null; // signal: wrong event type, skip processing - } - - const sender = (payload.data?.author?.address as string) ?? ""; - if (!sender) return null; - - const content = payload.data?.content as Record | undefined; - const incomingMessageBody = (content?.text as string) ?? ""; - const mediaUrl = (content?.url as string) ?? (content?.mediaUrl as string) ?? null; - - return { sender, incomingMessageBody, mediaUrl }; - } else { - // Raw Twilio SMS / WhatsApp webhook (form-encoded) - const data = await request.formData(); - const sender = data.get("From") as string; - if (!sender) return null; - - const incomingMessageBody = (data.get("Body") as string) ?? ""; - const numMedia = Number(data.get("NumMedia") || "0"); - const mediaUrl = (data.get("MediaUrl0") as string | null) ?? (numMedia > 0 ? data.get("MediaUrl0") as string | null : null); - - return { sender, incomingMessageBody, mediaUrl }; - } + /** The exact Twilio-side sender address this message arrived on (pinned for replies). */ + from: string; +}> { + const data = await request.formData(); + const sender = (data.get("From") as string) ?? ""; + const from = (data.get("To") as string) ?? ""; + const incomingMessageBody = (data.get("Body") as string) ?? ""; + const numMedia = Number(data.get("NumMedia") || "0"); + const mediaUrl = (data.get("MediaUrl0") as string | null) ?? (numMedia > 0 ? data.get("MediaUrl0") as string | null : null); + + return { sender, incomingMessageBody, mediaUrl, from }; } export async function POST(request: Request) { - const parsed = await parseWebhook(request); - - if (parsed === null) { - return new Response("Wrong event type", { status: 200 }); - } - - const { sender, incomingMessageBody, mediaUrl } = parsed; + const { sender, incomingMessageBody, mediaUrl, from: receivedOn } = await parseWebhook(request); if (!sender) { return new Response("Missing sender", { status: 400 }); @@ -255,9 +224,18 @@ export async function POST(request: Request) { phone, ); + // Pin the exact sender this message arrived on so replies (here and from + // other flows like broadcasts) go out from the same sender, never a + // different one auto-selected by the Messaging Service. + const from = receivedOn || ((attendeeRecord as any).from as string | undefined) || ""; + if (receivedOn && receivedOn !== (attendeeRecord as any).from) { + await updateOrCreateSyncMapItem(NEXT_PUBLIC_ATTENDEES_MAP, phone, { from: receivedOn }, TwoWeeksInSeconds); + (attendeeRecord as any).from = receivedOn; + } + // New customer — no event assigned yet if (!attendeeRecord.event) { - const result = await selectEventForCustomer(phone, sender, incomingMessageBody, false, attendeeRecord); + const result = await selectEventForCustomer(phone, sender, incomingMessageBody, false, attendeeRecord, from); if (result) return result; } @@ -266,12 +244,13 @@ export async function POST(request: Request) { // Returning customer whose stored event is no longer active if (!event) { - const result = await selectEventForCustomer(phone, sender, incomingMessageBody, true); + const result = await selectEventForCustomer(phone, sender, incomingMessageBody, true, undefined, from); if (result) return result; } - // "Forget me" — delete all stored data for this attendee - if (incomingMessageBody.toLowerCase().includes("forget me")) { + // "Forget me" / "Esqueça de mim" — delete all stored data for this attendee + const lowerBody = incomingMessageBody.toLowerCase(); + if (lowerBody.includes("forget me") || lowerBody.includes("esqueça de mim") || lowerBody.includes("esqueca de mim")) { const profileId = event?.leadCollection === "WeAreDevs_QR" ? (attendeeRecord as any).profileId as string | undefined : undefined; @@ -289,7 +268,12 @@ export async function POST(request: Request) { } sendMessage( sender, - "✅ Done! Your data has been deleted from our system.", + eventLang(event) === "pt-BR" + ? "✅ Pronto! Seus dados foram excluídos do nosso sistema." + : "✅ Done! Your data has been deleted from our system.", + undefined, + undefined, + from, ); return new Response(emptyTwiml(), { status: 200, headers: { "Content-Type": "text/xml" } }); } @@ -313,12 +297,16 @@ export async function POST(request: Request) { incomingMessageBody, mediaUrl, sender, + from, ); return new Response(emptyTwiml(), { status: 200, headers: { "Content-Type": "text/xml" } }); } else { sendMessage( sender, "Registration is temporarily unavailable. Please try again in a moment.", + undefined, + undefined, + from, ); return new Response(emptyTwiml(), { status: 200, headers: { "Content-Type": "text/xml" } }); } @@ -331,6 +319,7 @@ export async function POST(request: Request) { event, incomingMessageBody, event.leadCollection, + from, ); if (handled) { return new Response(emptyTwiml(), { status: 200, headers: { "Content-Type": "text/xml" } }); @@ -339,13 +328,13 @@ export async function POST(request: Request) { if (event.state === EventState.CLOSED) { const message = getPausedEventMessage(eventLang(event)); - sendMessage(sender, message); + sendMessage(sender, message, undefined, undefined, from); return new Response(emptyTwiml(), { status: 200, headers: { "Content-Type": "text/xml" } }); } - const reply = await runAiAgent(incomingMessageBody, event, phone, sender); + const reply = await runAiAgent(incomingMessageBody, event, phone, sender, from); if (reply) { - sendMessage(sender, reply); + sendMessage(sender, reply, undefined, undefined, from); } return new Response(emptyTwiml(), { status: 200, headers: { "Content-Type": "text/xml" } }); diff --git a/src/components/icon-map.tsx b/src/components/icon-map.tsx index 43061d7..82d17d8 100644 --- a/src/components/icon-map.tsx +++ b/src/components/icon-map.tsx @@ -78,12 +78,16 @@ const iconMap: { [key: string]: any } = { Cosmopolitan: WhiteWineIcon, Cosmo: WhiteWineIcon, Appletini: WhiteWineIcon, + "Irish Lovers": WhiteWineIcon, + "Shakerato Lovers": WhiteWineIcon, + "Blue Gin Lovers": WhiteWineIcon, "Mango Black Tea": CupIcon, "Lychee Peachy Green Tea": CupIcon, "Caramel Milk Tea": CupIcon, "Strawberry Matcha Latte": CupIcon, "Strawberry Lemonade Tea": CupIcon, "Cold Brew": CupIcon, + Moka: FlatWhiteIcon, "Matcha Green Tea": CupIcon, "Cucumber Juice": CupIcon, Nuttello: WaffleIcon, diff --git a/src/components/menu-select.tsx b/src/components/menu-select.tsx index 3cde6af..5683107 100644 --- a/src/components/menu-select.tsx +++ b/src/components/menu-select.tsx @@ -1,14 +1,33 @@ -import { Checkbox } from "@/components/ui/checkbox"; +import { Check } from "lucide-react"; import MenuItem from "./menu-item"; import type { MenuItem as MenuItemInterface, Menus, modes, Selection } from "@/types"; import { useToast } from "./ui/use-toast"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "./ui/tabs"; import { Badge } from "./ui/badge"; +import { cn } from "@/lib/utils"; export type { Selection } from "@/types"; const MAX_SELECTABLE_ITEMS = 10; +// Purely decorative checked-state indicator — the surrounding ); diff --git a/src/config/env.ts b/src/config/env.ts index f1d510d..d8cce2b 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -14,7 +14,6 @@ export const env = { twilioSyncServiceSid: process.env.TWILIO_SYNC_SERVICE_SID ?? "", twilioVerifyServiceSid: process.env.TWILIO_VERIFY_SERVICE_SID ?? "", twilioMessagingServiceSid: process.env.TWILIO_MESSAGING_SERVICE_SID ?? "", - twilioPhoneNumber: process.env.TWILIO_PHONE_NUMBER ?? "", twilioMemoryStoreId: process.env.TWILIO_MEMORY_STORE_ID ?? "", // App config diff --git a/src/config/menus.ts b/src/config/menus.ts index 3c189aa..7ad2103 100644 --- a/src/config/menus.ts +++ b/src/config/menus.ts @@ -230,6 +230,11 @@ export default { description: "Cucumber Juice, Wildflower Honey, Parsley, Pellegrino, mint-garnished", }, + { + shortTitle: "Moka", + title: "Moka", + description: "Stovetop-brewed coffee, rich and strong", + }, ], modifiers: [ "Decaf", @@ -456,6 +461,24 @@ export default { shortTitle: "Appletini", description: "Vodka, Apple Schnapps, Apple Juice", }, + { + title: "Irish Lovers", + shortTitle: "Irish Lovers", + description: + "Espresso, brown sugar, Jameson, topped with macadamia-infused cream", + }, + { + title: "Shakerato Lovers", + shortTitle: "Shakerato Lovers", + description: + "Espresso shaken with ice, Leblon Cachaça, lemon syrup, sugarcane molasses", + }, + { + title: "Blue Gin Lovers", + shortTitle: "Blue Gin Lovers", + description: + "Espresso, blue curaçao, tonic water and gin, garnished with lemon", + }, ], modifiers: ["Whipped Cream"], }, diff --git a/src/lib/twilio/content-templates.ts b/src/lib/twilio/content-templates.ts index 45e5f69..bb048ed 100644 --- a/src/lib/twilio/content-templates.ts +++ b/src/lib/twilio/content-templates.ts @@ -30,6 +30,12 @@ export async function deleteWhatsAppTemplate( return data; } +// WhatsApp never approves a template whose richest content type is +// twilio/list-picker — list-picker/interactive-list messages are only +// usable within an already-open session, never as a pre-approved +// outbound template. Submitting these always fails, so skip it. +const RICHEST_TYPES_INELIGIBLE_FOR_APPROVAL = ["twilio/list-picker"]; + export async function createWhatsAppTemplate( template: WhatsAppTemplateConfig, ): Promise { @@ -39,6 +45,16 @@ export async function createWhatsAppTemplate( contentApiAuth(), ); + const ineligibleType = RICHEST_TYPES_INELIGIBLE_FOR_APPROVAL.find( + (type) => type in template.types, + ); + if (ineligibleType) { + console.log( + `Skipping WhatsApp approval request for "${data.friendly_name}" — ${ineligibleType} is not eligible for approval.`, + ); + return data; + } + try { await axios.post( `https://content.twilio.com/v1/Content/${data.sid}/ApprovalRequests/whatsapp`, diff --git a/src/lib/twilio/index.ts b/src/lib/twilio/index.ts index 399dd48..9c88e7b 100644 --- a/src/lib/twilio/index.ts +++ b/src/lib/twilio/index.ts @@ -18,6 +18,7 @@ export { getMessagingService, getPossibleSenders, sendMessage, + getPinnedSender, fetchSegmentTraits, } from "./messaging"; export { getVerifyService, createVerification, checkVerification } from "./verify"; diff --git a/src/lib/twilio/messaging.ts b/src/lib/twilio/messaging.ts index 0cb3a1d..9f60c1d 100644 --- a/src/lib/twilio/messaging.ts +++ b/src/lib/twilio/messaging.ts @@ -2,10 +2,10 @@ import { throttledQueue } from "throttled-queue"; import { twilioClient, TWILIO_API_KEY, TWILIO_API_SECRET } from "./client"; +import { createSyncMapItemIfNotExists } from "./sync"; const { TWILIO_MESSAGING_SERVICE_SID = "", - TWILIO_PHONE_NUMBER = "", SEGMENT_SPACE_ID = "", SEGMENT_PROFILE_KEY = "", } = process.env; @@ -40,20 +40,26 @@ export async function sendMessage( body: string = "", contentSid: string = "", contentVariables: string = "", + from: string = "", ) { if (to === "test-order") { return; } - const from = TWILIO_MESSAGING_SERVICE_SID || TWILIO_PHONE_NUMBER || ""; + const defaultFrom = TWILIO_MESSAGING_SERVICE_SID; try { throttle(() => { twilioClient.messages.create({ to, - ...(TWILIO_MESSAGING_SERVICE_SID - ? { messagingServiceSid: TWILIO_MESSAGING_SERVICE_SID } - : { from }), + // Pin the exact sender the recipient last messaged in on — otherwise + // the Messaging Service can auto-select a different sender (e.g. RCS + // vs WhatsApp) that may be outside that channel's 24h session window. + ...(from + ? { from } + : TWILIO_MESSAGING_SERVICE_SID + ? { messagingServiceSid: TWILIO_MESSAGING_SERVICE_SID } + : { from: defaultFrom }), ...(body ? { body } : {}), ...(contentSid ? { contentSid } : {}), ...(contentVariables ? { contentVariables } : {}), @@ -66,6 +72,17 @@ export async function sendMessage( } } +// Returns just the plain pinned-sender string — Client Components can't +// receive the raw Twilio SDK instance createSyncMapItemIfNotExists resolves +// to (Server Action return values must be plain-serializable). +export async function getPinnedSender( + attendeesMap: string, + phone: string, +): Promise { + const { data } = await createSyncMapItemIfNotExists(attendeesMap, phone); + return (data as any)?.from || ""; +} + export async function fetchSegmentTraits( email: string, specificTrait?: string, diff --git a/src/scripts/broadcastMessage.ts b/src/scripts/broadcastMessage.ts index 9f0db4b..47012be 100644 --- a/src/scripts/broadcastMessage.ts +++ b/src/scripts/broadcastMessage.ts @@ -41,9 +41,16 @@ const throttle = throttledQueue({ maxPerInterval: 10, interval: 1000, evenlySpac // @ts-ignore thinks is a object but actually it's a string if (item.data.event === eventName) { counter++; + // @ts-ignore thinks is a object but actually it's a string + const from: string = item.data.from || ""; + // The pinned `from` carries the channel prefix (whatsapp:/rcs:) the + // attendee is actually reachable on — reuse it for `to` instead of + // sending a bare number, which would default to SMS. + const channelPrefix = from.match(/^(whatsapp:|rcs:)/)?.[1] || ""; + const to = `${channelPrefix}${item.key}`; throttle(async () => { try { - return await sendMessage(item.key, MESSAGE); + return await sendMessage(to, MESSAGE, undefined, undefined, from); } catch (e) { if (isRateLimited(e)) { throw new RetryError({ pauseQueue: true }); diff --git a/src/scripts/buildContentTemplates.ts b/src/scripts/buildContentTemplates.ts index 72bb006..d2b3855 100644 --- a/src/scripts/buildContentTemplates.ts +++ b/src/scripts/buildContentTemplates.ts @@ -93,6 +93,10 @@ function getMoreDetailsButton(language: Language) { return language === "pt-BR" ? "Mais Detalhes" : "More Details"; } +function getOrderActionLabel(language: Language) { + return language === "pt-BR" ? "Pedir" : "Order a"; +} + export function getShowHelpTemplate( numOptions: number, templateName: string, @@ -108,7 +112,7 @@ export function getShowHelpTemplate( indiciesOfFullTitles.push(`- {{${i * 3 + 1}}}`); items.push({ item: `{{${i * 3 + 2}}}`, - id: `Order a {{${i * 3 + 1}}}`, // should be same as indiciesOfFullTitles because this will be send to the webhook + id: `${getOrderActionLabel(language)} {{${i * 3 + 1}}}`, // should be same as indiciesOfFullTitles because this will be send to the webhook description: `{{${i * 3 + 3}}}`, }); } @@ -149,7 +153,7 @@ export function getReadyToOrderTemplate( indiciesOfFullTitles.push(`- {{${i * 3 + 2}}}`); items.push({ item: `{{${i * 3 + 3}}}`, - id: `Order a {{${i * 3 + 2}}}`, // should be same as indiciesOfFullTitles because this will be send to the webhook + id: `${getOrderActionLabel(language)} {{${i * 3 + 2}}}`, // should be same as indiciesOfFullTitles because this will be send to the webhook description: `{{${i * 3 + 4}}}`, }); } @@ -186,7 +190,7 @@ export function getReadyToOrderLimitlessTemplate( indiciesOfFullTitles.push(`- {{${i * 3 + 2}}}`); items.push({ item: `{{${i * 3 + 3}}}`, - id: `Order a {{${i * 3 + 2}}}`, // should be same as indiciesOfFullTitles because this will be send to the webhook + id: `${getOrderActionLabel(language)} {{${i * 3 + 2}}}`, // should be same as indiciesOfFullTitles because this will be send to the webhook description: `{{${i * 3 + 4}}}`, }); } @@ -225,7 +229,7 @@ export function getReadyToOrderWithoutEmailValidationTemplate( indiciesOfFullTitles.push(`- {{${i * 3 + 2}}}`); items.push({ item: `{{${i * 3 + 3}}}`, - id: `Order a {{${i * 3 + 2}}}`, // should be same as indiciesOfFullTitles because this will be send to the webhook + id: `${getOrderActionLabel(language)} {{${i * 3 + 2}}}`, // should be same as indiciesOfFullTitles because this will be send to the webhook description: `{{${i * 3 + 4}}}`, }); } @@ -261,7 +265,7 @@ export function getReadyToOrderLimitlessWithoutEmailValidationTemplate( indiciesOfFullTitles.push(`- {{${i * 3 + 2}}}`); items.push({ item: `{{${i * 3 + 3}}}`, - id: `Order a {{${i * 3 + 2}}}`, // should be same as indiciesOfFullTitles because this will be send to the webhook + id: `${getOrderActionLabel(language)} {{${i * 3 + 2}}}`, // should be same as indiciesOfFullTitles because this will be send to the webhook description: `{{${i * 3 + 4}}}`, }); } diff --git a/src/types/index.ts b/src/types/index.ts index fa10acb..ec9fb06 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -50,7 +50,7 @@ export interface Order { originalText?: string; status: "queued" | "cancelled" | "ready" | "delivered"; reminded?: true; - channel?: "rcs" | "whatsapp" | "sms" | "other"; + channel?: "rcs" | "whatsapp" | "sms" | "api" | "other"; } export interface Selection { @@ -83,6 +83,7 @@ export interface SegmentData { } export interface AttendeeRecord { + from?: string; event?: string; stage?: Stages; orderCount?: number;