# web客户端 - WebRTC 接入文档

本文面向 CRM、客服系统和业务前端开发人员，说明如何接入由服务提供方统一托管的 WebRTC 电话。

客户只需要加载在线脚本，不需要安装 npm 包、部署电话文件或取得项目源码。

## 1. 接入信息

在线脚本地址：

```text
https://indo.infin8linx.com/v1/webrtc-phone-hosted-loader.global.js
```

客户业务后端需要向前端返回临时 SIP 凭据：

```json
{
  "extension": "1001",
  "password": "temporary-sip-password",
  "displayName": "客服 1001"
}
```

`displayName` 可以省略。只有服务提供方明确给出了与分机号不同的鉴权用户名时，才额外传入 `authorizationUser`。

请勿把正式 SIP 密码写入 HTML、URL、公开 JavaScript 或浏览器存储。

## 2. 选择国家节点

| 国家/地区 | `sipNodeId` | 是否默认 |
|---|---|:---:|
| 墨西哥 | `mexico` | 否 |
| 印度尼西亚 | `indonesia` | 是 |
| 新加坡 | `singapore` | 否 |

客户选择国家后，将对应的 `sipNodeId` 传给电话即可，不需要填写域名和端口。

## 3. 接入内置电话条

推荐在业务页面加载后预先创建电话条，在用户输入账号时完成 iframe 和运行时初始化。此时不会连接 SIP，也不会注册坐席；点击登录后只需提交凭据完成注册，首次登录等待时间更短。

```html
<script src="https://indo.infin8linx.com/v1/webrtc-phone-hosted-loader.global.js"></script>

<div id="phone-container"></div>

<script>
  let phonePromise;

  function preparePhone() {
    if (!phonePromise) {
      phonePromise = WebRTCPhoneHosted.HostedPhone.mount({
        container: '#phone-container',
        layout: 'bar',
        language: 'zh-CN',
        autoRegister: false,
        config: {
          sipNodeId: 'indonesia',
        },
      }).then((phone) => {
        phone.on('error', (error) => {
          console.error(error.code, error.message);
        });
        window.businessPhone = phone;
        return phone;
      }).catch((error) => {
        phonePromise = undefined;
        throw error;
      });
    }

    return phonePromise;
  }

  async function loginPhone(sipNodeId) {
    const response = await fetch('/api/sip-credentials', {
      credentials: 'same-origin',
      headers: { Accept: 'application/json' },
    });

    if (!response.ok) {
      throw new Error('无法取得电话凭据');
    }

    const phone = await preparePhone();
    await phone.configure({ sipNodeId });
    await phone.register(await response.json());
  }

  // 页面打开时预加载运行时，不会连接 WSS 或注册坐席。
  void preparePhone();

  // 在用户点击登录后调用：
  // await loginPhone('indonesia');
</script>
```

注册失败时可以保留并复用当前电话实例，修正凭据后再次调用 `register()`，不需要重复挂载 iframe。只有用户实际退出登录或页面永久卸载电话功能时才调用 `destroy()`。

直接在 `mount()` 中传入 `credentials` 仍然受支持，但 iframe 加载、Bridge 握手和 SIP 注册会连续发生，登录按钮的等待时间会更长。

### 切换悬浮拨号盘

将 `layout` 改成 `floating`：

```js
const phone = await WebRTCPhoneHosted.HostedPhone.mount({
  container: '#phone-container',
  layout: 'floating',
  language: 'zh-CN',
  autoRegister: false,
  config: { sipNodeId: 'singapore' },
});

await phone.register(credentials);
```

布局支持：

- `bar`：紧凑电话条，适合 CRM 顶部或工具栏；
- `floating`：悬浮拨号盘，适合放在业务页面右下角。

语言支持 `zh-CN` 和 `en`。

已经挂载后可以直接切换布局，不会注销坐席或重建 SIP 连接：

```js
await phone.setLayout('floating');
await phone.setLayout('bar');
```

## 4. 使用客户自己的界面

如果不需要内置电话条，可以创建无界面电话 Client：

```js
const phone = await WebRTCPhoneHosted.HostedPhone.createClient({
  language: 'zh-CN',
  autoRegister: false,
  config: { sipNodeId: 'mexico' },
});

await phone.register(credentials);

phone.subscribe((snapshot) => {
  renderRegistration(snapshot.registration);
  renderCall(snapshot.call);
});

document.querySelector('#call').onclick = () => phone.call('1002');
document.querySelector('#hangup').onclick = () => phone.hangup();
```

## 5. 呼叫功能

内置电话条和无界面 Client 使用相同的业务 API。

### 呼出

```js
await phone.call('81519140200');
```

也可以携带可选业务 SIP Header：

```js
await phone.call('81519140200', {
  sipHeaders: {
    'X-Customer-Id': 'customer-10001',
    'X-Business-Id': 'order-20002',
    'X-Trace-Id': 'trace-30003',
  },
});
```

