Add embeddable chat widget for websites
Reuses the existing portal-token/SSE mechanism (customers.portalToken, /api/portal/events) for anonymous widget visitors instead of building a parallel auth system. Public API: session bootstrap + messages endpoint, both scoped by a non-secret siteKey with optional origin allowlist. Embeddable script (public/widget.js) is a small self-contained vanilla JS file: floating button + iframe kept mounted for a live SSE connection, with a postMessage bridge for the unread badge. Admin UI at Settings -> Виджет manages sites and shows the embed snippet. Also generalized recordTelegramInboundMessage -> recordChatInboundMessage (channel param) since Telegram and the widget need the identical find-or-create-open-ticket heuristic. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QcXH24ky6zjk2UyK5oZUPH
This commit is contained in:
1 parent
59e6c32d79
commit
4efce5f307
19 files changed
+1267
-14
No files matched your search
@@ -0,0 +1,71 @@
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
getWidgetSiteByKey,
|
||||
getCustomerByPortalToken,
|
||||
renameCustomerIfGuest,
|
||||
recordChatInboundMessage,
|
||||
} from "@/lib/tickets/service";
|
||||
|
||||
// Simple in-memory per-visitor rate limit — this is a public, unauthenticated
|
||||
// endpoint. Good enough at MVP scale; resets on restart.
|
||||
const attempts = new Map<string, { count: number; resetAt: number }>();
|
||||
const MAX_ATTEMPTS = 20;
|
||||
const WINDOW_MS = 5 * 60 * 1000;
|
||||
|
||||
function isRateLimited(key: string): boolean {
|
||||
const now = Date.now();
|
||||
const entry = attempts.get(key);
|
||||
if (!entry || entry.resetAt < now) {
|
||||
attempts.set(key, { count: 1, resetAt: now + WINDOW_MS });
|
||||
return false;
|
||||
}
|
||||
entry.count += 1;
|
||||
return entry.count > MAX_ATTEMPTS;
|
||||
}
|
||||
|
||||
const schema = z.object({
|
||||
siteKey: z.string().min(1),
|
||||
token: z.string().min(1),
|
||||
body: z.string().min(1).max(4_000),
|
||||
name: z.string().max(100).optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = schema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (isRateLimited(parsed.data.token)) {
|
||||
return NextResponse.json({ error: "Слишком много сообщений — подождите немного" }, { status: 429 });
|
||||
}
|
||||
|
||||
const site = await getWidgetSiteByKey(parsed.data.siteKey);
|
||||
if (!site || !site.enabled) {
|
||||
return NextResponse.json({ error: "Unknown or disabled widget" }, { status: 404 });
|
||||
}
|
||||
|
||||
const customer = await getCustomerByPortalToken(parsed.data.token);
|
||||
if (!customer) {
|
||||
return NextResponse.json({ error: "Invalid token" }, { status: 404 });
|
||||
}
|
||||
|
||||
const name = parsed.data.name?.trim();
|
||||
if (name) {
|
||||
await renameCustomerIfGuest(customer.id, name);
|
||||
}
|
||||
|
||||
const result = await recordChatInboundMessage({
|
||||
customerId: customer.id,
|
||||
channel: "widget",
|
||||
body: parsed.data.body,
|
||||
authorName: name || customer.displayName,
|
||||
subjectForNewTicket: `Чат (${site.name}): ${parsed.data.body.slice(0, 60)}`,
|
||||
});
|
||||
|
||||
return NextResponse.json(result, { status: 201 });
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
getWidgetSiteByKey,
|
||||
getCustomerByPortalToken,
|
||||
createGuestCustomer,
|
||||
listTicketsForCustomer,
|
||||
getTicketForCustomer,
|
||||
} from "@/lib/tickets/service";
|
||||
|
||||
const schema = z.object({
|
||||
siteKey: z.string().min(1),
|
||||
token: z.string().optional(),
|
||||
});
|
||||
|
||||
function originAllowed(request: Request, allowedOrigin: string | null): boolean {
|
||||
if (!allowedOrigin) return true;
|
||||
return request.headers.get("origin") === allowedOrigin;
|
||||
}
|
||||
|
||||
/** Bootstraps (or resumes) a widget visitor's session and their current conversation, in one call. */
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = schema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||
}
|
||||
|
||||
const site = await getWidgetSiteByKey(parsed.data.siteKey);
|
||||
if (!site || !site.enabled) {
|
||||
return NextResponse.json({ error: "Unknown or disabled widget" }, { status: 404 });
|
||||
}
|
||||
if (!originAllowed(request, site.allowedOrigin)) {
|
||||
return NextResponse.json({ error: "Origin not allowed" }, { status: 403 });
|
||||
}
|
||||
|
||||
const customer =
|
||||
(parsed.data.token ? await getCustomerByPortalToken(parsed.data.token) : null) ??
|
||||
(await createGuestCustomer());
|
||||
|
||||
const tickets = await listTicketsForCustomer(customer.id);
|
||||
const openTicket = tickets.find((t) => t.status !== "closed") ?? null;
|
||||
const result = openTicket ? await getTicketForCustomer(customer.id, openTicket.id) : null;
|
||||
|
||||
return NextResponse.json({
|
||||
token: customer.portalToken,
|
||||
ticket: result?.ticket ?? null,
|
||||
messages: result?.messages ?? [],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requireSession } from "@/lib/auth/require";
|
||||
import { listWidgetSites, createWidgetSite, setWidgetSiteEnabled } from "@/lib/tickets/service";
|
||||
|
||||
export async function GET() {
|
||||
const { session, response } = await requireSession();
|
||||
if (!session) return response;
|
||||
|
||||
return NextResponse.json({ sites: await listWidgetSites() });
|
||||
}
|
||||
|
||||
const createSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
allowedOrigin: z.string().url().optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { session, response } = await requireSession();
|
||||
if (!session) return response;
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = createSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||
}
|
||||
|
||||
const site = await createWidgetSite(parsed.data.name, parsed.data.allowedOrigin);
|
||||
return NextResponse.json({ site }, { status: 201 });
|
||||
}
|
||||
|
||||
const patchSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
const { session, response } = await requireSession();
|
||||
if (!session) return response;
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = patchSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||
}
|
||||
|
||||
const site = await setWidgetSiteEnabled(parsed.data.id, parsed.data.enabled);
|
||||
return NextResponse.json({ site });
|
||||
}
|
||||
Reference in new issue
Block a user