Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
55 changes: 55 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,61 @@ S3_REGION=us-west-2
# Leave unset to use AWS S3.
S3_ENDPOINT=http://localhost:9000

# ─────────────────────────────────────────────
# In-app WhatsApp calling — VoIP (WebRTC) + TURN
# ─────────────────────────────────────────────
# Calling runs over VoIP (browser WebRTC ↔ Meta) with Meta-native call
# recording/transcription. See docs/whatsapp-calling-voip.md.

# Per-workspace-minute limiter for opt-in call transcription (BullMQ limiter).
CALL_TRANSCRIBE_PER_MIN=10

# TURN (coturn) — needed for real calls; leave unset only for UI work.
#
# WHY LOCALHOST CANNOT WORK: TURN is a media RELAY. Both the agent's browser
# AND Meta's media servers have to send RTP to it, so it must be reachable
# from the public internet. A coturn on 127.0.0.1, or on a laptop behind a
# home router, makes the browser advertise a private relay address that Meta
# can never reach — and an HTTP tunnel (ngrok / cloudflared) does not help,
# because TURN needs UDP. There is no localhost value that makes this work.
#
# WHAT HAPPENS WITHOUT IT: signalling still succeeds, so the call connects
# and the UI shows a live call — then Meta gets no media and terminates it
# with `status: FAILED` and error 138021/138022/138023. The call is stored as
# failed and the conversation shows "Missed voice call" instead of the audio
# card. That is the single most common cause of "calls connect but nobody
# hears anything".
#
# Leaving these unset is fine while working on the calling UI: the app falls
# back to public STUN, which does connect media on NAT-friendly networks
# (same LAN, or a permissive router). It is not reliable anywhere else, and
# `voipTurnCredentialService` logs a warning on every call so it is never a
# silent downgrade.
#
# HOW TO RUN ONE, on a host that has a public IP (a VPS — not your laptop):
# 1. In docker/coturn/turnserver.conf set `external-ip=<that host's public IP>`.
# 2. Open on that host's firewall: 3478/udp+tcp and 49152-65535/udp
# (also 5349/udp+tcp if you serve TURN over TLS).
# 3. Generate a secret and put the SAME value in this file and in coturn:
# openssl rand -hex 32
# 4. Start it (the service sits behind a compose profile so local dev never
# boots it by accident):
# docker compose --profile production up -d coturn
#
# Then fill these in — TURN_URL points at that public host, never localhost:
# TURN_STATIC_SECRET=<output of `openssl rand -hex 32`>
# TURN_URL=turn:turn.example.com:3478
# TURN_REALM=chatbotx.local
#
# TURN-over-TLS (port 5349) additionally needs wss.pem/wss.key in this
# directory; plain 3478 works without them.
# TURN_CERTS_DIR=./.data/certs
#
# NOTE: only coturn's REST/HMAC scheme (`use-auth-secret`) is supported —
# credentials are minted per call from TURN_STATIC_SECRET and scoped to
# `<userId>:<wacid>`, so they cannot be replayed. A hosted TURN that hands out
# a fixed username/password cannot be configured here without a code change.

# ─────────────────────────────────────────────
# SMTP / Email
# ─────────────────────────────────────────────
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,6 @@ CLAUDE.md

.pnpm-store/
.plans/

# Local runtime data bind mounts, e.g. TURN certificates (docker-compose.yml)
.data/
176 changes: 176 additions & 0 deletions apps/builder/__tests__/call-recording-activity.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import type { MessageWhatsappCallRecordingEntity } from "@chatbotx.io/sdk"
import { act } from "react"
import { createRoot, type Root } from "react-dom/client"
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"
import type { AttachmentResource } from "@/features/attachments/schema/resource"

/** Echoes the key back so assertions never depend on the English copy. */
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}))

vi.mock("@/hooks/routing", () => ({
useWorkspaceId: () => "ws-1",
}))

vi.mock("@/features/attachments/utils", () => ({
useAttachmentUrl: (attachment: AttachmentResource | undefined) =>
attachment?.url ?? undefined,
}))

const { getCallRecordingUrlActionMock } = vi.hoisted(() => ({
getCallRecordingUrlActionMock: vi.fn(),
}))

vi.mock("@/features/messages/actions/get-call-recording-url.action", () => ({
getCallRecordingUrlAction: getCallRecordingUrlActionMock,
}))

const { CallRecordingActivity } = await import(
"@/features/messages/components/call-recording-activity"
)

let container: HTMLDivElement | null = null
let root: Root | null = null

function renderComponent(ui: React.ReactElement) {
container = document.createElement("div")
document.body.appendChild(container)
root = createRoot(container)
act(() => {
root?.render(ui)
})
return container
}

afterEach(() => {
if (root) {
act(() => {
root?.unmount()
})
}
container?.remove()
container = null
root = null
})

const attachment = {
id: "att-1",
fileType: "audio",
mimeType: "audio/ogg",
url: "https://signed.example/initial",
originPath: "space/ws-1/calls/call-1.ogg",
name: null,
} as unknown as AttachmentResource

