Merge pull request #358 from ArnasDon/feat/whatsapp-interactive-messages

feat: WhatsApp interactive messages (reply buttons + lists)
This commit is contained in:
Arnas Donauskas 2026-07-08 19:10:53 +03:00 committed by GitHub
commit 137760e8f8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 2365 additions and 34 deletions

View File

@ -193,7 +193,16 @@
"sendTemplate": "Send template", "sendTemplate": "Send template",
"draftWithAI": "Draft a reply with AI", "draftWithAI": "Draft a reply with AI",
"addCaption": "Add a caption…", "addCaption": "Add a caption…",
"removeAttachment": "Remove attachment" "removeAttachment": "Remove attachment",
"moreActions": "More",
"interactiveMessage": "Interactive message",
"quickReplies": "Quick replies",
"quickRepliesEmpty": "No quick replies yet. Add some in Settings → Quick replies.",
"saveAsQuickReply": "Save as quick reply",
"send": "Send",
"quickReplyNamePrompt": "Name this quick reply:",
"quickReplySaved": "Saved as a quick reply.",
"quickReplySaveError": "Couldn't save the quick reply."
}, },
"bubble": { "bubble": {
"photo": "Photo", "photo": "Photo",
@ -814,6 +823,8 @@
"builder": { "builder": {
"steps": { "steps": {
"send_message": "Send Message", "send_message": "Send Message",
"send_buttons": "Send Buttons",
"send_list": "Send List",
"send_template": "Send Template", "send_template": "Send Template",
"add_tag": "Add Tag", "add_tag": "Add Tag",
"remove_tag": "Remove Tag", "remove_tag": "Remove Tag",
@ -838,6 +849,10 @@
"label": "Keyword Match", "label": "Keyword Match",
"hint": "Message contains specific keyword(s)" "hint": "Message contains specific keyword(s)"
}, },
"interactive_reply": {
"label": "Button / List Reply",
"hint": "Customer taps a button or list row whose id matches — chain menus across automations"
},
"new_contact_created": { "new_contact_created": {
"label": "New Contact Created", "label": "New Contact Created",
"hint": "When a contact is auto-created from an incoming message" "hint": "When a contact is auto-created from an incoming message"
@ -859,6 +874,9 @@
"triggerType": "Trigger type", "triggerType": "Trigger type",
"keywords": "Keywords", "keywords": "Keywords",
"keywordsHint": "Comma-separated list (e.g. hello, help, buy)", "keywordsHint": "Comma-separated list (e.g. hello, help, buy)",
"replyIds": "Reply IDs",
"replyIdsHint": "yes, no, more_info",
"replyIdsHelp": "Comma-separated button/list-row ids to match, exact. Use the same ids you set on a Send Buttons / Send List step.",
"schedule": "Schedule", "schedule": "Schedule",
"scheduleHint": "Cron expression (e.g. 0 9 * * 1-5)", "scheduleHint": "Cron expression (e.g. 0 9 * * 1-5)",
"active": "Active", "active": "Active",

View File

@ -13,6 +13,7 @@ import { SecurityPanel } from '@/components/settings/security-panel';
import { AppearancePanel } from '@/components/settings/appearance-panel'; import { AppearancePanel } from '@/components/settings/appearance-panel';
import { WhatsAppConfig } from '@/components/settings/whatsapp-config'; import { WhatsAppConfig } from '@/components/settings/whatsapp-config';
import { TemplateManager } from '@/components/settings/template-manager'; import { TemplateManager } from '@/components/settings/template-manager';
import { QuickRepliesManager } from '@/components/settings/quick-replies-manager';
import { FieldsAndTagsPanel } from '@/components/settings/fields-and-tags-panel'; import { FieldsAndTagsPanel } from '@/components/settings/fields-and-tags-panel';
import { DealsSettings } from '@/components/settings/deals-settings'; import { DealsSettings } from '@/components/settings/deals-settings';
import { MembersTab } from '@/components/settings/members-tab'; import { MembersTab } from '@/components/settings/members-tab';
@ -59,6 +60,7 @@ export default function SettingsPage() {
appearance: <AppearancePanel />, appearance: <AppearancePanel />,
whatsapp: <WhatsAppConfig />, whatsapp: <WhatsAppConfig />,
templates: <TemplateManager />, templates: <TemplateManager />,
'quick-replies': <QuickRepliesManager />,
fields: <FieldsAndTagsPanel />, fields: <FieldsAndTagsPanel />,
deals: <DealsSettings />, deals: <DealsSettings />,
members: <MembersTab />, members: <MembersTab />,

View File

@ -0,0 +1,103 @@
import { NextResponse } from 'next/server'
import { requireRole, toErrorResponse } from '@/lib/auth/account'
import { supabaseAdmin } from '@/lib/automations/admin-client'
import { validateInteractivePayload } from '@/lib/whatsapp/interactive'
// Update / delete a single quick reply. Quick replies are account-
// shared, so every mutation is scoped by `account_id` (the service-role
// client bypasses the agent-gated RLS, so both the role check and the
// account scope are enforced here).
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params
let ctx
try {
ctx = await requireRole('agent')
} catch (err) {
return toErrorResponse(err)
}
const body = await request.json().catch(() => null)
if (!body) return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
const update: Record<string, unknown> = {}
if (typeof body.title === 'string') {
const title = body.title.trim()
if (!title) return NextResponse.json({ error: 'title cannot be empty' }, { status: 400 })
update.title = title
}
// When `kind` is supplied (e.g. the editor flips Text ↔ Interactive), it
// drives which content column is authoritative and the other is cleared —
// otherwise a switched row keeps a stale payload the picker mis-routes on.
if ('kind' in body) {
if (body.kind !== 'text' && body.kind !== 'interactive') {
return NextResponse.json({ error: 'kind must be "text" or "interactive"' }, { status: 400 })
}
update.kind = body.kind
if (body.kind === 'interactive') {
const result = validateInteractivePayload(body.interactive_payload)
if (!result.ok) return NextResponse.json({ error: result.error }, { status: 400 })
update.interactive_payload = body.interactive_payload
update.content_text = null
} else {
const text = typeof body.content_text === 'string' ? body.content_text : ''
if (!text.trim()) {
return NextResponse.json(
{ error: 'content_text is required for text quick replies' },
{ status: 400 },
)
}
update.content_text = text
update.interactive_payload = null
}
} else {
// No kind change — allow partial edits of whichever field the row uses.
if ('content_text' in body) update.content_text = body.content_text ?? null
if ('interactive_payload' in body) {
if (body.interactive_payload != null) {
const result = validateInteractivePayload(body.interactive_payload)
if (!result.ok) {
return NextResponse.json({ error: result.error }, { status: 400 })
}
}
update.interactive_payload = body.interactive_payload ?? null
}
}
if (Object.keys(update).length === 0) {
return NextResponse.json({ ok: true })
}
const { error } = await supabaseAdmin()
.from('quick_replies')
.update(update)
.eq('id', id)
.eq('account_id', ctx.accountId)
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ ok: true })
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params
let ctx
try {
ctx = await requireRole('agent')
} catch (err) {
return toErrorResponse(err)
}
const { error } = await supabaseAdmin()
.from('quick_replies')
.delete()
.eq('id', id)
.eq('account_id', ctx.accountId)
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ ok: true })
}

View File

@ -0,0 +1,80 @@
import { NextResponse } from 'next/server'
import { getCurrentAccount, requireRole, toErrorResponse } from '@/lib/auth/account'
import { supabaseAdmin } from '@/lib/automations/admin-client'
import { validateInteractivePayload } from '@/lib/whatsapp/interactive'
// Quick replies — reusable snippets (plain text or a saved interactive
// message) shared across the account. GET lists; POST creates. Mirrors
// the automations route: RLS-scoped read via the user client, service-
// role write after an explicit role check.
export async function GET() {
try {
const { supabase } = await getCurrentAccount()
// RLS (quick_replies_select) scopes to the caller's account.
const { data, error } = await supabase
.from('quick_replies')
.select('*')
.order('created_at', { ascending: false })
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ quick_replies: data ?? [] })
} catch (err) {
return toErrorResponse(err)
}
}
export async function POST(request: Request) {
let ctx
try {
ctx = await requireRole('agent')
} catch (err) {
return toErrorResponse(err)
}
const body = await request.json().catch(() => null)
if (!body) return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
const title = typeof body.title === 'string' ? body.title.trim() : ''
const kind = body.kind === 'interactive' ? 'interactive' : 'text'
if (!title) {
return NextResponse.json({ error: 'title is required' }, { status: 400 })
}
let content_text: string | null = null
let interactive_payload: unknown = null
if (kind === 'interactive') {
const result = validateInteractivePayload(body.interactive_payload)
if (!result.ok) {
return NextResponse.json({ error: result.error }, { status: 400 })
}
interactive_payload = body.interactive_payload
} else {
const text = typeof body.content_text === 'string' ? body.content_text : ''
if (!text.trim()) {
return NextResponse.json(
{ error: 'content_text is required for text quick replies' },
{ status: 400 },
)
}
content_text = text
}
const { data, error } = await supabaseAdmin()
.from('quick_replies')
.insert({
account_id: ctx.accountId,
user_id: ctx.userId,
title,
kind,
content_text,
interactive_payload,
})
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ quick_reply: data }, { status: 201 })
}

View File

@ -39,6 +39,7 @@ import {
validateSendMessageParams, validateSendMessageParams,
SendMessageError, SendMessageError,
} from '@/lib/whatsapp/send-message'; } from '@/lib/whatsapp/send-message';
import type { InteractiveMessagePayload } from '@/lib/whatsapp/interactive';
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
@ -79,11 +80,19 @@ export async function POST(request: Request) {
// Validate the message shape BEFORE resolveConversationByPhone // Validate the message shape BEFORE resolveConversationByPhone
// finds-or-creates a contact + conversation, so a bad payload 400s // finds-or-creates a contact + conversation, so a bad payload 400s
// without leaving an orphan contact/conversation behind. // without leaving an orphan contact/conversation behind.
// Validated by `validateSendMessageParams` below; the cast just bridges
// the untyped JSON body to the send-core param type.
const interactivePayload =
body.interactive_payload && typeof body.interactive_payload === 'object'
? (body.interactive_payload as InteractiveMessagePayload)
: null;
validateSendMessageParams({ validateSendMessageParams({
messageType: type, messageType: type,
contentText: typeof body.text === 'string' ? body.text : null, contentText: typeof body.text === 'string' ? body.text : null,
mediaUrl: typeof body.media_url === 'string' ? body.media_url : null, mediaUrl: typeof body.media_url === 'string' ? body.media_url : null,
templateName: typeof template?.name === 'string' ? template.name : null, templateName: typeof template?.name === 'string' ? template.name : null,
interactivePayload,
}); });
// Find-or-create the conversation for this phone, then send. Both // Find-or-create the conversation for this phone, then send. Both
@ -110,6 +119,7 @@ export async function POST(request: Request) {
typeof template?.language === 'string' ? template.language : null, typeof template?.language === 'string' ? template.language : null,
templateParams, templateParams,
templateMessageParams, templateMessageParams,
interactivePayload,
replyToMessageId: replyToMessageId:
typeof body.reply_to_message_id === 'string' typeof body.reply_to_message_id === 'string'
? body.reply_to_message_id ? body.reply_to_message_id

View File

@ -75,6 +75,7 @@ export async function POST(request: Request) {
template_language, template_language,
template_params, template_params,
template_message_params, template_message_params,
interactive_payload,
reply_to_message_id, reply_to_message_id,
} = body } = body
@ -97,6 +98,7 @@ export async function POST(request: Request) {
contentText: content_text, contentText: content_text,
mediaUrl: media_url, mediaUrl: media_url,
templateName: template_name, templateName: template_name,
interactivePayload: interactive_payload,
}) })
} catch (err) { } catch (err) {
if (err instanceof SendMessageError) { if (err instanceof SendMessageError) {
@ -180,6 +182,7 @@ export async function POST(request: Request) {
templateLanguage: template_language, templateLanguage: template_language,
templateParams: template_params, templateParams: template_params,
templateMessageParams: template_message_params, templateMessageParams: template_message_params,
interactivePayload: interactive_payload,
replyToMessageId: reply_to_message_id, replyToMessageId: reply_to_message_id,
}) })

View File

