feat: edição de rotas de entrada, fix de 2 bugs reais no ESL, diagnóstico de NAT/áudio e softphone WebRTC (PHASE 65/66)
Três achados reportados pelo usuário numa mensagem só: (1) Rotas de Entrada não tinha edição depois de criada — implementada no mesmo padrão de Filas; (2) telas de Platform > Infraestrutura sempre davam "Timeout no ESL" nesta VM — não era limitação permanente como o comentário antigo dizia, e sim ESL_HOST=freeswitch (nome DNS que só existe dentro da rede do Docker) mais um segundo bug independente (`show gateways as json` não é comando válido nesta versão do FreeSWITCH); (3) ramal externo registrava mas sem áudio — diagnosticado com contadores de pacote do iptables: a VM está atrás de um roteador sem port-forward pra faixa de RTP, achado de infraestrutura de rede, não bug de código. Também integra o softphone WebRTC (handphone.js/OpenSIPS, já em produção): código-fonte encontrado em git.falehandix.com.br/Handix/handphone-2.0, patch mínimo pra aceitar o endereço do proxy em runtime (era build-time), nova config global (Platform > Infraestrutura > Softphone WebRTC) e widget na topbar do tenant que pega usuário/domínio/senha do ramal vinculado ao agente logado. Adiciona docs/QA_SETUP.md — runbook completo pra subir o ambiente do zero numa máquina nova (Docker, migrations, seed, systemd), e completa o .env.example que estava faltando a maioria das variáveis reais. Testado ponta a ponta com Playwright: edição de rota (criar/editar/F5), as 3 telas de Infraestrutura com dado real, e um tenant/ramal/agente de teste criados na hora confirmando que o script do softphone recebe as credenciais certas. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
This commit is contained in:
@@ -10,11 +10,13 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { getPrismaClient, withTenantContext, type Prisma } from "@b2bcall/database";
|
||||
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
|
||||
import { decryptSecret } from "@b2bcall/shared";
|
||||
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
import { PauseDto } from "./dto/pause.dto";
|
||||
import { notifyAgentChanged, notifyTierChanged } from "./agent-sync.helper";
|
||||
import { publishAgentStateChanged } from "../realtime/realtime-publish.helper";
|
||||
import { WEBRTC_PROXY_SETTING_KEY } from "../platform/platform-webrtc-proxy.controller";
|
||||
|
||||
async function findMyAgent(tx: Prisma.TransactionClient, tenantId: string, userId: string) {
|
||||
const agent = await tx.agent.findFirst({
|
||||
@@ -58,6 +60,52 @@ export class AgentsMeController {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Credenciais SIP + endereço do proxy WebRTC pro softphone embutido
|
||||
* (Handphone, PHASE 66) — sempre o ramal do PRÓPRIO agente, nunca um
|
||||
* agentId/extensionId arbitrário do client (mesmo principio de
|
||||
* `findMyAgent`). Diferente de `POST /extensions/:id/reveal-password`
|
||||
* (que exige `extensions.manage`, permissão que um agente comum nunca
|
||||
* tem): aqui não há permissão nenhuma além de "sou um agente logado
|
||||
* com ramal vinculado" — é o próprio agente pegando a própria senha
|
||||
* pra usar no softphone, não uma ação administrativa sobre o ramal de
|
||||
* outra pessoa. Cada acesso fica no audit log (mesma lógica de
|
||||
* `revealPassword`: decifrar de novo é sensível mesmo sem trocar nada).
|
||||
*/
|
||||
@Get("softphone-config")
|
||||
async softphoneConfig(@CurrentUser() user: AccessTokenClaims) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
const agent = await withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.agent.findFirst({ where: { tenantId, userId: user.sub, deletedAt: null }, include: { extension: true } }),
|
||||
);
|
||||
if (!agent) {
|
||||
throw new NotFoundException("Nenhum agente vinculado a este usuario neste tenant");
|
||||
}
|
||||
if (!agent.extension) {
|
||||
return { hasExtension: false as const };
|
||||
}
|
||||
|
||||
const setting = await prisma.platformSetting.findUnique({ where: { key: WEBRTC_PROXY_SETTING_KEY } });
|
||||
|
||||
await recordAuditEvent(prisma, {
|
||||
action: "AGENT_SOFTPHONE_CONFIG_ACCESSED",
|
||||
tenantId,
|
||||
userId: user.sub,
|
||||
entityType: "extension",
|
||||
entityId: agent.extension.id,
|
||||
});
|
||||
|
||||
return {
|
||||
hasExtension: true as const,
|
||||
username: agent.extension.number,
|
||||
domain: agent.extension.domain,
|
||||
password: decryptSecret(agent.extension.sipPasswordEnc),
|
||||
displayName: agent.name,
|
||||
proxyUrl: setting?.value ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Motivos de pausa pro próprio agente escolher — sem exigir
|
||||
* `agents.view` (que listaria TODOS os agentes do tenant, permissão
|
||||
* que o role "agent" nunca precisou ter até aqui). */
|
||||
|
||||
6
apps/api/src/platform/dto/update-webrtc-proxy.dto.ts
Normal file
6
apps/api/src/platform/dto/update-webrtc-proxy.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { IsUrl } from "class-validator";
|
||||
|
||||
export class UpdateWebrtcProxyDto {
|
||||
@IsUrl({ protocols: ["ws", "wss"], require_protocol: true, require_tld: false })
|
||||
url!: string;
|
||||
}
|
||||
57
apps/api/src/platform/platform-webrtc-proxy.controller.ts
Normal file
57
apps/api/src/platform/platform-webrtc-proxy.controller.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Body, Controller, ForbiddenException, Get, Put, UseGuards } from "@nestjs/common";
|
||||
import { getPrismaClient } from "@b2bcall/database";
|
||||
import { recordAuditEvent, isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth";
|
||||
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
||||
import { PermissionGuard } from "../common/guards/permission.guard";
|
||||
import { RequirePermission } from "../common/decorators/require-permission.decorator";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
import { UpdateWebrtcProxyDto } from "./dto/update-webrtc-proxy.dto";
|
||||
|
||||
export const WEBRTC_PROXY_SETTING_KEY = "webrtc_proxy_url";
|
||||
|
||||
/**
|
||||
* "Sistema > Softphone WebRTC" — endereço WSS do proxy OpenSIPS que faz a
|
||||
* ponte WebRTC↔SIP pro widget Handphone embutido no app do tenant (PHASE
|
||||
* 66, ver docs/SOFTPHONE.md). Config global (não por tenant): um único
|
||||
* OpenSIPS atende todos os tenants, cada ramal continua puro SIP — o
|
||||
* FreeSWITCH deste projeto nunca fala WebRTC diretamente.
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@Controller("platform/webrtc-proxy")
|
||||
export class PlatformWebrtcProxyController {
|
||||
@RequirePermission("freeswitch.view")
|
||||
@Get()
|
||||
async get(@CurrentUser() user: AccessTokenClaims) {
|
||||
if (!(await isPlatformUser(user.sub))) {
|
||||
throw new ForbiddenException("So' um usuario com role de plataforma pode ver esta configuracao");
|
||||
}
|
||||
const prisma = getPrismaClient();
|
||||
const setting = await prisma.platformSetting.findUnique({ where: { key: WEBRTC_PROXY_SETTING_KEY } });
|
||||
return { url: setting?.value ?? null };
|
||||
}
|
||||
|
||||
@RequirePermission("freeswitch.configure")
|
||||
@Put()
|
||||
async update(@CurrentUser() user: AccessTokenClaims, @Body() dto: UpdateWebrtcProxyDto) {
|
||||
if (!(await isPlatformUser(user.sub))) {
|
||||
throw new ForbiddenException("So' um usuario com role de plataforma pode editar esta configuracao");
|
||||
}
|
||||
const prisma = getPrismaClient();
|
||||
const setting = await prisma.platformSetting.upsert({
|
||||
where: { key: WEBRTC_PROXY_SETTING_KEY },
|
||||
create: { key: WEBRTC_PROXY_SETTING_KEY, value: dto.url, updatedBy: user.sub },
|
||||
update: { value: dto.url, updatedBy: user.sub },
|
||||
});
|
||||
|
||||
await recordAuditEvent(prisma, {
|
||||
action: "PLATFORM_WEBRTC_PROXY_UPDATE",
|
||||
tenantId: null,
|
||||
userId: user.sub,
|
||||
entityType: "platform_setting",
|
||||
entityId: WEBRTC_PROXY_SETTING_KEY,
|
||||
after: { url: dto.url },
|
||||
});
|
||||
|
||||
return { url: setting.value };
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { PlatformQuotasController } from "./platform-quotas.controller";
|
||||
import { PlatformFreeswitchController } from "./platform-freeswitch.controller";
|
||||
import { PlatformAiUsageController } from "./platform-ai-usage.controller";
|
||||
import { PlatformSystemConfigController } from "./platform-system-config.controller";
|
||||
import { PlatformWebrtcProxyController } from "./platform-webrtc-proxy.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [
|
||||
@@ -20,6 +21,7 @@ import { PlatformSystemConfigController } from "./platform-system-config.control
|
||||
PlatformFreeswitchController,
|
||||
PlatformAiUsageController,
|
||||
PlatformSystemConfigController,
|
||||
PlatformWebrtcProxyController,
|
||||
],
|
||||
})
|
||||
export class PlatformModule {}
|
||||
|
||||
97
apps/frontend/public/handphone.js
Normal file
97
apps/frontend/public/handphone.js
Normal file
File diff suppressed because one or more lines are too long
@@ -43,6 +43,30 @@ export async function createInboundRoute(
|
||||
}
|
||||
}
|
||||
|
||||
export interface UpdateInboundRouteInput {
|
||||
description?: string;
|
||||
destinationType: InboundRouteDestinationType;
|
||||
destinationContext?: string;
|
||||
destinationNumber: string;
|
||||
}
|
||||
|
||||
export async function updateInboundRoute(
|
||||
id: string,
|
||||
input: UpdateInboundRouteInput,
|
||||
): Promise<{ ok: true; route: InboundRoute } | { ok: false; error: string }> {
|
||||
const session = await requireSession();
|
||||
try {
|
||||
const route = await apiFetch<InboundRoute>(`/inbound-routes/${id}`, session.accessToken, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
revalidatePath("/app/telefonia/rotas-entrada");
|
||||
return { ok: true, route };
|
||||
} catch (err) {
|
||||
return { ok: false, error: extractErrorMessage(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteInboundRoute(id: string): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const session = await requireSession();
|
||||
try {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { Fragment, useMemo, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { PhoneIncoming, Plus, Trash2, X } from "lucide-react";
|
||||
import { Pencil, PhoneIncoming, Plus, Trash2, X } from "lucide-react";
|
||||
import { Panel, PanelHeader } from "@/components/ui/panel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input, Select, FieldLabel } from "@/components/ui/input";
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
type Queue,
|
||||
} from "@/lib/callcenter-types";
|
||||
import type { Extension } from "@/lib/extension-types";
|
||||
import { createInboundRoute, deleteInboundRoute } from "./actions";
|
||||
import { createInboundRoute, deleteInboundRoute, updateInboundRoute } from "./actions";
|
||||
|
||||
function destinationLabel(
|
||||
route: InboundRoute,
|
||||
@@ -59,6 +59,7 @@ export function RotasEntradaView({
|
||||
queues: Queue[];
|
||||
}) {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
|
||||
const extensionsByNumber = useMemo(() => Object.fromEntries(extensions.map((e) => [e.number, e])), [extensions]);
|
||||
const ivrMenusByContext = useMemo(() => Object.fromEntries(ivrMenus.map((m) => [m.context, m])), [ivrMenus]);
|
||||
@@ -80,14 +81,20 @@ export function RotasEntradaView({
|
||||
tenant.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" onClick={() => setShowForm((s) => !s)}>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingId(null);
|
||||
setShowForm((s) => !s);
|
||||
}}
|
||||
>
|
||||
{showForm ? <X className="h-4 w-4" aria-hidden /> : <Plus className="h-4 w-4" aria-hidden />}
|
||||
{showForm ? "Cancelar" : "Nova rota"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<NewInboundRouteForm extensions={extensions} ivrMenus={ivrMenus} queues={queues} callGroups={callGroups} onDone={() => setShowForm(false)} />
|
||||
<InboundRouteForm extensions={extensions} ivrMenus={ivrMenus} queues={queues} callGroups={callGroups} onDone={() => setShowForm(false)} />
|
||||
)}
|
||||
|
||||
<Panel>
|
||||
@@ -110,23 +117,53 @@ export function RotasEntradaView({
|
||||
</THead>
|
||||
<TBody>
|
||||
{routes.map((r) => (
|
||||
<TR key={r.id}>
|
||||
<TD>
|
||||
<span className="flex items-center gap-2 font-mono font-medium text-foreground">
|
||||
<PhoneIncoming className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
|
||||
{r.didNumber}
|
||||
</span>
|
||||
</TD>
|
||||
<TD className="text-muted-foreground">{r.description ?? "—"}</TD>
|
||||
<TD className="text-muted-foreground">{destinationLabel(r, extensionsByNumber, ivrMenusByContext, queuesById)}</TD>
|
||||
<TD>
|
||||
<Pill tone={r.enabled ? "accent" : "neutral"}>{r.enabled ? "Ativa" : "Desativada"}</Pill>
|
||||
</TD>
|
||||
<TD className="text-muted-foreground">{formatDate(r.createdAt)}</TD>
|
||||
<TD>
|
||||
<DeleteInboundRouteButton routeId={r.id} didNumber={r.didNumber} />
|
||||
</TD>
|
||||
</TR>
|
||||
<Fragment key={r.id}>
|
||||
<TR>
|
||||
<TD>
|
||||
<span className="flex items-center gap-2 font-mono font-medium text-foreground">
|
||||
<PhoneIncoming className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
|
||||
{r.didNumber}
|
||||
</span>
|
||||
</TD>
|
||||
<TD className="text-muted-foreground">{r.description ?? "—"}</TD>
|
||||
<TD className="text-muted-foreground">{destinationLabel(r, extensionsByNumber, ivrMenusByContext, queuesById)}</TD>
|
||||
<TD>
|
||||
<Pill tone={r.enabled ? "accent" : "neutral"}>{r.enabled ? "Ativa" : "Desativada"}</Pill>
|
||||
</TD>
|
||||
<TD className="text-muted-foreground">{formatDate(r.createdAt)}</TD>
|
||||
<TD>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowForm(false);
|
||||
setEditingId((id) => (id === r.id ? null : r.id));
|
||||
}}
|
||||
aria-label={`Editar rota ${r.didNumber}`}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" aria-hidden />
|
||||
</Button>
|
||||
<DeleteInboundRouteButton routeId={r.id} didNumber={r.didNumber} />
|
||||
</div>
|
||||
</TD>
|
||||
</TR>
|
||||
{editingId === r.id && (
|
||||
<tr>
|
||||
<td colSpan={6} className="bg-muted/30 p-4">
|
||||
<InboundRouteForm
|
||||
route={r}
|
||||
extensions={extensions}
|
||||
ivrMenus={ivrMenus}
|
||||
queues={queues}
|
||||
callGroups={callGroups}
|
||||
onDone={() => setEditingId(null)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
@@ -136,23 +173,37 @@ export function RotasEntradaView({
|
||||
);
|
||||
}
|
||||
|
||||
function NewInboundRouteForm({
|
||||
/** Deriva o valor inicial do dropdown "Destino" a partir de uma rota já
|
||||
* salva — o único caso que precisa de tradução é IVR: o banco guarda
|
||||
* `destinationContext` (o context do menu), mas o `<select>` usa o id
|
||||
* do menu como valor. */
|
||||
function initialDestinationValue(route: InboundRoute, ivrMenus: IvrMenu[]): string {
|
||||
if (route.destinationType === "IVR") {
|
||||
return ivrMenus.find((m) => m.context === route.destinationContext)?.id ?? "";
|
||||
}
|
||||
return route.destinationNumber;
|
||||
}
|
||||
|
||||
function InboundRouteForm({
|
||||
route,
|
||||
extensions,
|
||||
ivrMenus,
|
||||
queues,
|
||||
callGroups,
|
||||
onDone,
|
||||
}: {
|
||||
route?: InboundRoute;
|
||||
extensions: Extension[];
|
||||
ivrMenus: IvrMenu[];
|
||||
queues: Queue[];
|
||||
callGroups: string[];
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [didNumber, setDidNumber] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [destinationType, setDestinationType] = useState<InboundRouteDestinationType>("EXTENSION");
|
||||
const [destinationValue, setDestinationValue] = useState("");
|
||||
const isEditing = !!route;
|
||||
const [didNumber, setDidNumber] = useState(route?.didNumber ?? "");
|
||||
const [description, setDescription] = useState(route?.description ?? "");
|
||||
const [destinationType, setDestinationType] = useState<InboundRouteDestinationType>(route?.destinationType ?? "EXTENSION");
|
||||
const [destinationValue, setDestinationValue] = useState(route ? initialDestinationValue(route, ivrMenus) : "");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
@@ -186,13 +237,20 @@ function NewInboundRouteForm({
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await createInboundRoute({
|
||||
didNumber: didNumber.trim(),
|
||||
description: description.trim() || undefined,
|
||||
destinationType,
|
||||
destinationNumber,
|
||||
destinationContext,
|
||||
});
|
||||
const result = isEditing
|
||||
? await updateInboundRoute(route.id, {
|
||||
description: description.trim() || undefined,
|
||||
destinationType,
|
||||
destinationNumber,
|
||||
destinationContext,
|
||||
})
|
||||
: await createInboundRoute({
|
||||
didNumber: didNumber.trim(),
|
||||
description: description.trim() || undefined,
|
||||
destinationType,
|
||||
destinationNumber,
|
||||
destinationContext,
|
||||
});
|
||||
if (!result.ok) {
|
||||
setError(result.error);
|
||||
return;
|
||||
@@ -212,8 +270,9 @@ function NewInboundRouteForm({
|
||||
value={didNumber}
|
||||
onChange={(e) => setDidNumber(e.target.value)}
|
||||
placeholder="Ex.: 551140028922"
|
||||
disabled={pending}
|
||||
disabled={pending || isEditing}
|
||||
/>
|
||||
{isEditing && <p className="mt-1 text-xs text-muted-foreground">O número (DID) não pode ser trocado depois de criado.</p>}
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel htmlFor="ir-description">Descrição (opcional)</FieldLabel>
|
||||
@@ -292,9 +351,14 @@ function NewInboundRouteForm({
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<div className="flex justify-end gap-2">
|
||||
{isEditing && (
|
||||
<Button type="button" variant="ghost" onClick={onDone} disabled={pending}>
|
||||
Cancelar
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "Criando…" : "Criar rota"}
|
||||
{pending ? "Salvando…" : isEditing ? "Salvar alterações" : "Criar rota"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -9,12 +9,11 @@ export function EslUnavailableNotice({ error }: { error: string }) {
|
||||
Erro retornado: <span className="font-mono text-xs text-destructive">{error}</span>
|
||||
</p>
|
||||
<p>
|
||||
<code className="font-mono text-xs">apps/api</code> roda direto no host desta VM, fora do Docker; a porta
|
||||
do Event Socket (8021) do FreeSWITCH é deliberadamente <strong>não publicada no host</strong> (agente.md
|
||||
secao 184: porta sensível, nunca exposta). Por isso este endpoint sempre falha aqui, mesmo com o
|
||||
FreeSWITCH saudável — o container está isolado do jeito certo. Em produção, onde{" "}
|
||||
<code className="font-mono text-xs">apps/api</code> roda na mesma rede Docker do FreeSWITCH, esta tela
|
||||
mostra os dados reais.
|
||||
<code className="font-mono text-xs">apps/api</code> roda direto no host desta VM, fora do Docker, e fala
|
||||
com o Event Socket (8021) do FreeSWITCH via <code className="font-mono text-xs">127.0.0.1</code> — a porta
|
||||
é publicada só em loopback (agente.md secao 184: nunca pra rede), então normalmente esta tela mostra dados
|
||||
reais. Se este erro aparecer, o mais provável é o container do FreeSWITCH estar fora do ar ou reiniciando
|
||||
— confira <code className="font-mono text-xs">docker ps</code>/<code className="font-mono text-xs">docker logs b2bcall-freeswitch</code> antes de mais nada.
|
||||
</p>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireSession } from "@/lib/session";
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
import type { WebrtcProxyConfig } from "@/lib/platform-types";
|
||||
|
||||
function extractErrorMessage(err: unknown): string {
|
||||
if (err instanceof ApiError) {
|
||||
try {
|
||||
const parsed = JSON.parse(err.message);
|
||||
if (Array.isArray(parsed.message)) return parsed.message.join(" ");
|
||||
if (typeof parsed.message === "string") return parsed.message;
|
||||
} catch {
|
||||
// corpo não era JSON
|
||||
}
|
||||
return err.message || "Falha inesperada na API.";
|
||||
}
|
||||
return "Falha inesperada. Tente novamente.";
|
||||
}
|
||||
|
||||
export async function updateWebrtcProxy(
|
||||
url: string,
|
||||
): Promise<{ ok: true; config: WebrtcProxyConfig } | { ok: false; error: string }> {
|
||||
const session = await requireSession();
|
||||
try {
|
||||
const config = await apiFetch<WebrtcProxyConfig>("/platform/webrtc-proxy", session.accessToken, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
revalidatePath("/platform/infraestrutura/softphone");
|
||||
return { ok: true, config };
|
||||
} catch (err) {
|
||||
return { ok: false, error: extractErrorMessage(err) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { requireSession } from "@/lib/session";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import type { WebrtcProxyConfig } from "@/lib/platform-types";
|
||||
import { SoftphoneSettingsView } from "./softphone-settings-view";
|
||||
|
||||
export default async function InfraestruturaSoftphonePage() {
|
||||
const session = await requireSession();
|
||||
const config = await apiFetch<WebrtcProxyConfig>("/platform/webrtc-proxy", session.accessToken);
|
||||
return <SoftphoneSettingsView config={config} />;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { Panel, PanelHeader } from "@/components/ui/panel";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { WebrtcProxyConfig } from "@/lib/platform-types";
|
||||
import { updateWebrtcProxy } from "./actions";
|
||||
|
||||
export function SoftphoneSettingsView({ config }: { config: WebrtcProxyConfig }) {
|
||||
const [url, setUrl] = useState(config.url ?? "");
|
||||
const [saved, setSaved] = useState(config.url);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await updateWebrtcProxy(url.trim());
|
||||
if (!result.ok) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
setSaved(result.config.url);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-foreground">Softphone WebRTC</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||
Endereço do proxy OpenSIPS que faz a ponte WebRTC↔SIP pro softphone embutido no app do tenant. O FreeSWITCH
|
||||
desta instalação nunca fala WebRTC — cada ramal continua um registro SIP puro, o navegador do agente é quem
|
||||
conecta via WebRTC no OpenSIPS, que repassa como SIP comum de volta pra este servidor.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Panel className="p-5">
|
||||
<PanelHeader title="Endereço do proxy (WSS)" description="Ex.: wss://wss.proxysip.exemplo.com.br:4443" />
|
||||
<form onSubmit={onSubmit} className="mt-3 flex items-end gap-3">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="wss://proxy.exemplo.com:4443"
|
||||
disabled={pending}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "Salvando…" : "Salvar"}
|
||||
</Button>
|
||||
</form>
|
||||
{error && <p className="mt-2 text-xs text-destructive">{error}</p>}
|
||||
{!error && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{saved ? `Configurado: ${saved}` : "Ainda não configurado — o widget do softphone não conecta até um endereço ser salvo aqui."}
|
||||
</p>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -104,6 +104,12 @@ export const PLATFORM_NAV: NavSection[] = [
|
||||
description: "Postgres, Redis e FreeSWITCH — verificação ao vivo",
|
||||
permission: "freeswitch.view",
|
||||
},
|
||||
{
|
||||
label: "Softphone WebRTC",
|
||||
href: "/platform/infraestrutura/softphone",
|
||||
description: "Endereço do proxy OpenSIPS pro softphone embutido no app do tenant",
|
||||
permission: "freeswitch.configure",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -49,3 +49,23 @@ export async function agentResume(): Promise<ActionResult> {
|
||||
export async function agentPause(pauseReasonId: string): Promise<ActionResult> {
|
||||
return callAgentMe("/pause", { pauseReasonId });
|
||||
}
|
||||
|
||||
export type SoftphoneConfig =
|
||||
| { hasExtension: false }
|
||||
| { hasExtension: true; username: string; domain: string; password: string; displayName: string; proxyUrl: string | null };
|
||||
|
||||
/**
|
||||
* Sob demanda, não no layout (agente.md secao 39/178: decifrar senha é
|
||||
* sensível e fica no audit log — buscar isto em toda navegação encheria o
|
||||
* log à toa). Chamado uma vez pelo próprio widget do softphone quando ele
|
||||
* monta, só pra quem já tem um Agent com ramal vinculado.
|
||||
*/
|
||||
export async function getSoftphoneConfig(): Promise<{ ok: true; config: SoftphoneConfig } | { ok: false; error: string }> {
|
||||
const session = await requireSession();
|
||||
try {
|
||||
const config = await apiFetch<SoftphoneConfig>("/agents/me/softphone-config", session.accessToken);
|
||||
return { ok: true, config };
|
||||
} catch (err) {
|
||||
return { ok: false, error: extractErrorMessage(err) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { getSoftphoneConfig } from "./agent-me-actions";
|
||||
|
||||
const SCRIPT_ID = "handphone-widget-script";
|
||||
|
||||
/**
|
||||
* Softphone WebRTC embutido (Handphone, PHASE 66) — o FreeSWITCH deste
|
||||
* projeto nunca fala WebRTC: quem faz a ponte WebRTC↔SIP é um OpenSIPS
|
||||
* externo já em produção, configurado pelo Platform Super Admin em
|
||||
* Sistema > Softphone WebRTC. O ramal continua puro SIP; o widget só
|
||||
* pega username/domain/senha do ramal vinculado ao agente e o endereço
|
||||
* do proxy, e se autoconecta via `data-sip-*` (ver dist/handphone.js e
|
||||
* docs/SOFTPHONE.md) — nenhum estado de ACD (Disponível/Pausa/Offline)
|
||||
* interfere aqui, é o mesmo tipo de registro SIP que um telefone físico
|
||||
* faria.
|
||||
*/
|
||||
export function SoftphoneWidget({ hasExtension }: { hasExtension: boolean }) {
|
||||
const loadedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasExtension || loadedRef.current) return;
|
||||
if (document.getElementById(SCRIPT_ID)) return;
|
||||
loadedRef.current = true;
|
||||
|
||||
getSoftphoneConfig().then((result) => {
|
||||
if (!result.ok || !result.config.hasExtension || !result.config.proxyUrl) {
|
||||
if (result.ok && result.config.hasExtension && !result.config.proxyUrl) {
|
||||
console.warn("[Softphone] proxy WebRTC nao configurado (Platform > Sistema > Softphone WebRTC)");
|
||||
}
|
||||
return;
|
||||
}
|
||||
const { username, domain, password, proxyUrl } = result.config;
|
||||
const script = document.createElement("script");
|
||||
script.id = SCRIPT_ID;
|
||||
script.src = "/handphone.js";
|
||||
script.dataset.sipUser = username;
|
||||
script.dataset.sipDomain = domain;
|
||||
script.dataset.sipPassword = password;
|
||||
script.dataset.sipServer = proxyUrl;
|
||||
script.dataset.position = "bottom-right";
|
||||
document.body.appendChild(script);
|
||||
});
|
||||
}, [hasExtension]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { filterNavByPermissions } from "@/components/shell/nav-types";
|
||||
import type { MyAgent, PauseReason } from "@/lib/callcenter-types";
|
||||
import { TENANT_NAV } from "./nav-data";
|
||||
import { AgentStatusWidget } from "./agent-status-widget";
|
||||
import { SoftphoneWidget } from "./softphone-widget";
|
||||
|
||||
/** Ver comentário em `platform-shell/platform-sidebar.tsx` — mesma razão. */
|
||||
export function TenantTopbar({
|
||||
@@ -25,7 +26,12 @@ export function TenantTopbar({
|
||||
items={filterNavByPermissions(TENANT_NAV, permissionKeys)}
|
||||
fallbackTitle={fallbackTitle}
|
||||
userLabel={userLabel}
|
||||
rightExtra={<AgentStatusWidget initialAgent={myAgent} pauseReasons={pauseReasons} />}
|
||||
rightExtra={
|
||||
<>
|
||||
<AgentStatusWidget initialAgent={myAgent} pauseReasons={pauseReasons} />
|
||||
<SoftphoneWidget hasExtension={myAgent?.hasExtension ?? false} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,6 +112,10 @@ export interface SystemConfig {
|
||||
corsOrigin: string | null;
|
||||
}
|
||||
|
||||
export interface WebrtcProxyConfig {
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
export interface TenantAiUsage {
|
||||
tenantId: string;
|
||||
legalName: string;
|
||||
|
||||
Reference in New Issue
Block a user