Implementa sistema de chat em tempo real entre agente e cliente
- Adiciona tabela liveChatSessions no schema Convex - Cria convex/liveChat.ts com mutations e queries para chat - Adiciona API routes para maquinas (sessions, messages, poll) - Cria modulo chat.rs no Tauri com ChatRuntime e polling - Adiciona comandos de chat no lib.rs (start/stop polling, open/close window) - Cria componentes React do chat widget (ChatWidget, types) - Adiciona botao "Iniciar Chat" no dashboard (ticket-chat-panel) - Implementa menu de chat no system tray - Polling de 2 segundos para maior responsividade - Janela de chat flutuante, frameless, always-on-top 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
0c8d53c0b6
commit
ba91c1e0f5
15 changed files with 2004 additions and 15 deletions
126
src/app/api/machines/chat/messages/route.ts
Normal file
126
src/app/api/machines/chat/messages/route.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import { z } from "zod"
|
||||
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { createCorsPreflight, jsonWithCors } from "@/server/cors"
|
||||
import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client"
|
||||
|
||||
const getMessagesSchema = z.object({
|
||||
machineToken: z.string().min(1),
|
||||
ticketId: z.string().min(1),
|
||||
since: z.number().optional(),
|
||||
limit: z.number().optional(),
|
||||
})
|
||||
|
||||
const postMessageSchema = z.object({
|
||||
machineToken: z.string().min(1),
|
||||
ticketId: z.string().min(1),
|
||||
body: z.string().min(1).max(4000),
|
||||
attachments: z
|
||||
.array(
|
||||
z.object({
|
||||
storageId: z.string(),
|
||||
name: z.string(),
|
||||
size: z.number().optional(),
|
||||
type: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
|
||||
const CORS_METHODS = "POST, OPTIONS"
|
||||
|
||||
export async function OPTIONS(request: Request) {
|
||||
return createCorsPreflight(request.headers.get("origin"), CORS_METHODS)
|
||||
}
|
||||
|
||||
// POST /api/machines/chat/messages
|
||||
// action=list: Lista mensagens de um chat
|
||||
// action=send: Envia nova mensagem
|
||||
export async function POST(request: Request) {
|
||||
const origin = request.headers.get("origin")
|
||||
|
||||
let client
|
||||
try {
|
||||
client = createConvexClient()
|
||||
} catch (error) {
|
||||
if (error instanceof ConvexConfigurationError) {
|
||||
return jsonWithCors({ error: error.message }, 500, origin, CORS_METHODS)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
let raw
|
||||
try {
|
||||
raw = await request.json()
|
||||
} catch {
|
||||
return jsonWithCors({ error: "JSON invalido" }, 400, origin, CORS_METHODS)
|
||||
}
|
||||
|
||||
const action = raw.action ?? "list"
|
||||
|
||||
if (action === "list") {
|
||||
let payload
|
||||
try {
|
||||
payload = getMessagesSchema.parse(raw)
|
||||
} catch (error) {
|
||||
return jsonWithCors(
|
||||
{ error: "Payload invalido", details: error instanceof Error ? error.message : String(error) },
|
||||
400,
|
||||
origin,
|
||||
CORS_METHODS
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await client.query(api.liveChat.listMachineMessages, {
|
||||
machineToken: payload.machineToken,
|
||||
ticketId: payload.ticketId as Id<"tickets">,
|
||||
since: payload.since,
|
||||
limit: payload.limit,
|
||||
})
|
||||
return jsonWithCors(result, 200, origin, CORS_METHODS)
|
||||
} catch (error) {
|
||||
console.error("[machines.chat.messages] Falha ao listar mensagens", error)
|
||||
const details = error instanceof Error ? error.message : String(error)
|
||||
return jsonWithCors({ error: "Falha ao listar mensagens", details }, 500, origin, CORS_METHODS)
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "send") {
|
||||
let payload
|
||||
try {
|
||||
payload = postMessageSchema.parse(raw)
|
||||
} catch (error) {
|
||||
return jsonWithCors(
|
||||
{ error: "Payload invalido", details: error instanceof Error ? error.message : String(error) },
|
||||
400,
|
||||
origin,
|
||||
CORS_METHODS
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await client.mutation(api.liveChat.postMachineMessage, {
|
||||
machineToken: payload.machineToken,
|
||||
ticketId: payload.ticketId as Id<"tickets">,
|
||||
body: payload.body,
|
||||
attachments: payload.attachments as
|
||||
| Array<{
|
||||
storageId: Id<"_storage">
|
||||
name: string
|
||||
size?: number
|
||||
type?: string
|
||||
}>
|
||||
| undefined,
|
||||
})
|
||||
return jsonWithCors(result, 200, origin, CORS_METHODS)
|
||||
} catch (error) {
|
||||
console.error("[machines.chat.messages] Falha ao enviar mensagem", error)
|
||||
const details = error instanceof Error ? error.message : String(error)
|
||||
return jsonWithCors({ error: "Falha ao enviar mensagem", details }, 500, origin, CORS_METHODS)
|
||||
}
|
||||
}
|
||||
|
||||
return jsonWithCors({ error: "Acao invalida" }, 400, origin, CORS_METHODS)
|
||||
}
|
||||
57
src/app/api/machines/chat/poll/route.ts
Normal file
57
src/app/api/machines/chat/poll/route.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { z } from "zod"
|
||||
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { createCorsPreflight, jsonWithCors } from "@/server/cors"
|
||||
import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client"
|
||||
|
||||
const pollSchema = z.object({
|
||||
machineToken: z.string().min(1),
|
||||
lastCheckedAt: z.number().optional(),
|
||||
})
|
||||
|
||||
const CORS_METHODS = "POST, OPTIONS"
|
||||
|
||||
export async function OPTIONS(request: Request) {
|
||||
return createCorsPreflight(request.headers.get("origin"), CORS_METHODS)
|
||||
}
|
||||
|
||||
// POST /api/machines/chat/poll
|
||||
// Endpoint leve para polling de atualizacoes de chat
|
||||
export async function POST(request: Request) {
|
||||
const origin = request.headers.get("origin")
|
||||
|
||||
let client
|
||||
try {
|
||||
client = createConvexClient()
|
||||
} catch (error) {
|
||||
if (error instanceof ConvexConfigurationError) {
|
||||
return jsonWithCors({ error: error.message }, 500, origin, CORS_METHODS)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
let payload
|
||||
try {
|
||||
const raw = await request.json()
|
||||
payload = pollSchema.parse(raw)
|
||||
} catch (error) {
|
||||
return jsonWithCors(
|
||||
{ error: "Payload invalido", details: error instanceof Error ? error.message : String(error) },
|
||||
400,
|
||||
origin,
|
||||
CORS_METHODS
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await client.query(api.liveChat.checkMachineUpdates, {
|
||||
machineToken: payload.machineToken,
|
||||
lastCheckedAt: payload.lastCheckedAt,
|
||||
})
|
||||
return jsonWithCors(result, 200, origin, CORS_METHODS)
|
||||
} catch (error) {
|
||||
console.error("[machines.chat.poll] Falha ao verificar atualizacoes", error)
|
||||
const details = error instanceof Error ? error.message : String(error)
|
||||
return jsonWithCors({ error: "Falha ao verificar atualizacoes", details }, 500, origin, CORS_METHODS)
|
||||
}
|
||||
}
|
||||
55
src/app/api/machines/chat/sessions/route.ts
Normal file
55
src/app/api/machines/chat/sessions/route.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { z } from "zod"
|
||||
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { createCorsPreflight, jsonWithCors } from "@/server/cors"
|
||||
import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client"
|
||||
|
||||
const sessionsSchema = z.object({
|
||||
machineToken: z.string().min(1),
|
||||
})
|
||||
|
||||
const CORS_METHODS = "POST, OPTIONS"
|
||||
|
||||
export async function OPTIONS(request: Request) {
|
||||
return createCorsPreflight(request.headers.get("origin"), CORS_METHODS)
|
||||
}
|
||||
|
||||
// POST /api/machines/chat/sessions
|
||||
// Lista sessoes de chat ativas para a maquina
|
||||
export async function POST(request: Request) {
|
||||
const origin = request.headers.get("origin")
|
||||
|
||||
let client
|
||||
try {
|
||||
client = createConvexClient()
|
||||
} catch (error) {
|
||||
if (error instanceof ConvexConfigurationError) {
|
||||
return jsonWithCors({ error: error.message }, 500, origin, CORS_METHODS)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
let payload
|
||||
try {
|
||||
const raw = await request.json()
|
||||
payload = sessionsSchema.parse(raw)
|
||||
} catch (error) {
|
||||
return jsonWithCors(
|
||||
{ error: "Payload invalido", details: error instanceof Error ? error.message : String(error) },
|
||||
400,
|
||||
origin,
|
||||
CORS_METHODS
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const sessions = await client.query(api.liveChat.listMachineSessions, {
|
||||
machineToken: payload.machineToken,
|
||||
})
|
||||
return jsonWithCors({ sessions }, 200, origin, CORS_METHODS)
|
||||
} catch (error) {
|
||||
console.error("[machines.chat.sessions] Falha ao listar sessoes", error)
|
||||
const details = error instanceof Error ? error.message : String(error)
|
||||
return jsonWithCors({ error: "Falha ao listar sessoes", details }, 500, origin, CORS_METHODS)
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import { cn } from "@/lib/utils"
|
|||
import { toast } from "sonner"
|
||||
import { formatDistanceToNowStrict } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
import { MessageCircle, MonitorSmartphone, WifiOff, X } from "lucide-react"
|
||||
|
||||
const MAX_MESSAGE_LENGTH = 4000
|
||||
|
||||
|
|
@ -41,6 +42,18 @@ export function TicketChatPanel({ ticketId }: TicketChatPanelProps) {
|
|||
status: string
|
||||
canPost: boolean
|
||||
reopenDeadline: number | null
|
||||
liveChat: {
|
||||
hasMachine: boolean
|
||||
machineOnline: boolean
|
||||
machineHostname: string | null
|
||||
activeSession: {
|
||||
sessionId: Id<"liveChatSessions">
|
||||
agentId: Id<"users">
|
||||
agentName: string | null
|
||||
startedAt: number
|
||||
unreadByAgent: number
|
||||
} | null
|
||||
}
|
||||
messages: Array<{
|
||||
id: Id<"ticketChatMessages">
|
||||
body: string
|
||||
|
|
@ -58,9 +71,13 @@ export function TicketChatPanel({ ticketId }: TicketChatPanelProps) {
|
|||
|
||||
const markChatRead = useMutation(api.tickets.markChatRead)
|
||||
const postChatMessage = useMutation(api.tickets.postChatMessage)
|
||||
const startLiveChat = useMutation(api.liveChat.startSession)
|
||||
const endLiveChat = useMutation(api.liveChat.endSession)
|
||||
const messagesEndRef = useRef<HTMLDivElement | null>(null)
|
||||
const [draft, setDraft] = useState("")
|
||||
const [isSending, setIsSending] = useState(false)
|
||||
const [isStartingChat, setIsStartingChat] = useState(false)
|
||||
const [isEndingChat, setIsEndingChat] = useState(false)
|
||||
|
||||
const messages = chat?.messages ?? []
|
||||
const canPost = Boolean(chat?.canPost && viewerId)
|
||||
|
|
@ -96,10 +113,50 @@ export function TicketChatPanel({ ticketId }: TicketChatPanelProps) {
|
|||
return null
|
||||
}, [canPost, chatEnabled])
|
||||
|
||||
const handleStartLiveChat = async () => {
|
||||
if (!viewerId) return
|
||||
setIsStartingChat(true)
|
||||
toast.dismiss("live-chat")
|
||||
try {
|
||||
const result = await startLiveChat({
|
||||
ticketId: ticketId as Id<"tickets">,
|
||||
actorId: viewerId as Id<"users">,
|
||||
})
|
||||
if (result.isNew) {
|
||||
toast.success("Chat ao vivo iniciado! O cliente sera notificado.", { id: "live-chat" })
|
||||
} else {
|
||||
toast.info("Ja existe uma sessao de chat ativa.", { id: "live-chat" })
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Nao foi possivel iniciar o chat"
|
||||
toast.error(message, { id: "live-chat" })
|
||||
} finally {
|
||||
setIsStartingChat(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEndLiveChat = async () => {
|
||||
if (!viewerId || !chat?.liveChat?.activeSession) return
|
||||
setIsEndingChat(true)
|
||||
toast.dismiss("live-chat")
|
||||
try {
|
||||
await endLiveChat({
|
||||
sessionId: chat.liveChat.activeSession.sessionId,
|
||||
actorId: viewerId as Id<"users">,
|
||||
})
|
||||
toast.success("Chat ao vivo encerrado.", { id: "live-chat" })
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Nao foi possivel encerrar o chat"
|
||||
toast.error(message, { id: "live-chat" })
|
||||
} finally {
|
||||
setIsEndingChat(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!viewerId || !canPost || draft.trim().length === 0) return
|
||||
if (draft.length > MAX_MESSAGE_LENGTH) {
|
||||
toast.error(`Mensagem muito longa (máx. ${MAX_MESSAGE_LENGTH} caracteres).`)
|
||||
toast.error(`Mensagem muito longa (max. ${MAX_MESSAGE_LENGTH} caracteres).`)
|
||||
return
|
||||
}
|
||||
setIsSending(true)
|
||||
|
|
@ -115,7 +172,7 @@ export function TicketChatPanel({ ticketId }: TicketChatPanelProps) {
|
|||
toast.success("Mensagem enviada!", { id: "ticket-chat" })
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error("Não foi possível enviar a mensagem.", { id: "ticket-chat" })
|
||||
toast.error("Nao foi possivel enviar a mensagem.", { id: "ticket-chat" })
|
||||
} finally {
|
||||
setIsSending(false)
|
||||
}
|
||||
|
|
@ -125,15 +182,85 @@ export function TicketChatPanel({ ticketId }: TicketChatPanelProps) {
|
|||
return null
|
||||
}
|
||||
|
||||
const liveChat = chat?.liveChat
|
||||
const hasActiveSession = Boolean(liveChat?.activeSession)
|
||||
|
||||
return (
|
||||
<Card className="border-slate-200">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2">
|
||||
<CardTitle className="text-base font-semibold text-neutral-800">Chat do atendimento</CardTitle>
|
||||
{!chatEnabled ? (
|
||||
<span className="text-xs font-medium text-neutral-500">Chat desativado</span>
|
||||
) : null}
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-base font-semibold text-neutral-800">Chat do atendimento</CardTitle>
|
||||
{liveChat?.hasMachine && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{liveChat.machineOnline ? (
|
||||
<span className="flex items-center gap-1 rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">
|
||||
<span className="size-1.5 rounded-full bg-green-500" />
|
||||
Online
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 rounded-full bg-neutral-100 px-2 py-0.5 text-xs font-medium text-neutral-500">
|
||||
<WifiOff className="size-3" />
|
||||
Offline
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!chatEnabled ? (
|
||||
<span className="text-xs font-medium text-neutral-500">Chat desativado</span>
|
||||
) : null}
|
||||
{liveChat?.hasMachine && (
|
||||
<>
|
||||
{hasActiveSession ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleEndLiveChat}
|
||||
disabled={isEndingChat}
|
||||
className="gap-1.5 border-red-200 text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||
>
|
||||
{isEndingChat ? <Spinner className="size-3" /> : <X className="size-3" />}
|
||||
Encerrar Chat
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleStartLiveChat}
|
||||
disabled={isStartingChat || !liveChat.machineOnline}
|
||||
className="gap-1.5"
|
||||
title={!liveChat.machineOnline ? "A maquina precisa estar online para iniciar o chat" : undefined}
|
||||
>
|
||||
{isStartingChat ? <Spinner className="size-3" /> : <MessageCircle className="size-3" />}
|
||||
Iniciar Chat
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{hasActiveSession && liveChat?.activeSession && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-green-200 bg-green-50 px-3 py-2">
|
||||
<div className="flex size-6 items-center justify-center rounded-full bg-green-100">
|
||||
<MessageCircle className="size-3.5 text-green-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-green-800">Chat ao vivo ativo</p>
|
||||
<p className="text-xs text-green-600">
|
||||
Iniciado por {liveChat.activeSession.agentName ?? "agente"} {formatRelative(liveChat.activeSession.startedAt)}
|
||||
{liveChat.activeSession.unreadByAgent > 0 && (
|
||||
<span className="ml-2 rounded-full bg-green-600 px-1.5 py-0.5 text-white">
|
||||
{liveChat.activeSession.unreadByAgent} nao lidas
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{chat === undefined ? (
|
||||
<div className="flex items-center justify-center gap-2 rounded-lg border border-dashed border-slate-200 py-6 text-sm text-neutral-500">
|
||||
<Spinner className="size-4" /> Carregando mensagens...
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue