mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Merge pull request #358 from ArnasDon/feat/whatsapp-interactive-messages
feat: WhatsApp interactive messages (reply buttons + lists)
This commit is contained in:
commit
137760e8f8
@ -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",
|
||||||
|
|||||||
@ -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 />,
|
||||||
|
|||||||
103
src/app/api/quick-replies/[id]/route.ts
Normal file
103
src/app/api/quick-replies/[id]/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
80
src/app/api/quick-replies/route.ts
Normal file
80
src/app/api/quick-replies/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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":
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)}
|
||||||
|
|||||||
104
src/components/inbox/quick-reply-picker.tsx
Normal file
104
src/components/inbox/quick-reply-picker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
468
src/components/interactive/interactive-builder.tsx
Normal file
468
src/components/interactive/interactive-builder.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
src/components/interactive/interactive-preview.tsx
Normal file
75
src/components/interactive/interactive-preview.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
265
src/components/settings/quick-replies-manager.tsx
Normal file
265
src/components/settings/quick-replies-manager.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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' },
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -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()
|
||||||
@ -602,6 +628,21 @@ function triggerMatches(automation: Automation, ctx: AutomationContext | undefin
|
|||||||
const k = cfg.case_sensitive ? raw : raw.toLowerCase()
|
const k = cfg.case_sensitive ? raw : raw.toLowerCase()
|
||||||
return cfg.match_type === 'exact' ? haystack === k : haystack.includes(k)
|
return cfg.match_type === 'exact' ? haystack === k : haystack.includes(k)
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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> {
|
||||||
|
|||||||
@ -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' })
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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([]);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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',
|
||||||
})
|
})
|
||||||
|
|||||||
142
src/lib/whatsapp/interactive.test.ts
Normal file
142
src/lib/whatsapp/interactive.test.ts
Normal 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]')
|
||||||
|
})
|
||||||
|
})
|
||||||
239
src/lib/whatsapp/interactive.ts
Normal file
239
src/lib/whatsapp/interactive.ts
Normal 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]'
|
||||||
|
}
|
||||||
@ -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(
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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(),
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
61
supabase/migrations/035_interactive_messages.sql
Normal file
61
supabase/migrations/035_interactive_messages.sql
Normal 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();
|
||||||
Loading…
Reference in New Issue
Block a user