@ -759,11 +759,19 @@ async function processMessage(
| 'first_inbound_message' | 'first_inbound_message'
| 'new_message_received' | 'new_message_received'
| 'keyword_match' | 'keyword_match'
| 'interactive_reply'
)[] = [] )[] = []
// Content-level triggers are suppressed when a flow consumed the // Content-level triggers are suppressed when a flow consumed the
// message — see the comment block above. // message — see the comment block above.
if (!flowConsumed) { if (!flowConsumed) {
automationTriggers.push('new_message_received', 'keyword_match') automationTriggers.push('new_message_received', 'keyword_match')
// Interactive tap → fire the interactive_reply trigger too (only
// meaningful when a button/list reply actually arrived). Enables
// automation-only chained menus; when a Flow owns the menu it will
// have consumed the reply and this is skipped.
if (interactiveReplyId) {
automationTriggers.push('interactive_reply')
}
} }
// new_contact_created fires only when the webhook just auto-created the // new_contact_created fires only when the webhook just auto-created the
// contact row. first_inbound_message fires whenever this is the contact's // contact row. first_inbound_message fires whenever this is the contact's
@ -781,6 +789,9 @@ async function processMessage(
context: { context: {
message_text: inboundText, message_text: inboundText,
conversation_id: conversation.id, conversation_id: conversation.id,
// Only set on interactive taps; drives the interactive_reply
// trigger's exact-id match.
interactive_reply_id: interactiveReplyId ?? undefined,
}, },
}).catch((err) => console.error('[automations] dispatch failed:', err)) }).catch((err) => console.error('[automations] dispatch failed:', err))
} }

View File

@ -31,6 +31,8 @@ import {
Loader2, Loader2,
ArrowDown, ArrowDown,
ArrowUp, ArrowUp,
MousePointerClick,
List,
} from "lucide-react" } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@ -48,10 +50,17 @@ import type {
AutomationStepType, AutomationStepType,
AutomationTriggerType, AutomationTriggerType,
CustomField, CustomField,
InteractiveMessagePayload,
KeywordMatchTriggerConfig, KeywordMatchTriggerConfig,
MessageTemplate, MessageTemplate,
Tag as TagRecord, Tag as TagRecord,
} from "@/types" } from "@/types"
import {
InteractiveBuilder,
blankButtonsPayload,
blankListPayload,
} from "@/components/interactive/interactive-builder"
import { interactivePayloadPreviewText } from "@/lib/whatsapp/interactive"
import { createClient } from "@/lib/supabase/client" import { createClient } from "@/lib/supabase/client"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@ -90,6 +99,8 @@ interface StepMeta {
const STEP_META: Record<AutomationStepType, StepMeta> = { const STEP_META: Record<AutomationStepType, StepMeta> = {
send_message: { label: "send_message", icon: MessageSquare, border: "border-l-primary" }, send_message: { label: "send_message", icon: MessageSquare, border: "border-l-primary" },
send_buttons: { label: "send_buttons", icon: MousePointerClick, border: "border-l-primary" },
send_list: { label: "send_list", icon: List, border: "border-l-primary" },
send_template: { label: "send_template", icon: FileText, border: "border-l-primary" }, send_template: { label: "send_template", icon: FileText, border: "border-l-primary" },
add_tag: { label: "add_tag", icon: Tag, border: "border-l-primary" }, add_tag: { label: "add_tag", icon: Tag, border: "border-l-primary" },
remove_tag: { label: "remove_tag", icon: TagIcon, border: "border-l-primary" }, remove_tag: { label: "remove_tag", icon: TagIcon, border: "border-l-primary" },
@ -104,6 +115,8 @@ const STEP_META: Record<AutomationStepType, StepMeta> = {
const ADDABLE_STEPS: AutomationStepType[] = [ const ADDABLE_STEPS: AutomationStepType[] = [
"send_message", "send_message",
"send_buttons",
"send_list",
"send_template", "send_template",
"add_tag", "add_tag",
"remove_tag", "remove_tag",
@ -120,6 +133,7 @@ const TRIGGER_OPTIONS: { value: AutomationTriggerType }[] = [
{ value: "new_message_received" }, { value: "new_message_received" },
{ value: "first_inbound_message" }, { value: "first_inbound_message" },
{ value: "keyword_match" }, { value: "keyword_match" },
{ value: "interactive_reply" },
{ value: "new_contact_created" }, { value: "new_contact_created" },
{ value: "conversation_assigned" }, { value: "conversation_assigned" },
{ value: "tag_added" }, { value: "tag_added" },
@ -135,10 +149,26 @@ function cid(): string {
) )
} }
// The send_buttons / send_list step_config IS an InteractiveMessagePayload,
// but step_config is typed generically as Record<string, unknown>. These two
// helpers hold the single unavoidable structural cast in one place so a
// payload-shape change has one seam to update instead of four scattered
// `as unknown as` sites.
function toStepConfig(p: InteractiveMessagePayload): Record<string, unknown> {
return p as unknown as Record<string, unknown>
}
function asInteractive(cfg: Record<string, unknown>): InteractiveMessagePayload {
return cfg as unknown as InteractiveMessagePayload
}
function blankConfig(type: AutomationStepType): Record<string, unknown> { function blankConfig(type: AutomationStepType): Record<string, unknown> {
switch (type) { switch (type) {
case "send_message": case "send_message":
return { text: "" } return { text: "" }
case "send_buttons":
return toStepConfig(blankButtonsPayload())
case "send_list":
return toStepConfig(blankListPayload())
case "send_template": case "send_template":
return { template_name: "", language: "en_US" } return { template_name: "", language: "en_US" }
case "add_tag": case "add_tag":
@ -815,6 +845,9 @@ function TriggerCard({
t={t} t={t}
/> />
)} )}
{type === "interactive_reply" && (
<InteractiveReplyConfig config={config} onChange={onConfigChange} t={t} />
)}
{type === "tag_added" && ( {type === "tag_added" && (
<div> <div>
<label className="mb-1 block text-xs font-medium text-muted-foreground"> <label className="mb-1 block text-xs font-medium text-muted-foreground">
@ -928,6 +961,52 @@ function KeywordMatchConfig({
) )
} }
function InteractiveReplyConfig({
config,
onChange,
t,
}: {
config: Record<string, unknown>
onChange: (c: Record<string, unknown>) => void
t: ReturnType<typeof useTranslations>
}) {
const ids = (config?.reply_ids as string[] | undefined) ?? []
// Same local-draft-then-commit pattern as KeywordMatchConfig so
// commas + spaces survive keystrokes.
const [draft, setDraft] = useState(ids.join(", "))
function commit() {
const parsed = draft
.split(",")
.map((s) => s.trim())
.filter(Boolean)
setDraft(parsed.join(", "))
onChange({ ...config, reply_ids: parsed })
}
return (
<div>
<label className="mb-1 block text-xs font-medium text-muted-foreground">
{t("replyIds")}
</label>
<Input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
commit()
}
}}
placeholder={t("replyIdsHint")}
className="bg-muted font-mono text-foreground"
/>
<p className="mt-1 text-[11px] text-muted-foreground">{t("replyIdsHelp")}</p>
</div>
)
}
// ------------------------------------------------------------ // ------------------------------------------------------------
// Step list + card + connectors // Step list + card + connectors
// ------------------------------------------------------------ // ------------------------------------------------------------
@ -1213,6 +1292,18 @@ function StepEditor({
/> />
</FieldBlock> </FieldBlock>
) )
case "send_buttons":
case "send_list":
// The whole step_config IS the interactive payload; the shared
// builder edits it in place (and enforces Meta's limits + preview).
return (
<InteractiveBuilder
value={asInteractive(cfg)}
onChange={(payload) =>
onChange({ ...step, step_config: toStepConfig(payload) })
}
/>
)
case "send_template": case "send_template":
return ( return (
<SendTemplateFields <SendTemplateFields
@ -1419,6 +1510,9 @@ function previewFor(step: BuilderStep): string {
switch (step.step_type) { switch (step.step_type) {
case "send_message": case "send_message":
return (step.step_config.text as string) || "no text yet" return (step.step_config.text as string) || "no text yet"
case "send_buttons":
case "send_list":
return interactivePayloadPreviewText(asInteractive(step.step_config)) || "no body yet"
case "send_template": case "send_template":
return (step.step_config.template_name as string) || "pick a template" return (step.step_config.template_name as string) || "pick a template"
case "wait": case "wait":

View File

@ -18,6 +18,7 @@ import {
import { format } from "date-fns"; import { format } from "date-fns";
import { ReplyQuote } from "./reply-quote"; import { ReplyQuote } from "./reply-quote";
import { MessageReactions } from "./message-reactions"; import { MessageReactions } from "./message-reactions";
import { InteractivePreview } from "@/components/interactive/interactive-preview";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
interface MessageBubbleProps { interface MessageBubbleProps {
@ -216,11 +217,19 @@ function MessageContent({ message, t }: { message: Message, t: ReturnType<typeof
); );
case "interactive": { case "interactive": {
// Customer tapped a reply button or list row on a message the bot // Three cases share content_type='interactive':
// sent. We show the tapped option's title (already in content_text, // - OUTBOUND with payload (composer / automation / Flow send after
// set by parseMessageContent in the webhook) with a small affordance // migration 035): render the buttons/list as they appear on the phone.
// so agents reading the inbox can tell at a glance that this is a // - INBOUND tap (customer chose an option, sender_type='customer'):
// tap rather than the customer typing the same words. // no payload; show the tapped option's title with a reply affordance
// so agents can tell it's a tap, not the customer typing.
// - OUTBOUND with NO payload (legacy bot/Flow sends from before
// migration 035 backfilled the column): show the body text plainly —
// it is our own message, NOT a customer tap.
if (message.interactive_payload) {
return <InteractivePreview payload={message.interactive_payload} />;
}
if (message.sender_type === "customer") {
return ( return (
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<span className="inline-flex items-center gap-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground"> <span className="inline-flex items-center gap-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
@ -233,6 +242,12 @@ function MessageContent({ message, t }: { message: Message, t: ReturnType<typeof
</div> </div>
); );
} }
return (
<p className="whitespace-pre-wrap break-words text-sm">
{message.content_text || t("interactiveReply")}
</p>
);
}
default: default:
return ( return (

View File

@ -19,6 +19,9 @@ import {
X, X,
Loader2, Loader2,
Sparkles, Sparkles,
Plus,
MessageSquareDashed,
Zap,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { GatedButton } from "@/components/ui/gated-button"; import { GatedButton } from "@/components/ui/gated-button";
@ -28,6 +31,13 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useCan } from "@/hooks/use-can"; import { useCan } from "@/hooks/use-can";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { toast } from "sonner"; import { toast } from "sonner";
@ -38,6 +48,13 @@ import {
} from "@/lib/storage/upload-media"; } from "@/lib/storage/upload-media";
import { ReplyQuote } from "./reply-quote"; import { ReplyQuote } from "./reply-quote";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import {
InteractiveBuilder,
blankButtonsPayload,
} from "@/components/interactive/interactive-builder";
import { validateInteractivePayload } from "@/lib/whatsapp/interactive";
import type { InteractiveMessagePayload, QuickReply } from "@/types";
import { QuickReplyPicker } from "./quick-reply-picker";
/** Media content types an agent can send from the composer. */ /** Media content types an agent can send from the composer. */
export type ComposerMediaKind = "image" | "video" | "document" | "audio"; export type ComposerMediaKind = "image" | "video" | "document" | "audio";
@ -97,6 +114,7 @@ interface MessageComposerProps {
sessionExpired: boolean; sessionExpired: boolean;
onSend: (text: string, replyToId?: string) => void; onSend: (text: string, replyToId?: string) => void;
onSendMedia: (payload: SendMediaPayload) => void; onSendMedia: (payload: SendMediaPayload) => void;
onSendInteractive: (payload: InteractiveMessagePayload, replyToId?: string) => void;
onOpenTemplates: () => void; onOpenTemplates: () => void;
replyTo?: ReplyDraft | null; replyTo?: ReplyDraft | null;
onClearReply?: () => void; onClearReply?: () => void;
@ -118,6 +136,7 @@ export function MessageComposer({
sessionExpired, sessionExpired,
onSend, onSend,
onSendMedia, onSendMedia,
onSendInteractive,
onOpenTemplates, onOpenTemplates,
replyTo, replyTo,
onClearReply, onClearReply,
@ -129,6 +148,13 @@ export function MessageComposer({
const [drafting, setDrafting] = useState(false); const [drafting, setDrafting] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
// Interactive-message builder dialog + quick-reply picker.
const [interactiveOpen, setInteractiveOpen] = useState(false);
const [interactivePayload, setInteractivePayload] =
useState<InteractiveMessagePayload>(blankButtonsPayload);
const [savingQuickReply, setSavingQuickReply] = useState(false);
const [quickReplyOpen, setQuickReplyOpen] = useState(false);
// Media attachment state. `draft` holds an uploaded-but-not-yet-sent // Media attachment state. `draft` holds an uploaded-but-not-yet-sent
// attachment; `busy` covers the upload/transcode window. // attachment; `busy` covers the upload/transcode window.
const [draft, setDraft] = useState<MediaDraft | null>(null); const [draft, setDraft] = useState<MediaDraft | null>(null);
@ -272,6 +298,89 @@ export function MessageComposer({
} }
}, [drafting, conversationId, adjustHeight]); }, [drafting, conversationId, adjustHeight]);
// ---- Interactive message + quick replies --------------------------
const openInteractiveBuilder = useCallback(
(seed?: InteractiveMessagePayload) => {
setInteractivePayload(seed ?? blankButtonsPayload());
setInteractiveOpen(true);
},
[],
);
const sendInteractive = useCallback(() => {
const result = validateInteractivePayload(interactivePayload);
if (!result.ok) {
toast.error(result.error);
return;
}
onSendInteractive(interactivePayload, replyTo?.id);
setInteractiveOpen(false);
onClearReply?.();
}, [interactivePayload, onSendInteractive, replyTo?.id, onClearReply]);
// Persist the current builder payload as a reusable interactive snippet.
const saveAsQuickReply = useCallback(async () => {
const result = validateInteractivePayload(interactivePayload);
if (!result.ok) {
toast.error(result.error);
return;
}
const title = window
.prompt(t("quickReplyNamePrompt"))
?.trim();
if (!title) return;
setSavingQuickReply(true);
try {
const res = await fetch("/api/quick-replies", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title,
kind: "interactive",
interactive_payload: interactivePayload,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
toast.error(data.error ?? t("quickReplySaveError"));
return;
}
toast.success(t("quickReplySaved"));
} catch {
toast.error(t("quickReplySaveError"));
} finally {
setSavingQuickReply(false);
}
}, [interactivePayload, t]);
// A picked quick reply: text fills the composer; interactive opens the
// builder pre-filled so the agent can tweak before sending.
const handlePickQuickReply = useCallback(
(qr: QuickReply) => {
setQuickReplyOpen(false);
if (qr.kind === "interactive" && qr.interactive_payload) {
openInteractiveBuilder(qr.interactive_payload);
return;
}
const body = qr.content_text ?? "";
// Separate the snippet from any existing draft with a newline so the
// words don't run together ("Thanks" + "we'll…" → "Thankswe'll…").
setText((prev) =>
prev && !/\s$/.test(prev) ? `${prev}\n${body}` : `${prev}${body}`,
);
requestAnimationFrame(() => {
adjustHeight();
const el = textareaRef.current;
if (el) {
el.focus();
el.setSelectionRange(el.value.length, el.value.length);
}
});
},
[openInteractiveBuilder, adjustHeight],
);
// Upload a captured file to chat-media and stage it as a draft. // Upload a captured file to chat-media and stage it as a draft.
const stageUpload = useCallback( const stageUpload = useCallback(
async (kind: ComposerMediaKind, file: File) => { async (kind: ComposerMediaKind, file: File) => {
@ -560,6 +669,34 @@ export function MessageComposer({
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{/* + menu — interactive messages + quick replies. Gated on the
24h window like free-form text (interactive requires it). */}
<DropdownMenu>
<DropdownMenuTrigger
disabled={inputsDisabled}
title={
readOnly
? t("readOnlyTitle")
: inputsDisabled
? undefined
: t("moreActions")
}
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md p-0 text-muted-foreground hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
>
<Plus className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="border-border bg-popover">
<DropdownMenuItem onClick={() => openInteractiveBuilder()}>
<MessageSquareDashed className="mr-2 h-4 w-4" />
{t("interactiveMessage")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setQuickReplyOpen(true)}>
<Zap className="mr-2 h-4 w-4" />
{t("quickReplies")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<GatedButton <GatedButton
variant="ghost" variant="ghost"
size="sm" size="sm"
@ -634,6 +771,46 @@ export function MessageComposer({
{t("draftHint")} {t("draftHint")}
</p> </p>
)} )}
{/* Interactive-message builder dialog. */}
<Dialog open={interactiveOpen} onOpenChange={setInteractiveOpen}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{t("interactiveMessage")}</DialogTitle>
</DialogHeader>
<div className="max-h-[70vh] overflow-y-auto">
<InteractiveBuilder
value={interactivePayload}
onChange={setInteractivePayload}
/>
</div>
<DialogFooter>
<Button
variant="outline"
disabled={savingQuickReply}
onClick={saveAsQuickReply}
>
{savingQuickReply ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<Zap className="mr-1 h-4 w-4" />
)}
{t("saveAsQuickReply")}
</Button>
<Button onClick={sendInteractive}>
<Send className="mr-1 h-4 w-4" />
{t("send")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Quick-reply picker. */}
<QuickReplyPicker
open={quickReplyOpen}
onOpenChange={setQuickReplyOpen}
onPick={handlePickQuickReply}
/>
</div> </div>
); );
} }

View File

@ -15,6 +15,7 @@ import type {
ConversationStatus, ConversationStatus,
MessageTemplate, MessageTemplate,
Profile, Profile,
InteractiveMessagePayload,
} from "@/types"; } from "@/types";
import { import {
MessageSquare, MessageSquare,
@ -567,6 +568,59 @@ export function MessageThread({
[conversation, onNewMessage, onUpdateMessage], [conversation, onNewMessage, onUpdateMessage],
); );
const handleSendInteractive = useCallback(
async (payload: InteractiveMessagePayload, replyToId?: string) => {
if (!conversation) return;
const tempId = `temp-${Date.now()}`;
// Optimistic bubble — renders the buttons/list immediately via the
// interactive_payload, same as the persisted row will.
const optimisticMsg: Message = {
id: tempId,
conversation_id: conversation.id,
sender_type: "agent",
content_type: "interactive",
content_text: payload.body,
interactive_payload: payload,
status: "sending",
created_at: new Date().toISOString(),
reply_to_message_id: replyToId,
};
onNewMessage(optimisticMsg);
try {
const res = await fetch("/api/whatsapp/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
conversation_id: conversation.id,
message_type: "interactive",
interactive_payload: payload,
reply_to_message_id: replyToId,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const reason = data?.error || `HTTP ${res.status}`;
console.error("Failed to send interactive message:", reason);
toast.error(`Failed to send: ${reason}`);
onUpdateMessage(tempId, { status: "failed" });
return;
}
onUpdateMessage(tempId, { status: "sent" });
} catch (err) {
console.error("Failed to send interactive message:", err);
const reason = err instanceof Error ? err.message : "network error";
toast.error(`Failed to send: ${reason}`);
onUpdateMessage(tempId, { status: "failed" });
}
},
[conversation, onNewMessage, onUpdateMessage],
);
const handleStatusChange = useCallback( const handleStatusChange = useCallback(
async (status: ConversationStatus) => { async (status: ConversationStatus) => {
if (!conversation) return; if (!conversation) return;
@ -1101,6 +1155,7 @@ export function MessageThread({
sessionExpired={sessionInfo.expired} sessionExpired={sessionInfo.expired}
onSend={handleSend} onSend={handleSend}
onSendMedia={handleSendMedia} onSendMedia={handleSendMedia}
onSendInteractive={handleSendInteractive}
onOpenTemplates={handleOpenTemplates} onOpenTemplates={handleOpenTemplates}
replyTo={replyTo} replyTo={replyTo}
onClearReply={() => setReplyTo(null)} onClearReply={() => setReplyTo(null)}

View File

@ -0,0 +1,104 @@
"use client";
import { useEffect, useState } from "react";
import { Loader2, MessageSquare, Zap } from "lucide-react";
import { useTranslations } from "next-intl";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { QuickReply } from "@/types";
import { interactivePayloadPreviewText } from "@/lib/whatsapp/interactive";
interface QuickReplyPickerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onPick: (qr: QuickReply) => void;
}
/**
* Lists the account's saved quick replies for insertion into the
* composer. Text snippets fill the textarea; interactive snippets open
* the builder pre-filled (handled by the caller's `onPick`).
*/
export function QuickReplyPicker({
open,
onOpenChange,
onPick,
}: QuickReplyPickerProps) {
const t = useTranslations("Inbox.composer");
const [items, setItems] = useState<QuickReply[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!open) return;
let cancelled = false;
setLoading(true);
void (async () => {
try {
const res = await fetch("/api/quick-replies", { cache: "no-store" });
const data = await res.json().catch(() => ({}));
if (!cancelled && res.ok) {
setItems((data.quick_replies as QuickReply[]) ?? []);
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [open]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("quickReplies")}</DialogTitle>
</DialogHeader>
<div className="max-h-[60vh] overflow-y-auto">
{loading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : items.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
{t("quickRepliesEmpty")}
</p>
) : (
<ul className="flex flex-col gap-1">
{items.map((qr) => (
<li key={qr.id}>
<button
type="button"
onClick={() => onPick(qr)}
className="flex w-full items-start gap-2 rounded-md border border-border bg-muted/40 p-2.5 text-left hover:border-primary/50 hover:bg-muted"
>
{qr.kind === "interactive" ? (
<Zap className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
) : (
<MessageSquare className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
)}
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-foreground">
{qr.title}
</span>
<span className="block truncate text-xs text-muted-foreground">
{qr.kind === "interactive" && qr.interactive_payload
? interactivePayloadPreviewText(qr.interactive_payload)
: qr.content_text}
</span>
</span>
</button>
</li>
))}
</ul>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,468 @@
"use client";
import { useState } from "react";
import { Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { slugify } from "@/components/flows/shared";
import { INTERACTIVE_LIMITS } from "@/lib/whatsapp/meta-api";
import {
validateInteractivePayload,
type InteractiveButtonsPayload,
type InteractiveListPayload,
type InteractiveMessagePayload,
} from "@/lib/whatsapp/interactive";
import { InteractivePreview } from "./interactive-preview";
// ------------------------------------------------------------
// Blank payload factories — used to seed a fresh builder and to
// switch kind without losing the shared body/header/footer.
// ------------------------------------------------------------
/**
* Generate an id that doesn't collide with any already in use. A plain
* count-based id (`btn_${length+1}`) regenerates an existing id after a
* middle item is removed, which then trips the duplicate-id validator and
* silently blocks sending. Increment past any taken id instead.
*/
function nextId(existing: string[], prefix: string): string {
const taken = new Set(existing);
let n = existing.length + 1;
while (taken.has(`${prefix}${n}`)) n++;
return `${prefix}${n}`;
}
export function blankButtonsPayload(): InteractiveButtonsPayload {
return {
kind: "buttons",
body: "",
buttons: [{ id: "btn_1", title: "" }],
};
}
export function blankListPayload(): InteractiveListPayload {
return {
kind: "list",
body: "",
button_label: "Menu",
sections: [{ title: "", rows: [{ id: "row_1", title: "" }] }],
};
}
interface InteractiveBuilderProps {
value: InteractiveMessagePayload;
onChange: (payload: InteractiveMessagePayload) => void;
/** Show the live WhatsApp-style preview beside the form. Default true. */
showPreview?: boolean;
}
/**
* Controlled builder for a WhatsApp interactive message (reply buttons
* or list). Enforces Meta's char limits inline (maxLength + counters)
* and surfaces a single validation error via `validateInteractivePayload`
* — the same check the server runs before sending. Shared by the inbox
* composer, the automation Send node, and the quick-replies manager.
*/
export function InteractiveBuilder({
value,
onChange,
showPreview = true,
}: InteractiveBuilderProps) {
const [advanced, setAdvanced] = useState(false);
const validation = validateInteractivePayload(value);
const setField = (patch: Partial<InteractiveMessagePayload>) =>
onChange({ ...value, ...patch } as InteractiveMessagePayload);
const switchKind = (kind: "buttons" | "list") => {
if (kind === value.kind) return;
const shared = { body: value.body, header: value.header, footer: value.footer };
onChange(
kind === "buttons"
? { ...blankButtonsPayload(), ...shared }
: { ...blankListPayload(), ...shared },
);
};
return (
<div className="flex flex-col gap-4 md:flex-row">
<div className="flex min-w-0 flex-1 flex-col gap-3">
{/* Kind toggle */}
<div className="flex gap-2">
<KindButton
active={value.kind === "buttons"}
label="Reply buttons"
onClick={() => switchKind("buttons")}
/>
<KindButton
active={value.kind === "list"}
label="List"
onClick={() => switchKind("list")}
/>
</div>
<Field label="Body" counter={`${value.body.length}/${INTERACTIVE_LIMITS.bodyMaxLength}`}>
<Textarea
value={value.body}
maxLength={INTERACTIVE_LIMITS.bodyMaxLength}
onChange={(e) => setField({ body: e.target.value })}
placeholder="What the customer reads above the options"
className="min-h-20 bg-muted text-foreground"
/>
</Field>
<div className="grid grid-cols-2 gap-2">
<Field
label="Header (optional)"
counter={`${(value.header ?? "").length}/${INTERACTIVE_LIMITS.headerTextMaxLength}`}
>
<Input
value={value.header ?? ""}
maxLength={INTERACTIVE_LIMITS.headerTextMaxLength}
onChange={(e) => setField({ header: e.target.value })}
className="bg-muted text-foreground"
/>
</Field>
<Field
label="Footer (optional)"
counter={`${(value.footer ?? "").length}/${INTERACTIVE_LIMITS.footerMaxLength}`}
>
<Input
value={value.footer ?? ""}
maxLength={INTERACTIVE_LIMITS.footerMaxLength}
onChange={(e) => setField({ footer: e.target.value })}
className="bg-muted text-foreground"
/>
</Field>
</div>
{value.kind === "buttons" ? (
<ButtonsEditor value={value} onChange={onChange} advanced={advanced} />
) : (
<ListEditor value={value} onChange={onChange} advanced={advanced} />
)}
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
checked={advanced}
onChange={(e) => setAdvanced(e.target.checked)}
className="h-3.5 w-3.5 accent-primary"
/>
Show reply IDs (advanced)
</label>
{!validation.ok && (
<p className="text-xs text-red-400">{validation.error}</p>
)}
</div>
{showPreview && (
<div className="flex shrink-0 flex-col gap-1.5 md:w-[280px]">
<span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
Preview
</span>
<div className="rounded-lg bg-muted/40 p-3">
<InteractivePreview payload={value} />
</div>
</div>
)}
</div>
);
}
// ------------------------------------------------------------
// Buttons editor
// ------------------------------------------------------------
function ButtonsEditor({
value,
onChange,
advanced,
}: {
value: InteractiveButtonsPayload;
onChange: (p: InteractiveMessagePayload) => void;
advanced: boolean;
}) {
const buttons = value.buttons;
const update = (idx: number, patch: Partial<InteractiveButtonsPayload["buttons"][number]>) =>
onChange({
...value,
buttons: buttons.map((b, i) => (i === idx ? { ...b, ...patch } : b)),
});
const add = () =>
onChange({
...value,
buttons: [
...buttons,
{ id: nextId(buttons.map((b) => b.id), "btn_"), title: "" },
],
});
const remove = (idx: number) =>
onChange({ ...value, buttons: buttons.filter((_, i) => i !== idx) });
return (
<div>
<label className="mb-2 block text-xs text-muted-foreground">
Buttons ({buttons.length}/{INTERACTIVE_LIMITS.maxButtons})
</label>
<div className="flex flex-col gap-2">
{buttons.map((b, i) => (
<div
key={i}
className="flex items-center gap-2 rounded-md border border-border bg-muted/40 p-2"
>
{advanced && (
<Input
value={b.id}
onChange={(e) => update(i, { id: slugify(e.target.value, `btn_${i + 1}`) })}
placeholder="id"
className="w-28 bg-muted font-mono text-xs"
/>
)}
<Input
value={b.title}
maxLength={INTERACTIVE_LIMITS.buttonTitleMaxLength}
onChange={(e) => update(i, { title: e.target.value })}
placeholder="Button label"
className="flex-1 bg-muted"
/>
<span className="w-10 shrink-0 text-right text-[10px] text-muted-foreground">
{b.title.length}/{INTERACTIVE_LIMITS.buttonTitleMaxLength}
</span>
{buttons.length > 1 && (
<Button
variant="ghost"
size="sm"
onClick={() => remove(i)}
className="text-red-400 hover:bg-red-500/10 hover:text-red-300"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
))}
</div>
{buttons.length < INTERACTIVE_LIMITS.maxButtons && (
<Button variant="ghost" size="sm" onClick={add} className="mt-2">
<Plus className="h-3.5 w-3.5" />
Add button
</Button>
)}
</div>
);
}
// ------------------------------------------------------------
// List editor
// ------------------------------------------------------------
function ListEditor({
value,
onChange,
advanced,
}: {
value: InteractiveListPayload;
onChange: (p: InteractiveMessagePayload) => void;
advanced: boolean;
}) {
const sections = value.sections;
const totalRows = sections.reduce((n, s) => n + s.rows.length, 0);
const allRowIds = () => sections.flatMap((s) => s.rows.map((r) => r.id));
const updateSection = (sIdx: number, patch: Partial<InteractiveListPayload["sections"][number]>) =>
onChange({
...value,
sections: sections.map((s, i) => (i === sIdx ? { ...s, ...patch } : s)),
});
const updateRow = (
sIdx: number,
rIdx: number,
patch: Partial<InteractiveListPayload["sections"][number]["rows"][number]>,
) =>
onChange({
...value,
sections: sections.map((s, i) =>
i === sIdx
? { ...s, rows: s.rows.map((r, j) => (j === rIdx ? { ...r, ...patch } : r)) }
: s,
),
});
const addRow = (sIdx: number) =>
onChange({
...value,
sections: sections.map((s, i) =>
i === sIdx
? { ...s, rows: [...s.rows, { id: nextId(allRowIds(), "row_"), title: "" }] }
: s,
),
});
const removeRow = (sIdx: number, rIdx: number) =>
onChange({
...value,
sections: sections.map((s, i) =>
i === sIdx ? { ...s, rows: s.rows.filter((_, j) => j !== rIdx) } : s,
),
});
const addSection = () =>
onChange({
...value,
sections: [
...sections,
{ title: "", rows: [{ id: nextId(allRowIds(), "row_"), title: "" }] },
],
});
const removeSection = (sIdx: number) =>
onChange({ ...value, sections: sections.filter((_, i) => i !== sIdx) });
return (
<div className="flex flex-col gap-3">
<Field label="List button label" counter={`${value.button_label.length}/${INTERACTIVE_LIMITS.buttonTitleMaxLength}`}>
<Input
value={value.button_label}
maxLength={INTERACTIVE_LIMITS.buttonTitleMaxLength}
onChange={(e) => onChange({ ...value, button_label: e.target.value })}
className="bg-muted text-foreground"
/>
</Field>
<label className="block text-xs text-muted-foreground">
Rows ({totalRows}/{INTERACTIVE_LIMITS.maxListRowsTotal})
</label>
{sections.map((section, sIdx) => (
<div key={sIdx} className="rounded-md border border-border bg-muted/40 p-2">
<div className="mb-2 flex items-center gap-2">
<Input
value={section.title ?? ""}
onChange={(e) => updateSection(sIdx, { title: e.target.value })}
placeholder="Section title (optional)"
className="flex-1 bg-muted text-xs"
/>
{sections.length > 1 && (
<Button
variant="ghost"
size="sm"
onClick={() => removeSection(sIdx)}
className="text-red-400 hover:bg-red-500/10 hover:text-red-300"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
<div className="flex flex-col gap-2">
{section.rows.map((row, rIdx) => (
<div key={rIdx} className="rounded border border-border bg-card p-2">
<div className="flex items-center gap-2">
{advanced && (
<Input
value={row.id}
onChange={(e) =>
updateRow(sIdx, rIdx, { id: slugify(e.target.value, `row_${rIdx + 1}`) })
}
placeholder="id"
className="w-24 bg-muted font-mono text-xs"
/>
)}
<Input
value={row.title}
maxLength={INTERACTIVE_LIMITS.listRowTitleMaxLength}
onChange={(e) => updateRow(sIdx, rIdx, { title: e.target.value })}
placeholder="Row title"
className="flex-1 bg-muted"
/>
<span className="w-10 shrink-0 text-right text-[10px] text-muted-foreground">
{row.title.length}/{INTERACTIVE_LIMITS.listRowTitleMaxLength}
</span>
{totalRows > 1 && (
<Button
variant="ghost"
size="sm"
onClick={() => removeRow(sIdx, rIdx)}
className="text-red-400 hover:bg-red-500/10 hover:text-red-300"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
<Input
value={row.description ?? ""}
maxLength={INTERACTIVE_LIMITS.listRowDescriptionMaxLength}
onChange={(e) => updateRow(sIdx, rIdx, { description: e.target.value })}
placeholder="Description (optional)"
className="mt-2 bg-muted text-xs"
/>
</div>
))}
</div>
{totalRows < INTERACTIVE_LIMITS.maxListRowsTotal && (
<Button variant="ghost" size="sm" onClick={() => addRow(sIdx)} className="mt-2">
<Plus className="h-3.5 w-3.5" />
Add row
</Button>
)}
</div>
))}
{sections.length < INTERACTIVE_LIMITS.maxListSections &&
totalRows < INTERACTIVE_LIMITS.maxListRowsTotal && (
<Button variant="ghost" size="sm" onClick={addSection}>
<Plus className="h-3.5 w-3.5" />
Add section
</Button>
)}
</div>
);
}
// ------------------------------------------------------------
// Small presentational helpers
// ------------------------------------------------------------
function KindButton({
active,
label,
onClick,
}: {
active: boolean;
label: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex-1 rounded-md border px-3 py-1.5 text-sm font-medium transition-colors",
active
? "border-primary bg-primary/10 text-primary"
: "border-border bg-muted text-muted-foreground hover:text-foreground",
)}
>
{label}
</button>
);
}
function Field({
label,
counter,
children,
}: {
label: string;
counter?: string;
children: React.ReactNode;
}) {
return (
<div>
<div className="mb-1 flex items-center justify-between">
<label className="text-xs text-muted-foreground">{label}</label>
{counter && <span className="text-[10px] text-muted-foreground">{counter}</span>}
</div>
{children}
</div>
);
}

View File

@ -0,0 +1,75 @@
"use client";
import { List, Reply } from "lucide-react";
import { cn } from "@/lib/utils";
import type { InteractiveMessagePayload } from "@/lib/whatsapp/interactive";
/**
* WhatsApp-style read-only render of an interactive message. Used both
* in the builder's live preview and by the inbox message bubble so a
* sent buttons/list message shows the same way it does on the phone.
*
* Purely presentational — the buttons/rows are not clickable here (the
* customer taps them on their own device). Kept namespace-free (plain
* English) so it can be dropped into the composer, the automation
* builder, and the quick-replies manager without namespace coupling.
*/
export function InteractivePreview({
payload,
className,
}: {
payload: InteractiveMessagePayload;
className?: string;
}) {
return (
<div
className={cn(
"w-full max-w-[260px] overflow-hidden rounded-lg bg-card text-foreground shadow-sm ring-1 ring-border",
className,
)}
>
<div className="px-3 py-2">
{payload.header ? (
<p className="mb-1 break-words text-sm font-semibold">
{payload.header}
</p>
) : null}
<p className="whitespace-pre-wrap break-words text-sm">
{payload.body || (
<span className="text-muted-foreground">Message body…</span>
)}
</p>
{payload.footer ? (
<p className="mt-1 break-words text-[11px] text-muted-foreground">
{payload.footer}
</p>
) : null}
</div>
{payload.kind === "buttons" ? (
<div className="flex flex-col border-t border-border">
{payload.buttons.map((b, i) => (
<button
key={b.id || i}
type="button"
disabled
className="flex items-center justify-center gap-1.5 border-t border-border py-2 text-sm font-medium text-primary first:border-t-0"
>
<Reply className="h-3.5 w-3.5" />
<span className="truncate">{b.title || "Button"}</span>
</button>
))}
</div>
) : (
<button
type="button"
disabled
className="flex w-full items-center justify-center gap-1.5 border-t border-border py-2 text-sm font-medium text-primary"
>
<List className="h-3.5 w-3.5" />
<span className="truncate">{payload.button_label || "Menu"}</span>
</button>
)}
</div>
);
}

View File

@ -0,0 +1,265 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Loader2, MessageSquare, Pencil, Plus, Trash2, Zap } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { SettingsPanelHead } from "./settings-panel-head";
import {
InteractiveBuilder,
blankButtonsPayload,
} from "@/components/interactive/interactive-builder";
import {
interactivePayloadPreviewText,
type InteractiveMessagePayload,
} from "@/lib/whatsapp/interactive";
import type { QuickReply, QuickReplyKind } from "@/types";
interface DraftState {
id?: string;
title: string;
kind: QuickReplyKind;
content_text: string;
interactive_payload: InteractiveMessagePayload;
}
function emptyDraft(): DraftState {
return {
title: "",
kind: "text",
content_text: "",
interactive_payload: blankButtonsPayload(),
};
}
export function QuickRepliesManager() {
const [items, setItems] = useState<QuickReply[]>([]);
const [loading, setLoading] = useState(true);
const [draft, setDraft] = useState<DraftState | null>(null);
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await fetch("/api/quick-replies", { cache: "no-store" });
const data = await res.json().catch(() => ({}));
if (res.ok) setItems((data.quick_replies as QuickReply[]) ?? []);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const openCreate = () => setDraft(emptyDraft());
const openEdit = (qr: QuickReply) =>
setDraft({
id: qr.id,
title: qr.title,
kind: qr.kind,
content_text: qr.content_text ?? "",
interactive_payload:
qr.interactive_payload ?? blankButtonsPayload(),
});
const save = useCallback(async () => {
if (!draft) return;
if (!draft.title.trim()) {
toast.error("Give the quick reply a name.");
return;
}
const payload =
draft.kind === "interactive"
? { title: draft.title, kind: "interactive", interactive_payload: draft.interactive_payload }
: { title: draft.title, kind: "text", content_text: draft.content_text };
setSaving(true);
try {
const res = await fetch(
draft.id ? `/api/quick-replies/${draft.id}` : "/api/quick-replies",
{
method: draft.id ? "PATCH" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
);
const data = await res.json().catch(() => ({}));
if (!res.ok) {
toast.error(data.error ?? "Couldn't save the quick reply.");
return;
}
toast.success(draft.id ? "Quick reply updated." : "Quick reply created.");
setDraft(null);
await load();
} catch {
toast.error("Couldn't save the quick reply.");
} finally {
setSaving(false);
}
}, [draft, load]);
const remove = useCallback(
async (id: string) => {
if (!window.confirm("Delete this quick reply?")) return;
const res = await fetch(`/api/quick-replies/${id}`, { method: "DELETE" });
if (!res.ok) {
toast.error("Couldn't delete the quick reply.");
return;
}
await load();
},
[load],
);
return (
<div>
<SettingsPanelHead
title="Quick replies"
description="Reusable snippets — plain text or a saved interactive message — that agents can insert from the inbox composer."
action={
<Button onClick={openCreate}>
<Plus className="mr-1 h-4 w-4" />
New quick reply
</Button>
}
/>
{loading ? (
<div className="flex justify-center py-10">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : items.length === 0 ? (
<p className="rounded-lg border border-dashed border-border py-10 text-center text-sm text-muted-foreground">
No quick replies yet. Create one to reuse it across conversations.
</p>
) : (
<ul className="flex flex-col gap-2">
{items.map((qr) => (
<li
key={qr.id}
className="flex items-start gap-3 rounded-lg border border-border bg-card p-3"
>
{qr.kind === "interactive" ? (
<Zap className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
) : (
<MessageSquare className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">{qr.title}</p>
<p className="truncate text-xs text-muted-foreground">
{qr.kind === "interactive" && qr.interactive_payload
? interactivePayloadPreviewText(qr.interactive_payload)
: qr.content_text}
</p>
</div>
<div className="flex shrink-0 gap-1">
<Button variant="ghost" size="icon-sm" onClick={() => openEdit(qr)}>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => remove(qr.id)}
className="text-red-400 hover:bg-red-500/10 hover:text-red-300"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</li>
))}
</ul>
)}
<Dialog open={!!draft} onOpenChange={(o) => !o && setDraft(null)}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{draft?.id ? "Edit quick reply" : "New quick reply"}</DialogTitle>
</DialogHeader>
{draft && (
<div className="max-h-[70vh] space-y-3 overflow-y-auto">
<div>
<label className="mb-1 block text-xs text-muted-foreground">Name</label>
<Input
value={draft.title}
onChange={(e) => setDraft({ ...draft, title: e.target.value })}
placeholder="e.g. Business hours"
className="bg-muted text-foreground"
/>
</div>
<div className="flex gap-2">
<KindTab
active={draft.kind === "text"}
label="Text"
onClick={() => setDraft({ ...draft, kind: "text" })}
/>
<KindTab
active={draft.kind === "interactive"}
label="Interactive"
onClick={() => setDraft({ ...draft, kind: "interactive" })}
/>
</div>
{draft.kind === "text" ? (
<Textarea
value={draft.content_text}
onChange={(e) => setDraft({ ...draft, content_text: e.target.value })}
placeholder="The message text to insert"
className="min-h-28 bg-muted text-foreground"
/>
) : (
<InteractiveBuilder
value={draft.interactive_payload}
onChange={(p) => setDraft({ ...draft, interactive_payload: p })}
/>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setDraft(null)} disabled={saving}>
Cancel
</Button>
<Button onClick={save} disabled={saving}>
{saving && <Loader2 className="mr-1 h-4 w-4 animate-spin" />}
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function KindTab({
active,
label,
onClick,
}: {
active: boolean;
label: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={
active
? "flex-1 rounded-md border border-primary bg-primary/10 px-3 py-1.5 text-sm font-medium text-primary"
: "flex-1 rounded-md border border-border bg-muted px-3 py-1.5 text-sm font-medium text-muted-foreground hover:text-foreground"
}
>
{label}
</button>
);
}

View File

@ -9,6 +9,7 @@ import {
Tags, Tags,
User, User,
UsersRound, UsersRound,
Zap,
type LucideIcon, type LucideIcon,
} from 'lucide-react'; } from 'lucide-react';
@ -27,6 +28,7 @@ export const SETTINGS_SECTIONS = [
'appearance', 'appearance',
'whatsapp', 'whatsapp',
'templates', 'templates',
'quick-replies',
'fields', 'fields',
'deals', 'deals',
'members', 'members',
@ -52,6 +54,7 @@ export const SECTION_META: Record<SettingsSection, SectionMeta> = {
appearance: { id: 'appearance', label: 'Appearance', icon: Palette, group: 'account' }, appearance: { id: 'appearance', label: 'Appearance', icon: Palette, group: 'account' },
whatsapp: { id: 'whatsapp', label: 'WhatsApp', icon: PlugZap, group: 'workspace' }, whatsapp: { id: 'whatsapp', label: 'WhatsApp', icon: PlugZap, group: 'workspace' },
templates: { id: 'templates', label: 'Templates', icon: FileText, group: 'workspace' }, templates: { id: 'templates', label: 'Templates', icon: FileText, group: 'workspace' },
'quick-replies': { id: 'quick-replies', label: 'Quick replies', icon: Zap, group: 'workspace' },
fields: { id: 'fields', label: 'Fields & tags', icon: Tags, group: 'workspace' }, fields: { id: 'fields', label: 'Fields & tags', icon: Tags, group: 'workspace' },
deals: { id: 'deals', label: 'Deals & currency', icon: Coins, group: 'workspace' }, deals: { id: 'deals', label: 'Deals & currency', icon: Coins, group: 'workspace' },
members: { id: 'members', label: 'Team members', icon: UsersRound, group: 'workspace' }, members: { id: 'members', label: 'Team members', icon: UsersRound, group: 'workspace' },

View File

@ -93,9 +93,11 @@ vi.mock("./admin-client", () => {
vi.mock("./meta-send", () => ({ vi.mock("./meta-send", () => ({
engineSendText: vi.fn(async () => ({ whatsapp_message_id: "m1" })), engineSendText: vi.fn(async () => ({ whatsapp_message_id: "m1" })),
engineSendTemplate: vi.fn(async () => ({ whatsapp_message_id: "m1" })), engineSendTemplate: vi.fn(async () => ({ whatsapp_message_id: "m1" })),
engineSendInteractive: vi.fn(async () => ({ whatsapp_message_id: "m1" })),
})); }));
import { runAutomationsForTrigger } from "./engine"; import { runAutomationsForTrigger, triggerMatches } from "./engine";
import type { Automation } from "@/types";
const ACCOUNT = "acct-1"; const ACCOUNT = "acct-1";
@ -294,3 +296,43 @@ function customStep(field: string, value: string) {
step_config: { field, value }, step_config: { field, value },
}; };
} }
describe("triggerMatches — interactive_reply", () => {
function automation(reply_ids: string[]): Automation {
return {
id: "a1",
account_id: ACCOUNT,
user_id: "u1",
name: "menu step",
trigger_type: "interactive_reply",
trigger_config: { reply_ids },
is_active: true,
execution_count: 0,
created_at: "",
updated_at: "",
};
}
it("matches when the tapped id is in reply_ids (exact)", () => {
expect(
triggerMatches(automation(["yes", "no"]), { interactive_reply_id: "yes" }),
).toBe(true);
});
it("does not match a different id", () => {
expect(
triggerMatches(automation(["yes"]), { interactive_reply_id: "maybe" }),
).toBe(false);
});
it("does not match on a substring (exact only)", () => {
expect(
triggerMatches(automation(["yes"]), { interactive_reply_id: "yes_please" }),
).toBe(false);
});
it("does not match when no reply id is present or config is empty", () => {
expect(triggerMatches(automation(["yes"]), {})).toBe(false);
expect(triggerMatches(automation([]), { interactive_reply_id: "yes" })).toBe(false);
});
});

View File

@ -5,7 +5,10 @@ import type {
AutomationTriggerType, AutomationTriggerType,
ConditionStepConfig, ConditionStepConfig,
KeywordMatchTriggerConfig, KeywordMatchTriggerConfig,
InteractiveReplyTriggerConfig,
SendMessageStepConfig, SendMessageStepConfig,
SendButtonsStepConfig,
SendListStepConfig,
SendTemplateStepConfig, SendTemplateStepConfig,
SendWebhookStepConfig, SendWebhookStepConfig,
TagStepConfig, TagStepConfig,
@ -15,7 +18,8 @@ import type {
AssignConversationStepConfig, AssignConversationStepConfig,
} from '@/types' } from '@/types'
import { supabaseAdmin } from './admin-client' import { supabaseAdmin } from './admin-client'
import { engineSendText, engineSendTemplate } from './meta-send' import { engineSendText, engineSendTemplate, engineSendInteractive } from './meta-send'
import { validateInteractivePayload } from '@/lib/whatsapp/interactive'
import { isDeliverableUrl } from '@/lib/webhooks/ssrf' import { isDeliverableUrl } from '@/lib/webhooks/ssrf'
// ------------------------------------------------------------ // ------------------------------------------------------------
@ -33,6 +37,8 @@ export interface AutomationContext {
tag_id?: string tag_id?: string
/** Agent the conversation was assigned to, for conversation_assigned. */ /** Agent the conversation was assigned to, for conversation_assigned. */
agent_id?: string agent_id?: string
/** Button / list-row id the customer tapped, for interactive_reply. */
interactive_reply_id?: string
} }
export interface DispatchInput { export interface DispatchInput {
@ -358,6 +364,26 @@ async function runStep(step: AutomationStep, args: ExecuteArgs): Promise<string>
return `sent via Meta (${whatsapp_message_id})` return `sent via Meta (${whatsapp_message_id})`
} }
case 'send_buttons':
case 'send_list': {
const payload = step.step_config as SendButtonsStepConfig | SendListStepConfig
if (!args.contactId) throw new Error(`${step.step_type} needs a contact`)
// Validate against Meta's limits before the network call so a bad
// payload surfaces as a clear failed-step detail rather than a raw
// Meta 400 mid-conversation.
const check = validateInteractivePayload(payload)
if (!check.ok) throw new Error(check.error)
const conversationId = await resolveConversationId(args)
const { whatsapp_message_id } = await engineSendInteractive({
accountId: args.automation.account_id,
userId: args.automation.user_id,
conversationId,
contactId: args.contactId,
payload,
})
return `interactive sent via Meta (${whatsapp_message_id})`
}
case 'send_template': { case 'send_template': {
const cfg = step.step_config as SendTemplateStepConfig const cfg = step.step_config as SendTemplateStepConfig
if (!args.contactId) throw new Error('send_template needs a contact') if (!args.contactId) throw new Error('send_template needs a contact')
@ -591,8 +617,8 @@ async function resolveConversationId(args: ExecuteArgs): Promise<string> {
return data.id as string return data.id as string
} }
function triggerMatches(automation: Automation, ctx: AutomationContext | undefined): boolean { export function triggerMatches(automation: Automation, ctx: AutomationContext | undefined): boolean {
if (automation.trigger_type !== 'keyword_match') return true if (automation.trigger_type === 'keyword_match') {
const cfg = automation.trigger_config as KeywordMatchTriggerConfig const cfg = automation.trigger_config as KeywordMatchTriggerConfig
if (!cfg?.keywords || cfg.keywords.length === 0) return false if (!cfg?.keywords || cfg.keywords.length === 0) return false
const text = (ctx?.message_text ?? '').toString() const text = (ctx?.message_text ?? '').toString()
@ -604,6 +630,21 @@ function triggerMatches(automation: Automation, ctx: AutomationContext | undefin
}) })
} }
// Match on the tapped button / list-row id (exact). Lets multi-step
// menus be chained: automation A sends buttons, automation B fires on
// the reply id and sends the next step.
if (automation.trigger_type === 'interactive_reply') {
const cfg = automation.trigger_config as InteractiveReplyTriggerConfig
const replyId = ctx?.interactive_reply_id
if (!replyId || !Array.isArray(cfg?.reply_ids) || cfg.reply_ids.length === 0) {
return false
}
return cfg.reply_ids.includes(replyId)
}
return true
}
async function evaluateCondition(cfg: ConditionStepConfig, args: ExecuteArgs): Promise<boolean> { async function evaluateCondition(cfg: ConditionStepConfig, args: ExecuteArgs): Promise<boolean> {
const db = supabaseAdmin() const db = supabaseAdmin()
switch (cfg.subject) { switch (cfg.subject) {

View File

@ -1,4 +1,9 @@
import { sendTextMessage, sendTemplateMessage } from '@/lib/whatsapp/meta-api' import { sendTextMessage, sendTemplateMessage } from '@/lib/whatsapp/meta-api'
import type { InteractiveMessagePayload } from '@/lib/whatsapp/interactive'
import {
engineSendInteractiveButtons,
engineSendInteractiveList,
} from '@/lib/flows/meta-send'
import { decrypt } from '@/lib/whatsapp/encryption' import { decrypt } from '@/lib/whatsapp/encryption'
import { import {
sanitizePhoneForMeta, sanitizePhoneForMeta,
@ -53,6 +58,49 @@ export async function engineSendTemplate(
return sendViaMeta({ ...args, kind: 'template' }) return sendViaMeta({ ...args, kind: 'template' })
} }
interface SendInteractiveArgs {
accountId: string
userId: string
conversationId: string
contactId: string
payload: InteractiveMessagePayload
}
/**
* Send an interactive (reply-buttons or list) message from the
* automation engine.
*
* Delegates to the Flows interactive senders
* (`engineSendInteractiveButtons` / `engineSendInteractiveList`), which
* already own the account-scoped lookup, phone-variant retry, and the
* `messages` insert with `interactive_payload` + `sender_type='bot'`.
* Both engines want identical behaviour here, so there's one
* implementation rather than a second hand-rolled copy that could drift.
*/
export async function engineSendInteractive(
args: SendInteractiveArgs,
): Promise<{ whatsapp_message_id: string }> {
const { payload, accountId, userId, conversationId, contactId } = args
const common = { accountId, userId, conversationId, contactId }
if (payload.kind === 'buttons') {
return engineSendInteractiveButtons({
...common,
bodyText: payload.body,
headerText: payload.header,
footerText: payload.footer,
buttons: payload.buttons,
})
}
return engineSendInteractiveList({
...common,
bodyText: payload.body,
buttonLabel: payload.button_label,
headerText: payload.header,
footerText: payload.footer,
sections: payload.sections,
})
}
type SendInput = type SendInput =
| (SendTextArgs & { kind: 'text' }) | (SendTextArgs & { kind: 'text' })
| (SendTemplateArgs & { kind: 'template' }) | (SendTemplateArgs & { kind: 'template' })

View File

@ -35,6 +35,10 @@ export const TRIGGER_META: Record<AutomationTriggerType, TriggerMeta> = {
label: 'Time-Based', label: 'Time-Based',
pillClass: 'border-slate-500/30 bg-slate-500/10 text-muted-foreground', pillClass: 'border-slate-500/30 bg-slate-500/10 text-muted-foreground',
}, },
interactive_reply: {
label: 'Button / List Reply',
pillClass: 'border-pink-500/30 bg-pink-500/10 text-pink-300',
},
} }
export function triggerMeta(t: AutomationTriggerType | string): TriggerMeta { export function triggerMeta(t: AutomationTriggerType | string): TriggerMeta {

View File

@ -120,6 +120,37 @@ describe("validateStepsForActivation", () => {
]); ]);
}); });
it("validates send_buttons / send_list interactive payloads", () => {
const good = validateStepsForActivation([
{
step_type: "send_buttons",
step_config: {
kind: "buttons",
body: "Pick one",
buttons: [{ id: "yes", title: "Yes" }],
},
},
]);
expect(good).toEqual([]);
const tooMany = validateStepsForActivation([
{
step_type: "send_buttons",
step_config: {
kind: "buttons",
body: "Pick one",
buttons: [
{ id: "a", title: "A" },
{ id: "b", title: "B" },
{ id: "c", title: "C" },
{ id: "d", title: "D" },
],
},
},
]);
expect(tooMany.map((i) => i.path)).toEqual(["steps[0].interactive"]);
});
it("flags update_contact_field when field or value is missing", () => { it("flags update_contact_field when field or value is missing", () => {
const issues = validateStepsForActivation([ const issues = validateStepsForActivation([
{ step_type: "update_contact_field", step_config: { field: "name" } }, { step_type: "update_contact_field", step_config: { field: "name" } },
@ -236,6 +267,21 @@ describe("validateTriggerForActivation", () => {
).toEqual([]); ).toEqual([]);
}); });
it("requires reply_ids on interactive_reply triggers", () => {
expect(validateTriggerForActivation("interactive_reply", {})).toEqual([
{ path: "trigger.reply_ids", message: "at least one reply id is required" },
]);
expect(
validateTriggerForActivation("interactive_reply", { reply_ids: ["yes", "no"] }),
).toEqual([]);
const empties = validateTriggerForActivation("interactive_reply", {
reply_ids: ["yes", " "],
});
expect(empties.map((i) => i.message)).toContain(
"reply ids cannot be empty strings",
);
});
it("does not flag unknown trigger types (handled elsewhere)", () => { it("does not flag unknown trigger types (handled elsewhere)", () => {
expect(validateTriggerForActivation("some_future_trigger", {})).toEqual([]); expect(validateTriggerForActivation("some_future_trigger", {})).toEqual([]);
}); });

View File

@ -1,4 +1,5 @@
import type { AutomationTriggerType } from '@/types' import type { AutomationTriggerType } from '@/types'
import { validateInteractivePayload } from '@/lib/whatsapp/interactive'
// ------------------------------------------------------------ // ------------------------------------------------------------
// Pre-flight config validation for automations about to be activated. // Pre-flight config validation for automations about to be activated.
@ -58,6 +59,16 @@ function validateOne(step: StepLike, path: string, issues: ValidationIssue[]): v
issues.push({ path: `${path}.text`, message: 'message text is required' }) issues.push({ path: `${path}.text`, message: 'message text is required' })
} }
break break
case 'send_buttons':
case 'send_list': {
// The whole step_config IS the interactive payload; validate it
// against Meta's limits (same check the engine runs before send).
const result = validateInteractivePayload(c)
if (!result.ok) {
issues.push({ path: `${path}.interactive`, message: result.error })
}
break
}
case 'send_template': case 'send_template':
if (!nonEmpty(c.template_name)) { if (!nonEmpty(c.template_name)) {
issues.push({ path: `${path}.template_name`, message: 'template name is required' }) issues.push({ path: `${path}.template_name`, message: 'template name is required' })
@ -174,6 +185,19 @@ export function validateTriggerForActivation(
if (!nonEmpty(cfg.tag_id)) { if (!nonEmpty(cfg.tag_id)) {
issues.push({ path: 'trigger.tag_id', message: 'tag is required' }) issues.push({ path: 'trigger.tag_id', message: 'tag is required' })
} }
} else if (triggerType === 'interactive_reply') {
const ids = cfg.reply_ids
if (!Array.isArray(ids) || ids.length === 0) {
issues.push({
path: 'trigger.reply_ids',
message: 'at least one reply id is required',
})
} else if (ids.some((v) => typeof v !== 'string' || v.trim() === '')) {
issues.push({
path: 'trigger.reply_ids',
message: 'reply ids cannot be empty strings',
})
}
} }
return issues return issues

View File

@ -7,6 +7,7 @@ import {
type InteractiveListSection, type InteractiveListSection,
type MediaKind, type MediaKind,
} from '@/lib/whatsapp/meta-api' } from '@/lib/whatsapp/meta-api'
import type { InteractiveMessagePayload } from '@/lib/whatsapp/interactive'
import { decrypt } from '@/lib/whatsapp/encryption' import { decrypt } from '@/lib/whatsapp/encryption'
import { import {
sanitizePhoneForMeta, sanitizePhoneForMeta,
@ -413,12 +414,33 @@ async function sendInteractiveViaMeta(
// //
// We do NOT set interactive_reply_id here — that column is reserved // We do NOT set interactive_reply_id here — that column is reserved
// for the customer's tap on this message, populated by the webhook // for the customer's tap on this message, populated by the webhook
// when their reply arrives. // when their reply arrives. We DO persist the structured payload so
// the inbox thread re-renders the buttons/rows the bot sent (round-
// trip), matching the composer + automation send paths.
const interactivePayload: InteractiveMessagePayload =
input.kind === 'buttons'
? {
kind: 'buttons',
body: input.bodyText,
header: input.headerText,
footer: input.footerText,
buttons: input.buttons,
}
: {
kind: 'list',
body: input.bodyText,
header: input.headerText,
footer: input.footerText,
button_label: input.buttonLabel,
sections: input.sections,
}
const { error: msgErr } = await db.from('messages').insert({ const { error: msgErr } = await db.from('messages').insert({
conversation_id: input.conversationId, conversation_id: input.conversationId,
sender_type: 'bot', sender_type: 'bot',
content_type: 'interactive', content_type: 'interactive',
content_text: input.bodyText, content_text: input.bodyText,
interactive_payload: interactivePayload,
message_id: waMessageId, message_id: waMessageId,
status: 'sent', status: 'sent',
}) })

View File

@ -0,0 +1,142 @@
import { describe, expect, it } from 'vitest'
import {
validateInteractivePayload,
interactivePayloadPreviewText,
type InteractiveButtonsPayload,
type InteractiveListPayload,
} from './interactive'
const validButtons: InteractiveButtonsPayload = {
kind: 'buttons',
body: 'Choose an option',
buttons: [
{ id: 'yes', title: 'Yes' },
{ id: 'no', title: 'No' },
],
}
const validList: InteractiveListPayload = {
kind: 'list',
body: 'Pick a service',
button_label: 'View menu',
sections: [
{
title: 'Services',
rows: [
{ id: 'seo', title: 'SEO', description: 'Search optimization' },
{ id: 'ads', title: 'Ads' },
],
},
],
}
describe('validateInteractivePayload — buttons', () => {
it('accepts a well-formed buttons payload', () => {
expect(validateInteractivePayload(validButtons)).toEqual({ ok: true })
})
it('rejects a missing/empty payload', () => {
expect(validateInteractivePayload(undefined).ok).toBe(false)
expect(validateInteractivePayload(null).ok).toBe(false)
})
it('requires a non-empty body within 1024 chars', () => {
expect(validateInteractivePayload({ ...validButtons, body: '' }).ok).toBe(false)
const long = validateInteractivePayload({ ...validButtons, body: 'x'.repeat(1025) })
expect(long.ok).toBe(false)
})
it('requires 1-3 buttons', () => {
expect(validateInteractivePayload({ ...validButtons, buttons: [] }).ok).toBe(false)
const four = validateInteractivePayload({
...validButtons,
buttons: [
{ id: 'a', title: 'A' },
{ id: 'b', title: 'B' },
{ id: 'c', title: 'C' },
{ id: 'd', title: 'D' },
],
})
expect(four.ok).toBe(false)
})
it('caps button title at 20 chars', () => {
const res = validateInteractivePayload({
...validButtons,
buttons: [{ id: 'a', title: 'x'.repeat(21) }],
})
expect(res.ok).toBe(false)
})
it('rejects duplicate button ids', () => {
const res = validateInteractivePayload({
...validButtons,
buttons: [
{ id: 'dup', title: 'A' },
{ id: 'dup', title: 'B' },
],
})
expect(res).toEqual({ ok: false, error: 'Duplicate button id "dup".' })
})
it('rejects empty button id / title', () => {
expect(
validateInteractivePayload({ ...validButtons, buttons: [{ id: '', title: 'A' }] }).ok,
).toBe(false)
expect(
validateInteractivePayload({ ...validButtons, buttons: [{ id: 'a', title: '' }] }).ok,
).toBe(false)
})
})
describe('validateInteractivePayload — list', () => {
it('accepts a well-formed list payload', () => {
expect(validateInteractivePayload(validList)).toEqual({ ok: true })
})
it('requires a button label within 20 chars', () => {
expect(validateInteractivePayload({ ...validList, button_label: '' }).ok).toBe(false)
expect(
validateInteractivePayload({ ...validList, button_label: 'x'.repeat(21) }).ok,
).toBe(false)
})
it('caps total rows at 10 across sections', () => {
const rows = Array.from({ length: 11 }, (_, i) => ({ id: `r${i}`, title: `Row ${i}` }))
const res = validateInteractivePayload({
...validList,
sections: [{ rows }],
})
expect(res.ok).toBe(false)
})
it('caps list row title at 24 chars', () => {
const res = validateInteractivePayload({
...validList,
sections: [{ rows: [{ id: 'r', title: 'x'.repeat(25) }] }],
})
expect(res.ok).toBe(false)
})
it('rejects duplicate row ids across sections', () => {
const res = validateInteractivePayload({
...validList,
sections: [
{ rows: [{ id: 'dup', title: 'A' }] },
{ rows: [{ id: 'dup', title: 'B' }] },
],
})
expect(res.ok).toBe(false)
})
})
describe('interactivePayloadPreviewText', () => {
it('returns the trimmed body', () => {
expect(interactivePayloadPreviewText({ ...validButtons, body: ' Hi ' })).toBe('Hi')
})
it('falls back when body is blank', () => {
expect(interactivePayloadPreviewText({ ...validButtons, body: ' ' })).toBe('[buttons]')
expect(interactivePayloadPreviewText({ ...validList, body: '' })).toBe('[list]')
})
})

View File

@ -0,0 +1,239 @@
// ============================================================
// Interactive message payload — shared shape + validation.
//
// The persisted, round-trippable representation of a WhatsApp
// interactive message (reply buttons or a list). This is the single
// source of truth used by:
// - the inbox composer + automation "send interactive" builders,
// - the send-message core + automation engine (send + persist),
// - the message bubble + preview (render),
// - quick replies (store an interactive snippet).
//
// The field names (`id`/`title`/`description` on buttons/rows) match
// `meta-api.ts`'s `InteractiveButton` / `InteractiveListRow` /
// `InteractiveListSection` on purpose, so a payload maps straight onto
// the Meta send args with no translation.
//
// `validateInteractivePayload` mirrors the throws already inside the
// meta-api senders, but returns a result object so callers (API routes,
// activation checks) can surface a clean error to the user *before* the
// network call rather than turning a bad payload into a 400 from Meta
// mid-conversation.
// ============================================================
import { INTERACTIVE_LIMITS } from './meta-api'
export interface InteractiveButton {
/** Stable id echoed back in the webhook when tapped. */
id: string
/** Visible label (≤ 20 chars per Meta). */
title: string
}
export interface InteractiveButtonsPayload {
kind: 'buttons'
/** Body text shown above the buttons (≤ 1024 chars). */
body: string
/** Optional plain-text header (≤ 60 chars). */
header?: string
/** Optional grey footer line (≤ 60 chars). */
footer?: string
/** 1–3 buttons. */
buttons: InteractiveButton[]
}
export interface InteractiveListRow {
/** Stable id echoed back in the webhook when selected. */
id: string
/** Row title (≤ 24 chars per Meta). */
title: string
/** Optional secondary line (≤ 72 chars). */
description?: string
}
export interface InteractiveListSection {
/** Optional section header shown above its rows. */
title?: string
rows: InteractiveListRow[]
}
export interface InteractiveListPayload {
kind: 'list'
body: string
header?: string
footer?: string
/** Label of the tap-to-expand button on the message bubble (≤ 20 chars). */
button_label: string
/** 1–10 rows TOTAL across all sections. */
sections: InteractiveListSection[]
}
export type InteractiveMessagePayload =
| InteractiveButtonsPayload
| InteractiveListPayload
export type InteractiveValidation =
| { ok: true }
| { ok: false; error: string }
function ok(): InteractiveValidation {
return { ok: true }
}
function fail(error: string): InteractiveValidation {
return { ok: false, error }
}
function validateHeaderFooter(
header: string | undefined,
footer: string | undefined,
): InteractiveValidation {
if (header && header.length > INTERACTIVE_LIMITS.headerTextMaxLength) {
return fail(
`Header exceeds the ${INTERACTIVE_LIMITS.headerTextMaxLength}-character limit.`,
)
}
if (footer && footer.length > INTERACTIVE_LIMITS.footerMaxLength) {
return fail(
`Footer exceeds the ${INTERACTIVE_LIMITS.footerMaxLength}-character limit.`,
)
}
return ok()
}
/**
* Validate an interactive payload against Meta's hard limits + our
* structural rules (non-empty ids/titles, unique ids). Returns a result
* object rather than throwing so API routes can map it to a 400 with a
* user-facing message.
*
* `unknown` in, narrowed here, so it's safe to call straight on a parsed
* request body.
*/
export function validateInteractivePayload(
payload: unknown,
): InteractiveValidation {
if (!payload || typeof payload !== 'object') {
return fail('Interactive message payload is required.')
}
const p = payload as Partial<InteractiveMessagePayload>
if (typeof p.body !== 'string' || p.body.trim() === '') {
return fail('Interactive message body text is required.')
}
if (p.body.length > INTERACTIVE_LIMITS.bodyMaxLength) {
return fail(
`Body text exceeds the ${INTERACTIVE_LIMITS.bodyMaxLength}-character limit.`,
)
}
const hf = validateHeaderFooter(p.header, p.footer)
if (!hf.ok) return hf
if (p.kind === 'buttons') {
const buttons = (p as InteractiveButtonsPayload).buttons
if (!Array.isArray(buttons) || buttons.length < 1) {
return fail('Add at least one reply button.')
}
if (buttons.length > INTERACTIVE_LIMITS.maxButtons) {
return fail(
`A reply-button message allows at most ${INTERACTIVE_LIMITS.maxButtons} buttons.`,
)
}
const seen = new Set<string>()
for (const b of buttons) {
if (!b || typeof b.id !== 'string' || b.id.trim() === '') {
return fail('Every button needs an id.')
}
if (seen.has(b.id)) {
return fail(`Duplicate button id "${b.id}".`)
}
seen.add(b.id)
if (typeof b.title !== 'string' || b.title.trim() === '') {
return fail('Every button needs a label.')
}
if (b.title.length > INTERACTIVE_LIMITS.buttonTitleMaxLength) {
return fail(
`Button label "${b.title}" exceeds the ${INTERACTIVE_LIMITS.buttonTitleMaxLength}-character limit.`,
)
}
}
return ok()
}
if (p.kind === 'list') {
const list = p as InteractiveListPayload
if (
typeof list.button_label !== 'string' ||
list.button_label.trim() === ''
) {
return fail('The list needs a button label.')
}
if (list.button_label.length > INTERACTIVE_LIMITS.buttonTitleMaxLength) {
return fail(
`List button label exceeds the ${INTERACTIVE_LIMITS.buttonTitleMaxLength}-character limit.`,
)
}
if (!Array.isArray(list.sections) || list.sections.length < 1) {
return fail('Add at least one list section.')
}
if (list.sections.length > INTERACTIVE_LIMITS.maxListSections) {
return fail(
`A list allows at most ${INTERACTIVE_LIMITS.maxListSections} sections.`,
)
}
const seen = new Set<string>()
let total = 0
for (const section of list.sections) {
if (!section || !Array.isArray(section.rows)) {
return fail('Every list section needs rows.')
}
for (const row of section.rows) {
total++
if (!row || typeof row.id !== 'string' || row.id.trim() === '') {
return fail('Every list row needs an id.')
}
if (seen.has(row.id)) {
return fail(`Duplicate list row id "${row.id}".`)
}
seen.add(row.id)
if (typeof row.title !== 'string' || row.title.trim() === '') {
return fail('Every list row needs a title.')
}
if (row.title.length > INTERACTIVE_LIMITS.listRowTitleMaxLength) {
return fail(
`List row title "${row.title}" exceeds the ${INTERACTIVE_LIMITS.listRowTitleMaxLength}-character limit.`,
)
}
if (
row.description &&
row.description.length >
INTERACTIVE_LIMITS.listRowDescriptionMaxLength
) {
return fail(
`List row description exceeds the ${INTERACTIVE_LIMITS.listRowDescriptionMaxLength}-character limit.`,
)
}
}
}
if (total < 1) return fail('Add at least one list row.')
if (total > INTERACTIVE_LIMITS.maxListRowsTotal) {
return fail(
`A list allows at most ${INTERACTIVE_LIMITS.maxListRowsTotal} rows in total.`,
)
}
return ok()
}
return fail('Interactive message must be reply buttons or a list.')
}
/**
* Short single-line summary used for `conversations.last_message_text`
* and quick-reply list rows — the body, trimmed, or a sensible fallback.
*/
export function interactivePayloadPreviewText(
payload: InteractiveMessagePayload,
): string {
const body = payload.body?.trim()
if (body) return body
return payload.kind === 'buttons' ? '[buttons]' : '[list]'
}

View File

@ -777,8 +777,16 @@ export async function sendInteractiveButtons(
`Interactive button message requires 1-${INTERACTIVE_LIMITS.maxButtons} buttons (got ${buttons.length}).` `Interactive button message requires 1-${INTERACTIVE_LIMITS.maxButtons} buttons (got ${buttons.length}).`
) )
} }
const seenButtonIds = new Set<string>()
for (const btn of buttons) { for (const btn of buttons) {
if (!btn.id) throw new Error('Interactive button missing id.') if (!btn.id) throw new Error('Interactive button missing id.')
// Duplicate button ids make the tapped-button webhook ambiguous —
// Meta rejects them, and the pre-flight validator (interactive.ts)
// rejects them too, so guard here to keep the two paths in step.
if (seenButtonIds.has(btn.id)) {
throw new Error(`Interactive message has duplicate button id "${btn.id}".`)
}
seenButtonIds.add(btn.id)
if (!btn.title) throw new Error(`Interactive button "${btn.id}" missing title.`) if (!btn.title) throw new Error(`Interactive button "${btn.id}" missing title.`)
if (btn.title.length > INTERACTIVE_LIMITS.buttonTitleMaxLength) { if (btn.title.length > INTERACTIVE_LIMITS.buttonTitleMaxLength) {
throw new Error( throw new Error(

View File

@ -88,6 +88,48 @@ describe('sendMessageToConversation — param validation (pre-DB)', () => {
); );
}); });
it('requires a valid interactive payload for interactive messages', async () => {
// Missing payload entirely.
await expectSendError(
{ ...base, messageType: 'interactive' },
400,
/payload is required/
);
// Too many buttons.
await expectSendError(
{
...base,
messageType: 'interactive',
interactivePayload: {
kind: 'buttons',
body: 'Pick one',
buttons: [
{ id: 'a', title: 'A' },
{ id: 'b', title: 'B' },
{ id: 'c', title: 'C' },
{ id: 'd', title: 'D' },
],
},
},
400,
/at most 3 buttons/
);
// Over-long button title.
await expectSendError(
{
...base,
messageType: 'interactive',
interactivePayload: {
kind: 'buttons',
body: 'Pick one',
buttons: [{ id: 'a', title: 'x'.repeat(21) }],
},
},
400,
/20-character limit/
);
});
it('allows a long "caption" on audio (audio carries none) — so it reaches the DB', async () => { it('allows a long "caption" on audio (audio carries none) — so it reaches the DB', async () => {
// Audio is exempt from the caption cap, so validation passes and we // Audio is exempt from the caption cap, so validation passes and we
// proceed to the conversation lookup — proven by the stub throwing. // proceed to the conversation lookup — proven by the stub throwing.

View File

@ -25,8 +25,15 @@ import {
sendTextMessage, sendTextMessage,
sendTemplateMessage, sendTemplateMessage,
sendMediaMessage, sendMediaMessage,
sendInteractiveButtons,
sendInteractiveList,
type MediaKind, type MediaKind,
} from '@/lib/whatsapp/meta-api'; } from '@/lib/whatsapp/meta-api';
import {
validateInteractivePayload,
interactivePayloadPreviewText,
type InteractiveMessagePayload,
} from '@/lib/whatsapp/interactive';
import { decrypt, encrypt, isLegacyFormat } from '@/lib/whatsapp/encryption'; import { decrypt, encrypt, isLegacyFormat } from '@/lib/whatsapp/encryption';
import { supabaseAdmin } from '@/lib/flows/admin-client'; import { supabaseAdmin } from '@/lib/flows/admin-client';
import { import {
@ -42,6 +49,7 @@ export const MEDIA_KINDS = ['image', 'video', 'document', 'audio'] as const;
export const VALID_MESSAGE_TYPES = [ export const VALID_MESSAGE_TYPES = [
'text', 'text',
'template', 'template',
'interactive',
...MEDIA_KINDS, ...MEDIA_KINDS,
] as const; ] as const;
@ -73,6 +81,8 @@ export interface SendMessageParams {
templateParams?: string[]; templateParams?: string[];
/** Structured template params (header/body/buttons). */ /** Structured template params (header/body/buttons). */
templateMessageParams?: unknown; templateMessageParams?: unknown;
/** Structured payload for `messageType === 'interactive'`. */
interactivePayload?: InteractiveMessagePayload | null;
replyToMessageId?: string | null; replyToMessageId?: string | null;
} }
@ -103,8 +113,10 @@ export function validateSendMessageParams(params: {
contentText?: string | null; contentText?: string | null;
mediaUrl?: string | null; mediaUrl?: string | null;
templateName?: string | null; templateName?: string | null;
interactivePayload?: InteractiveMessagePayload | null;
}): void { }): void {
const { messageType, contentText, mediaUrl, templateName } = params; const { messageType, contentText, mediaUrl, templateName, interactivePayload } =
params;
if (!messageType) { if (!messageType) {
throw new SendMessageError('bad_request', 'message_type is required', 400); throw new SendMessageError('bad_request', 'message_type is required', 400);
@ -136,6 +148,15 @@ export function validateSendMessageParams(params: {
); );
} }
// Interactive: validate the full structured payload against Meta's
// limits up front so a bad payload 400s before we touch Meta.
if (messageType === 'interactive') {
const result = validateInteractivePayload(interactivePayload);
if (!result.ok) {
throw new SendMessageError('bad_request', result.error, 400);
}
}
if (isMediaKind && !mediaUrl) { if (isMediaKind && !mediaUrl) {
throw new SendMessageError( throw new SendMessageError(
'bad_request', 'bad_request',
@ -174,6 +195,7 @@ export async function sendMessageToConversation(
templateLanguage, templateLanguage,
templateParams, templateParams,
templateMessageParams, templateMessageParams,
interactivePayload,
replyToMessageId, replyToMessageId,
} = params; } = params;
@ -185,7 +207,13 @@ export async function sendMessageToConversation(
); );
} }
validateSendMessageParams({ messageType, contentText, mediaUrl, templateName }); validateSendMessageParams({
messageType,
contentText,
mediaUrl,
templateName,
interactivePayload,
});
const isMediaKind = (MEDIA_KINDS as readonly string[]).includes(messageType); const isMediaKind = (MEDIA_KINDS as readonly string[]).includes(messageType);
@ -329,6 +357,34 @@ export async function sendMessageToConversation(
}); });
return result.messageId; return result.messageId;
} }
if (messageType === 'interactive') {
const p = interactivePayload!;
if (p.kind === 'buttons') {
const result = await sendInteractiveButtons({
phoneNumberId: config.phone_number_id,
accessToken,
to: phone,
bodyText: p.body,
headerText: p.header || undefined,
footerText: p.footer || undefined,
buttons: p.buttons,
contextMessageId,
});
return result.messageId;
}
const result = await sendInteractiveList({
phoneNumberId: config.phone_number_id,
accessToken,
to: phone,
bodyText: p.body,
buttonLabel: p.button_label,
headerText: p.header || undefined,
footerText: p.footer || undefined,
sections: p.sections,
contextMessageId,
});
return result.messageId;
}
const result = await sendTextMessage({ const result = await sendTextMessage({
phoneNumberId: config.phone_number_id, phoneNumberId: config.phone_number_id,
accessToken, accessToken,
@ -386,15 +442,23 @@ export async function sendMessageToConversation(
// Persist the sent message. Field names MUST match the messages // Persist the sent message. Field names MUST match the messages
// schema (see 001_initial_schema.sql). // schema (see 001_initial_schema.sql).
// Interactive messages persist the body as content_text (so the
// conversation-list preview reads sensibly) plus the full structured
// payload so the thread can re-render the buttons / rows.
const interactiveBody =
messageType === 'interactive' ? interactivePayload!.body : null;
const { data: messageRecord, error: msgError } = await db const { data: messageRecord, error: msgError } = await db
.from('messages') .from('messages')
.insert({ .insert({
conversation_id: conversationId, conversation_id: conversationId,
sender_type: 'agent', sender_type: 'agent',
content_type: messageType, content_type: messageType,
content_text: contentText || null, content_text: interactiveBody ?? contentText ?? null,
media_url: mediaUrl || null, media_url: mediaUrl || null,
template_name: templateName || null, template_name: templateName || null,
interactive_payload:
messageType === 'interactive' ? interactivePayload : null,
message_id: waMessageId, message_id: waMessageId,
status: 'sent', status: 'sent',
reply_to_message_id: replyToMessageId || null, reply_to_message_id: replyToMessageId || null,
@ -411,10 +475,15 @@ export async function sendMessageToConversation(
); );
} }
const lastMessageText =
messageType === 'interactive'
? interactivePayloadPreviewText(interactivePayload!)
: contentText || `[${messageType}]`;
await db await db
.from('conversations') .from('conversations')
.update({ .update({
last_message_text: contentText || `[${messageType}]`, last_message_text: lastMessageText,
last_message_at: new Date().toISOString(), last_message_at: new Date().toISOString(),
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),
}) })

View File

@ -1,4 +1,14 @@
import type { AccountRole } from "@/lib/auth/roles"; import type { AccountRole } from "@/lib/auth/roles";
import type { InteractiveMessagePayload } from "@/lib/whatsapp/interactive";
export type {
InteractiveMessagePayload,
InteractiveButtonsPayload,
InteractiveListPayload,
InteractiveButton,
InteractiveListRow,
InteractiveListSection,
} from "@/lib/whatsapp/interactive";
export interface Profile { export interface Profile {
id: string; id: string;
@ -228,6 +238,13 @@ export interface Message {
* cue (renders with a "↩ button reply" affordance). * cue (renders with a "↩ button reply" affordance).
*/ */
interactive_reply_id?: string; interactive_reply_id?: string;
/**
* Structured payload of an OUTBOUND interactive message (reply
* buttons or list) we sent. Lets the thread re-render the buttons /
* rows, not just the body text. Only set when `content_type ===
* 'interactive'` and `sender_type` is agent/bot. Migration 035.
*/
interactive_payload?: InteractiveMessagePayload;
/** /**
* True when the AI auto-reply bot generated + sent this message (as * True when the AI auto-reply bot generated + sent this message (as
* opposed to a human agent or a deterministic Flow/automation send, * opposed to a human agent or a deterministic Flow/automation send,
@ -419,10 +436,15 @@ export type AutomationTriggerType =
| 'new_contact_created' | 'new_contact_created'
| 'conversation_assigned' | 'conversation_assigned'
| 'tag_added' | 'tag_added'
| 'time_based'; | 'time_based'
/** Customer tapped a reply button / list row whose id matches; lets
* multi-step menus be chained across automations. */
| 'interactive_reply';
export type AutomationStepType = export type AutomationStepType =
| 'send_message' | 'send_message'
| 'send_buttons'
| 'send_list'
| 'send_template' | 'send_template'
| 'add_tag' | 'add_tag'
| 'remove_tag' | 'remove_tag'
@ -452,17 +474,31 @@ export interface TimeBasedTriggerConfig {
timezone?: string; timezone?: string;
} }
export interface InteractiveReplyTriggerConfig {
/** Button / list-row ids to match, exact. Any one matching fires. */
reply_ids: string[];
}
export type AutomationTriggerConfig = export type AutomationTriggerConfig =
| Record<string, never> | Record<string, never>
| KeywordMatchTriggerConfig | KeywordMatchTriggerConfig
| TagTriggerConfig | TagTriggerConfig
| TimeBasedTriggerConfig | TimeBasedTriggerConfig
| InteractiveReplyTriggerConfig
| Record<string, unknown>; | Record<string, unknown>;
export interface SendMessageStepConfig { export interface SendMessageStepConfig {
text: string; text: string;
} }
/**
* `send_buttons` / `send_list` step configs carry the full interactive
* payload (same shape stored on messages + quick replies). `kind` is
* implied by the step_type but kept on the payload for a uniform shape.
*/
export type SendButtonsStepConfig = InteractiveMessagePayload;
export type SendListStepConfig = InteractiveMessagePayload;
export interface SendTemplateStepConfig { export interface SendTemplateStepConfig {
template_name: string; template_name: string;
language?: string; language?: string;
@ -525,6 +561,8 @@ export interface SendWebhookStepConfig {
export type AutomationStepConfig = export type AutomationStepConfig =
| SendMessageStepConfig | SendMessageStepConfig
| SendButtonsStepConfig
| SendListStepConfig
| SendTemplateStepConfig | SendTemplateStepConfig
| TagStepConfig | TagStepConfig
| AssignConversationStepConfig | AssignConversationStepConfig
@ -586,3 +624,25 @@ export interface AutomationLog {
created_at: string; created_at: string;
contact?: Contact; contact?: Contact;
} }
// ============================================================
// Quick replies — reusable snippets (migration 035)
// ============================================================
export type QuickReplyKind = 'text' | 'interactive';
export interface QuickReply {
id: string;
/** Account tenancy key — shared across all members of the account. */
account_id: string;
/** Author / audit only. */
user_id: string;
title: string;
kind: QuickReplyKind;
/** Set when `kind === 'text'`. */
content_text?: string | null;
/** Set when `kind === 'interactive'`. */
interactive_payload?: InteractiveMessagePayload | null;
created_at: string;
updated_at: string;
}

View File

@ -0,0 +1,61 @@
-- ============================================================
-- 035_interactive_messages.sql
--
-- Full support for WhatsApp interactive messages (reply buttons +
-- list messages) beyond the Flows subsystem.
--
-- 1. messages.interactive_payload — the structured payload of an
-- OUTBOUND interactive message (buttons / list) so it round-trips:
-- the thread can re-render the buttons/rows we sent, not just the
-- body text. Migration 010 already added 'interactive' to the
-- content_type CHECK and the inbound `interactive_reply_id`
-- column, so no CHECK change is needed here.
--
-- 2. quick_replies — reusable snippets (plain text OR a saved
-- interactive message) an agent can insert from the inbox
-- composer. Account-scoped, same tenancy model as automations.
-- ============================================================
-- 1. Outbound interactive payload -----------------------------
ALTER TABLE messages
ADD COLUMN IF NOT EXISTS interactive_payload JSONB;
-- 2. Quick replies --------------------------------------------
CREATE TABLE IF NOT EXISTS quick_replies (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- Tenancy. Every member of the account shares its quick replies.
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
-- Author / audit only — never used for tenancy isolation.
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
-- 'text' snippets carry `content_text`; 'interactive' snippets carry
-- `interactive_payload` (validated app-side against Meta's limits).
kind TEXT NOT NULL DEFAULT 'text' CHECK (kind IN ('text', 'interactive')),
content_text TEXT,
interactive_payload JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_quick_replies_account ON quick_replies(account_id);
ALTER TABLE quick_replies ENABLE ROW LEVEL SECURITY;
-- Account-scoped policies mirroring automations (see 017): any member
-- can read; agent+ can create / edit / delete.
DROP POLICY IF EXISTS quick_replies_select ON quick_replies;
DROP POLICY IF EXISTS quick_replies_insert ON quick_replies;
DROP POLICY IF EXISTS quick_replies_update ON quick_replies;
DROP POLICY IF EXISTS quick_replies_delete ON quick_replies;
CREATE POLICY quick_replies_select ON quick_replies FOR SELECT
USING (is_account_member(account_id));
CREATE POLICY quick_replies_insert ON quick_replies FOR INSERT
WITH CHECK (is_account_member(account_id, 'agent'));
CREATE POLICY quick_replies_update ON quick_replies FOR UPDATE
USING (is_account_member(account_id, 'agent'));
CREATE POLICY quick_replies_delete ON quick_replies FOR DELETE
USING (is_account_member(account_id, 'agent'));
DROP TRIGGER IF EXISTS set_updated_at ON quick_replies;
CREATE TRIGGER set_updated_at BEFORE UPDATE ON quick_replies
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();