const recording: MessageWhatsappCallRecordingEntity = {
type: "whatsapp_call_recording",
callId: "call-1",
}

describe("CallRecordingActivity", () => {
beforeEach(() => {
vi.clearAllMocks()
getCallRecordingUrlActionMock.mockResolvedValue({
data: { url: "https://signed.example/refreshed" },
})
})

test("renders nothing when there is no attachment", () => {
const el = renderComponent(
<CallRecordingActivity attachment={undefined} recording={recording} />,
)
expect(el.querySelector("audio")).toBeNull()
})

test("renders the audio player with the initial attachment URL", () => {
const el = renderComponent(
<CallRecordingActivity attachment={attachment} recording={recording} />,
)
const audio = el.querySelector("audio")
expect(audio).not.toBeNull()
expect(audio?.getAttribute("src")).toBe("https://signed.example/initial")
})

test("hides the transcript block when no transcript is present", () => {
const el = renderComponent(
<CallRecordingActivity attachment={attachment} recording={recording} />,
)
expect(el.textContent).not.toContain("showTranscript")
})

test("shows a collapsible transcript trigger when a transcript is present", () => {
const el = renderComponent(
<CallRecordingActivity
attachment={attachment}
recording={{ ...recording, transcript: "hello from the call" }}
/>,
)
expect(el.textContent).toContain("recordingActivity.showTranscript")
expect(el.textContent).not.toContain("hello from the call")
})

test("expands the transcript text on trigger click", () => {
const el = renderComponent(
<CallRecordingActivity
attachment={attachment}
recording={{ ...recording, transcript: "hello from the call" }}
/>,
)
const trigger = el.querySelector('[data-slot="collapsible-trigger"]')
expect(trigger).not.toBeNull()
act(() => {
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }))
})
expect(el.textContent).toContain("hello from the call")
expect(el.textContent).toContain("recordingActivity.hideTranscript")
})

test("play does NOT trigger a URL refetch — swapping src mid-playback would abort it", async () => {
const el = renderComponent(
<CallRecordingActivity attachment={attachment} recording={recording} />,
)
const audio = el.querySelector("audio") as HTMLAudioElement

await act(async () => {
audio.dispatchEvent(new Event("play"))
await Promise.resolve()
})

expect(getCallRecordingUrlActionMock).not.toHaveBeenCalled()
expect(audio.getAttribute("src")).toBe("https://signed.example/initial")
})

test("error triggers exactly one refetch that updates src", async () => {
const el = renderComponent(
<CallRecordingActivity attachment={attachment} recording={recording} />,
)
const audio = el.querySelector("audio") as HTMLAudioElement

await act(async () => {
audio.dispatchEvent(new Event("error"))
await Promise.resolve()
})

expect(getCallRecordingUrlActionMock).toHaveBeenCalledTimes(1)
expect(getCallRecordingUrlActionMock).toHaveBeenCalledWith("ws-1", {
whatsappCallId: "call-1",
})
expect(audio.getAttribute("src")).toBe("https://signed.example/refreshed")
})

test("a failed refresh does not throw and leaves the player usable", async () => {
getCallRecordingUrlActionMock.mockRejectedValueOnce(new Error("boom"))
const el = renderComponent(
<CallRecordingActivity attachment={attachment} recording={recording} />,
)
const audio = el.querySelector("audio") as HTMLAudioElement

await act(async () => {
audio.dispatchEvent(new Event("error"))
await Promise.resolve()
})

expect(el.querySelector("audio")).not.toBeNull()
})
})
33 changes: 32 additions & 1 deletion apps/builder/__tests__/chat-layout-mobile.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { setViewportWidth } from "@chatbotx.io/vitest-config/setup-dom"
import { act } from "react"
import { act, type ReactNode } from "react"
import { createRoot, type Root } from "react-dom/client"
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"

Expand All @@ -11,6 +11,37 @@ vi.mock("@/features/chat/chat-realtime", () => ({
ChatRealtime: () => <div data-testid="realtime" />,
}))

vi.mock(
"@/features/integration-whatsapp/calling/voip/whatsapp-call-panel",
() => ({
WhatsappCallPanel: () => <div data-testid="voip-call-dock" />,
}),
)

vi.mock(
"@/features/integration-whatsapp/calling/voip/whatsapp-voip-call-context",
() => ({
WhatsappVoipCallProvider: ({ children }: { children: ReactNode }) =>
children,
useWhatsappVoipCallContext: () => ({
answer: vi.fn(),
dismiss: vi.fn(),
hangup: vi.fn(),
toggleMute: vi.fn(),
dismissEnded: vi.fn(),
}),
}),
)

vi.mock(
"@/features/integration-whatsapp/calling/voip/use-whatsapp-voip-presence",
() => ({ useWhatsappVoipPresence: () => undefined }),
)

vi.mock("@/features/messages/components/whatsapp-call-info-sheet", () => ({
WhatsappCallInfoSheet: () => <div data-testid="call-info-sheet" />,
}))

const mockRouterReplace = vi.fn()

vi.mock("next/navigation", () => ({
Expand Down
Loading
Loading