三个 Header 都不是必传参数，没有值时不要传。

### 来电、接听与拒接

```js
phone.on('incomingcall', ({ call }) => {
  console.log('来电号码', call.remoteIdentity);
});

await phone.answer();
await phone.reject(486, 'Busy Here');
```

### 挂断、静音、保持和 DTMF

```js
await phone.hangup();

await phone.setMuted(true);
await phone.setMuted(false);
await phone.toggleMuted();

await phone.setHeld(true);
await phone.setHeld(false);
await phone.toggleHeld();

await phone.sendDTMF('1');
await phone.sendDTMF('#');
```

静音、保持和 DTMF 应在通话接通后调用。

## 6. 状态监听

监听完整状态：

```js
const stop = phone.subscribe((snapshot) => {
  console.log('注册状态', snapshot.registration);
  console.log('通话状态', snapshot.call.state);
  console.log('对端号码', snapshot.call.remoteIdentity);
  console.log('接通时间', snapshot.call.connectedAt);
});

// 不再监听时
stop();
```

也可以按事件监听：

```js
phone.on('registrationchange', ({ registration }) => {
  console.log(registration);
});

phone.on('callchange', ({ call }) => {
  console.log(call.state);
});

phone.on('error', (error) => {
  console.error(error.code, error.message, error.recoverable);
});
```

注册状态：

```text
unregistered → connecting → registering → registered
```

通话常用状态：

```text
idle、dialing、ringing、incoming、connecting、connected、ending、ended、failed
```

## 7. 麦克风和扬声器

```js
await phone.requestMicrophonePermission();

const devices = await phone.listAudioDevices(true);

if (devices.outputs[0]) {
  await phone.setAudioOutputDevice(devices.outputs[0].deviceId);
}
```

如果浏览器阻止远端声音，请在用户点击按钮时调用：

```js
await phone.resumeAudio();
```

部分浏览器不支持选择扬声器，此时会继续使用系统默认设备。

## 8. 切换节点或账号

没有通话且未注册时，可以复用实例切换节点后注册：

```js
await phone.configure({ sipNodeId: 'singapore' });
await phone.register(newCredentials);
```

已经注册时，请先结束当前通话，再注销并切换节点：

```js
await phone.unregister();
await phone.configure({ sipNodeId: 'singapore' });
await phone.register(newCredentials);
```

## 9. 注销和页面退出

```js
await phone.unregister();
await phone.destroy();
```

客户退出登录或业务页面永久关闭电话功能时，应调用 `destroy()` 释放资源。
`unregister()` 会注销当前 Hosted 电话的 SIP Contact，并等待服务端确认；必须 `await` 完成后再跳转页面。`destroy()` 同样会先完成该流程，再移除 Hosted iframe，不会注销同一坐席在其他设备上的注册。

## 10. 常用错误码

| 错误码 | 含义 | 建议处理 |
|---|---|---|
| `MICROPHONE_DENIED` | 麦克风没有权限 | 引导用户授权浏览器麦克风 |
| `REGISTRATION_FAILED` | 电话注册失败 | 检查账号后联系服务提供方 |
| `UNREGISTRATION_FAILED` | 服务端未确认注销 | 联系服务提供方核查注册状态 |
| `CALL_FAILED` | 呼叫失败 | 展示提示并允许重新拨打 |
| `MEDIA_PLAY_FAILED` | 浏览器没有播放声音 | 用户点击后调用 `resumeAudio()` |
| `INVALID_STATE` | 当前状态不能执行操作 | 根据电话状态禁用对应按钮 |

客户业务提示应根据 `code` 映射，不要依赖英文 `message`。

## 11. 接入要求

- 客户生产页面使用 HTTPS；
- 页面允许加载和嵌入 `https://indo.infin8linx.com`；
- 页面允许托管电话使用麦克风和自动播放；
- 建议电话容器宽度不小于 280px；
- Chrome、Edge、Firefox 和 Safari 使用仍在厂商维护期内的版本。

如果客户页面设置了严格的 CSP、Permissions-Policy，或者客户系统本身也运行在 iframe 中，请把相关响应头和页面部署方式提供给服务提供方协助确认。

## 12. 验收项目

服务提供方如需协同验收，统一使用交付目录中的 `hosted-demo.html`。用户先选择节点并输入坐席凭据，注册成功后进入统一 CRM 页面，可以在右上角坐席菜单中切换“电话条 / 悬浮”样式，并从电话界面或客户列表发起呼叫。切换布局不会重新注册；展开的拨号盘覆盖在业务内容上方，不会撑开 CRM 布局。测试凭据仅保存在当前页面内存中。

- 电话成功注册；
- 三个国家节点按实际业务需要完成验证；
- 呼出、呼入、接听、拒接和挂断正常；
- 双向语音正常；
- 静音、保持、恢复和 DTMF 正常；
- 通话状态和时长正常更新；
- 麦克风拒绝、注册失败和呼叫失败有明确提示。