# WebRTC 통합 (/docs/clients/webrtc)

## WebRTC 통합

브라우저에서 마이크 권한만 받으면 한국AI통신 번호로 발신할 수 있는 click-to-call 위젯을 구현합니다.

한국AI통신의 WebRTC 는 표준 **SIP-over-WebSocket** (RFC 7118) 위에서 동작하므로,
**JsSIP** 등 검증된 SIP UA 라이브러리를 그대로 사용할 수 있습니다.

이 페이지는 두 부분으로 나뉩니다:

1. [**구현**](#구현) — 우선 동작하게 만드는 최소 흐름(발신)
2. [**착신 받기**](#착신-받기) — 브라우저로 전화를 **받는** 경우
3. [**보안**](#보안) — 운영 환경에 올리기 전에 반드시 추가할 것들

**부가서비스 활성화 필요.** WebRTC 통화는 **SIP 트렁크 연결 부가서비스** 입니다. [대시보드 → 부가서비스](https://platform.claw-ops.com/) 에서 활성화 후 진행하세요.

## 아키텍처

```mermaid
sequenceDiagram
  autonumber
  participant B as Browser (JsSIP UA)
  participant S as Your Server
  participant C as 한국AI통신 API
  participant G as 한국AI통신 SIP gateway
  B->>S: POST /your-token-api
  S->>C: POST /sip-credentials/:id/tokens
  C-->>S: token, ws_url, realm, ...
  S->>C: GET /ice-servers
  C-->>S: ice_servers
  S-->>B: wsUri, sipUsername, callerId, iceServers
  B->>G: WSS handshake (SIP-over-WebSocket)
  B->>G: SIP INVITE (destination at realm)
  G-->>B: 100 / 180 / 200 OK + SDP
  Note over B,G: RTP (DTLS-SRTP) - 한국AI통신 내부 라우팅 - PSTN/on-net
```

---

## 구현

여기까지만 따라하면 **로컬에서 발신이 동작합니다.** 운영 환경 보안은 다음 [보안](#보안) 섹션에서 추가합니다.

### 1단계: SipCredential 발급

대시보드에서 WebRTC 용 SipCredential 을 1회 만듭니다.

**대시보드 → SIP Credentials**
[platform.claw-ops.com](https://platform.claw-ops.com/) 의 **SIP Credentials** 메뉴로 이동합니다.

**새 자격 만들기**
| 필드 | 값 |
| --- | --- |
| `allowed_numbers` | 발신자 번호로 허용할 070 번호 목록 |
| `realm` | `<계정 ID 소문자>.sip.claw-ops.com` |

**ID 기록**
생성된 `credential_id` (예: `SC_xxxxx`) 와 계정 ID 를 메모합니다. 다음 단계에서 사용합니다.

### 2단계: 서버측 토큰 발급 API

브라우저가 한국AI통신 API Key 를 직접 호출하면 안 됩니다. 자체 서버에 엔드포인트 1개를 두고 **API Key 를 서버 환경변수로 가립니다.**

서버가 하는 일은 두 가지뿐입니다:

1. 한국AI통신에 단기 SIP 토큰을 요청 (`POST /sip-credentials/:id/tokens`)
2. ICE/TURN 서버 정보를 요청 (`GET /ice-servers`)

```ts
// app/api/webrtc-call/token/route.ts (Next.js)
const API_BASE = process.env.CLAWOPS_API_BASE!;          // https://api.claw-ops.com
const ACCOUNT_ID = process.env.CLAWOPS_ACCOUNT_ID!;
const CREDENTIAL_ID = process.env.CLAWOPS_WEBRTC_CREDENTIAL_ID!;
const API_KEY = process.env.CLAWOPS_API_KEY!;

export async function POST() {
  const headers = { Authorization: `Bearer ${API_KEY}` };

  // 1. SIP 단기 토큰 발급 (예: TTL 7분 — 생략하면 1시간, 최대 24시간)
  const tokenRes = await fetch(
    `${API_BASE}/v1/accounts/${ACCOUNT_ID}/sip-credentials/${CREDENTIAL_ID}/tokens`,
    {
      method: 'POST',
      headers: { ...headers, 'content-type': 'application/json' },
      body: JSON.stringify({ ttl_seconds: 420 }),
    },
  );
  const { token } = await tokenRes.json();

  // 2. ICE/TURN 서버 정보
  const iceRes = await fetch(`${API_BASE}/v1/accounts/${ACCOUNT_ID}/ice-servers`, { headers });
  const { ice_servers } = await iceRes.json();

  // 3. 브라우저로 그대로 전달
  return Response.json({ token, iceServers: ice_servers });
}
```


**왜 토큰을 그대로 넘겨주나요?**

토큰은 JWT 입니다. 안에 `sip_username` / `sip_password` / `ws_url` / `realm` 등 SIP 인증에 필요한 값이 들어 있습니다. 어차피 브라우저가 SIP 등록할 때 다 쓰는 값이라 숨길 의미가 없습니다. **서버가 가려야 할 건 "API Key" 하나뿐** 이고, 토큰 자체는 짧게 만료되는 1회용이라 노출돼도 무해합니다 (Twilio Voice JS / Vonage Client SDK 와 동일 패턴).

### 3단계: 브라우저 — JsSIP UA

```bash
npm install jssip
```

브라우저는 다음 흐름을 따릅니다:

1. 위에서 만든 `/api/webrtc-call/token` 호출
2. 받은 JWT 의 payload 를 디코드해서 SIP 자격(`sip_username`, `sip_password`, `ws_url`, `realm`) 추출
3. JsSIP UA 만들고 발신

```ts
import { UA, WebSocketInterface } from 'jssip';

// JWT payload 디코드 — 서명 검증은 한국AI통신 게이트웨이가 함
function decodeJwtPayload(jwt: string) {
  const base64 = jwt.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
  return JSON.parse(atob(base64));
}

export async function call(destination: string) {
  // 1. 서버에서 토큰 + ICE 받기
  const res = await fetch('/api/webrtc-call/token', { method: 'POST' });
  const { token, iceServers } = await res.json();

  // 2. JWT payload 에서 SIP 자격 추출
  const claims = decodeJwtPayload(token);
  const wsUri        = claims.ws_url;             // wss://sip.claw-ops.com:5063
  const realm        = claims.realm;              // <random>.sip.claw-ops.com
  const sipUsername  = claims.sip_username;       // ephem_xxxxx (1회용)
  const sipPassword  = claims.sip_password;
  const callerId     = claims.allowed_caller_ids[0];  // 발신자로 표시될 070 번호

  // 3. JsSIP UA 만들기
  const socket = new WebSocketInterface(wsUri);
  const ua = new UA({
    sockets: [socket],
    uri: `sip:${sipUsername}@${realm}`,
    password: sipPassword,
    // 발신 전용이라 등록하지 않습니다.
    // ⚠️ 브라우저로 전화를 **받으려면** register: true 여야 합니다 → [착신 받기](#착신-받기)
    register: false,
  });
  ua.start();

  // 4. 발신
  const session = ua.call(`sip:${destination}@${realm}`, {
    mediaConstraints: { audio: true, video: false },
    pcConfig: { iceServers },
    // From URI 발신번호 (자세한 이유는 아래 박스)
    fromUserName: callerId,
  });

  // 5. 통화 연결 후 상대 음성 재생
  session.on('confirmed', () => {
    const audioEl = document.querySelector<HTMLAudioElement>('#remote-audio')!;
    const tracks = session.connection.getReceivers().map(r => r.track).filter(Boolean);
    audioEl.srcObject = new MediaStream(tracks);
  });

  return {
    hangup: () => { session.terminate(); ua.stop(); },
  };
}
```

페이지 어딘가에 오디오 엘리먼트를 두고:

```html
<audio id="remote-audio" autoplay />
<button onclick="call('07012345678')">전화하기</button>
```


**왜 `fromUserName` 이 필요한가**

JsSIP 는 기본적으로 From URI user 부분에 UA 의 `uri` 값(`ephem_xxx`) 을 그대로 씁니다.
하지만 한국AI통신 게이트웨이는 발신번호 형식을 `^\+?[0-9]{1,20}$` 로 강제합니다 (위변조 방지).

`fromUserName: callerId` 옵션으로 From URI user 를 전화번호 형식으로 덮어씁니다.
인증 자체는 SIP digest username 에 의존하므로 영향 없음.

여기까지가 동작하는 최소 구성입니다. 그러나 **운영 환경에 그대로 올리면 안 됩니다.** 다음 보안 섹션을 적용하세요.

---

## 착신 받기

위 예제는 **발신 전용**입니다. 브라우저로 전화를 **받으려면** 두 가지가 더 필요합니다.

1. UA 를 `register: true` 로 만들어 **SIP 등록**한다 (등록해야 착신 대상이 됩니다)
2. 번호로 걸려 온 전화가 그 브라우저로 가도록 **라우팅**한다 — 소프트폰 라우팅(방법 A) 또는 웹훅의 `<Dial><Sip>`(방법 B)

**`register: false` 로는 전화를 받을 수 없습니다.** 위 click-to-call 예제를 그대로 쓰면
등록이 안 되므로, 인바운드 통화가 이 브라우저를 찾지 못합니다.

### 1단계: 등록

```ts
const ua = new UA({
  sockets: [new WebSocketInterface(claims.ws_url)],
  uri: `sip:${claims.sip_username}@${claims.realm}`,
  password: claims.sip_password,
  register: true,          // ← 착신 필수
});

ua.on('newRTCSession', ({ session, originator }) => {
  if (originator !== 'remote') return;   // 착신만 처리

  // 상담원이 수락하면
  session.answer({
    mediaConstraints: { audio: true, video: false },
    pcConfig: { iceServers },
  });

  session.on('confirmed', () => {
    const audioEl = document.querySelector<HTMLAudioElement>('#remote-audio')!;
    const tracks = session.connection.getReceivers().map(r => r.track).filter(Boolean);
    audioEl.srcObject = new MediaStream(tracks);
  });
});

ua.start();
```

### 2단계: 번호 → 브라우저 라우팅

#### 방법 A: 소프트폰 라우팅

대시보드에서 번호의 라우팅을 **소프트폰(등록 단말 착신)** 으로 두고 SIP 단말을 지정합니다.
그 SIP 단말에서 발급한 토큰으로 등록한 브라우저가 모두 동시에 울립니다. 웹훅은 필요 없습니다.

- 1단계의 `uri` 를 바꿀 필요는 없습니다.
- 함께 울리는 토큰은 그 SIP 단말에서 최근 발급한 9개까지입니다.

#### 방법 B: 웹훅에서 `<Dial><Sip>`

전화번호의 웹훅이 반환하는 VoiceML 에서 등록된 브라우저의 SIP URI 를 지정합니다.
`sip_uri` 는 토큰 발급 응답에 그대로 들어 있습니다.

```xml
<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Dial timeout="45">
    <Sip>sip:ephem_xxxxx@your-account.sip.claw-ops.com</Sip>
    <Sip>sip:ephem_yyyyy@your-account.sip.claw-ops.com</Sip>
  </Dial>
</Response>
```

토큰을 새로 발급하면 `sip_uri` 도 바뀝니다. 웹훅은 통화가 걸려 온 시점에 등록되어 있는 토큰의 `sip_uri` 를 반환해야 합니다.

### 여러 상담원 동시 호출

두 가지 방식이 있고 **둘 다 동시에 울린 뒤, 먼저 받은 쪽만 연결되고 나머지는 자동 취소**됩니다.

| 방식 | 구성 |
|---|---|
| **`<Sip>` 여러 개** | 상담원마다 다른 토큰을 쓸 때. 위 예시처럼 나열 |
| **같은 SIP identity 에 여러 등록** | 한 계정을 여러 기기가 공유할 때. 동일 AoR 당 **최대 5개** |

상담원이 유동적인 컨택센터라면, 현재 접속 중인 상담원의 `sip_uri` 목록을 웹훅에서 동적으로
구성하는 방식을 권장합니다.

토큰이 만료되면 다음 재등록(REGISTER 갱신)이 거절되어 그때부터 전화를 받지 못합니다. 진행 중인 통화는
끊기지 않습니다. 상담원 세션이 토큰 TTL 보다 길다면 **만료 전 재발급 후 재등록** 하세요.

---

## 보안

운영 환경 click-to-call 은 비용 폭주 / 발신번호 위조 / 토큰 도용 위험에 노출됩니다.
아래 5가지를 모두 적용하세요.

### S1. API Key 노출 금지

- API Key 는 **서버 환경변수에만** 둡니다 (`process.env.CLAWOPS_API_KEY`).
- `NEXT_PUBLIC_*` / `VITE_*` 등 클라이언트 번들에 들어가는 변수에 절대 넣지 마세요.
- 클라이언트는 *본인의 서버* 에만 요청하고, 본인 서버가 한국AI통신 API 를 호출합니다 (위 [2단계](#2단계-서버측-토큰-발급-api) 참고).

### S2. 도착 번호(destination) 는 서버가 결정

```diff
- // ❌ 클라이언트가 보낸 destination 을 그대로 사용
- const { destination } = await req.json();

+ // ✅ 서버가 세션 컨텍스트로부터 결정
+ const destination = await resolveDestinationForUser(req);  // 예: 사용자 ID → 담당자 번호
```

클라이언트가 destination 을 결정하면 토큰 탈취 시 임의 번호로 발신 가능 → **요금 폭탄.**
사용자별 1:1 콜센터라면 세션 사용자 ID → 담당자 번호로 매핑.
범용 데모(우리 콜센터로만 발신) 라면 환경변수 1개로 박아두기.

### S3. 봇 방어 (Cloudflare Turnstile)

토큰 발급 엔드포인트가 인증 없이 열려있다면 봇이 무한 발신 가능. **Turnstile invisible challenge** 를 페이지 로드 시점에 미리 실행해서 클릭 시점에 토큰 준비 완료 상태로 둡니다.

```tsx
// lib/turnstile-context.tsx — 페이지 로드 시 토큰 사전 발급
'use client';
import { createContext, useContext, useEffect, useRef, useState } from 'react';

const SITEKEY = process.env.NEXT_PUBLIC_TURNSTILE_SITEKEY!;
const Ctx = createContext<{ token: string | null; consume: () => void }>({
  token: null, consume: () => {},
});
export const useTurnstile = () => useContext(Ctx);

export function TurnstileProvider({ children }: { children: React.ReactNode }) {
  const [token, setToken] = useState<string | null>(null);
  const widgetIdRef = useRef<string | null>(null);
  const containerRef = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    if (!document.getElementById('cf-turnstile-script')) {
      const s = document.createElement('script');
      s.id = 'cf-turnstile-script';
      s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js';
      s.async = true;
      document.head.appendChild(s);
    }
    const t = setInterval(() => {
      if (!window.turnstile || !containerRef.current || widgetIdRef.current) return;
      widgetIdRef.current = window.turnstile.render(containerRef.current, {
        sitekey: SITEKEY,
        callback: setToken,
      });
      clearInterval(t);
    }, 200);
    return () => clearInterval(t);
  }, []);

  const consume = () => {
    setToken(null);
    if (widgetIdRef.current) window.turnstile?.reset(widgetIdRef.current);
  };

  return (
    <Ctx.Provider value={{ token, consume }}>
      {children}
      <div ref={containerRef} style={{ position: 'fixed', left: -9999, top: -9999 }} />
    </Ctx.Provider>
  );
}
```

서버에서 토큰 발급 전에 검증:

```ts
const { turnstileToken } = await req.json();
const ok = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
  method: 'POST',
  headers: { 'content-type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({ secret: process.env.TURNSTILE_SECRET!, response: turnstileToken }),
}).then(r => r.json()).then(r => r.success);
if (!ok) return NextResponse.json({ error: 'turnstile_failed' }, { status: 403 });
```

토큰 사용 후 `consume()` 으로 reset 하면 다음 통화용 새 Turnstile 토큰이 자동 발급됩니다.

### S4. Rate-limit (PSTN 발신 시 필수)

- **on-net 발신** (자기 계정 070 번호로 발신) → 통신사로 나가지는 않지만 **발신 분으로 과금**됩니다. 받는 번호의 착신 통화는 수신 분으로, 그 착신에서 `<Dial>` 로 연결한 통화는 다시 발신 분으로 계산됩니다. 길이 상한은 반드시 두세요.
- **PSTN 발신** → 토큰 발급 단위로 IP/계정당 시간/일 한도 필수.

권장 기본값: `IP 당 분당 1회 / 일 5회`. 프로덕트 특성에 맞게 조정.

### S5. 통화 길이 안전망

브라우저 `setTimeout` 은 사용자가 콘솔로 우회 가능합니다. 서버측 hard cap 을 별도로 둡니다.

| 시나리오 | 권장 |
| --- | --- |
| 자체 PBX/IVR 이 있다 | 거기서 발신자번호 기준 max-duration 설정 |
| 한국AI통신만 사용 | on-net 발신이면 받는 번호의 VoiceML 에서 [`<Dial timeLimit>`](/docs/build/voiceml) 으로 상한을 둡니다. 단말에서 건 PSTN 직접 발신은 서버에서 [통화 목록](/docs/api/calls/listCalls)을 `status=in-progress` 로 주기적으로 조회해, 상한을 넘은 통화를 [통화 제어](/docs/api/calls/updateCall) `Status=completed` 로 끊습니다. 단말 발신에는 `statusCallback` 이 오지 않습니다 |

토큰 TTL 은 **새 통화를 시작할 수 있는 기간**입니다. 만료돼도 이미 연결된 통화는 끊기지 않으므로 통화 길이 상한으로 쓸 수 없습니다.

PSTN 발신은 비용이 발생하므로 **반드시** 길이 상한이 있어야 합니다. 클라이언트만 믿지 마세요.

---

## 참고

**API Reference (Scalar UI)** — 전체 스키마/예제/Try-it-out:
- [📘 WebRTC 전체 그룹](https://api.claw-ops.com/docs#tag/webrtc)
- [POST /sip-credentials/:id/tokens](https://api.claw-ops.com/docs#tag/webrtc/POST/v1/accounts/{accountId}/sip-credentials/{credentialId}/tokens) — 단기 토큰 발급
- [DELETE /sip-credentials/:id/tokens/:jti](https://api.claw-ops.com/docs#tag/webrtc/DELETE/v1/accounts/{accountId}/sip-credentials/{credentialId}/tokens/{jti}) — 토큰 폐기
- [GET /ice-servers](https://api.claw-ops.com/docs#tag/webrtc/GET/v1/accounts/{accountId}/ice-servers) — STUN/TURN 자격

### 토큰과 JWT 안의 값들

토큰 응답에 들어있는 `token` 은 HS256 JWT 입니다. 디코드하면 SIP 인증에 필요한 모든 값(`sip_username`, `sip_password`, `ws_url`, `realm`, `allowed_caller_ids` 등)이 payload 에 들어 있습니다 — 위 [3단계](#3단계-브라우저--jssip-ua) 참고.

서명 검증은 한국AI통신 게이트웨이가 SIP digest 단계에서 수행하므로 **클라이언트는 디코드만** 하면 됩니다.

`ws_url` / `realm` 은 환경별로 달라질 수 있으므로 **하드코딩하지 말고 토큰 payload 에서 읽어** 사용하세요. 전체 응답/payload 필드 목록은 [API Reference](https://api.claw-ops.com/docs#tag/webrtc) 참고.

### ICE / TURN

`GET /ice-servers` 가 반환하는 `ice_servers` 배열을 그대로 `RTCPeerConnection` 의 `iceServers` 옵션에 넘기면 됩니다. TURN 자격은 시간제한 HMAC 으로 발급되며 `expires_at` 이후 무효 — 새로 발급받으세요.

전체 응답 스키마는 [API Reference](https://api.claw-ops.com/docs#tag/webrtc/GET/v1/accounts/{accountId}/ice-servers) 참고.

### 트러블슈팅

| 증상 | 원인 | 해결 |
| --- | --- | --- |
| WSS 연결 실패 (status 0) | `wsUri` 하드코딩, 환경 불일치 | JWT payload 의 `ws_url` 사용 |
| `407 Proxy Authentication Required` | digest 응답 누락 | JsSIP `password` 옵션 확인 |
| `403 Invalid Caller ID format` | From URI user 가 phone format 위반 | `fromUserName: callerId` 옵션 추가 |
| INVITE 후 즉시 BYE (Q.850 cause=16) | self-call (자기 계정 번호 → 자기 계정 번호) | 한국AI통신이 자동 on-net 라우팅. 별도 작업 불필요 |
| 마이크 권한 거부 | 사용자가 거부 | 안내 UI + 재요청 버튼 |
| 통화는 되는데 음성이 안 들림 | ICE/TURN 실패 (사용자 NAT 환경) | TURN(`turns:5349`) 자격 만료 여부 확인 |
| 전화가 브라우저로 안 옴 | `register: false` — 등록하지 않아 착신 대상이 아님 | [착신 받기](#착신-받기) 참고, `register: true` |
| `DialCallStatus=failed` 또는 `no-answer` 즉시 반환 | `<Dial><Sip>` 대상이 **미등록** 이거나 SIP URI 오타 | 토큰 응답의 `sip_uri` 를 그대로 사용 |
| `<Dial><Sip>` 이 아무 반응 없이 실패 | **SIP 트렁크 연결 부가서비스** 미보유 | 대시보드에서 활성화 |

### 운영 체크리스트

- [ ] `CLAWOPS_API_KEY` 는 서버 환경변수에만, 클라이언트 번들에 노출 X
- [ ] `destination` 은 서버에서 결정 (클라이언트 입력 신뢰 X)
- [ ] Cloudflare Turnstile 또는 동등한 봇 방어
- [ ] 토큰 TTL 5~7분 (발급 후 통화를 시작할 수 있는 기간)
- [ ] PSTN 발신은 토큰 발급 Rate-limit (IP/계정당 일/시간 한도)
- [ ] 통화 길이 안전망 (PBX max-duration 또는 통화 목록 조회 후 종료)
- [ ] HTTPS 필수 — `getUserMedia` 는 secure context 에서만 동작

## 다음

- [SIP 단말 (Linphone)](/docs/clients/sip) — 데스크톱/모바일 SIP 소프트폰 등록
- [Voice Agent](/docs/voice-agent) — 발신측이 아니라 *수신* AI 에이전트 만들기
