🇮🇱 עברית
→ חזרה להטמעה

חייגן CUSTOM — לבנות מאפס

בנתיב הזה אנחנו לא נותנים לכם ממשק ולא ספריה — רק פרטי SIP וכתובת WSS, ואתם מתחברים ישירות. כל מה שטלפון רגיל יודע לעשות, הקוד שלכם יודע לעשות.

רוב האנשים לא צריכים את הדף הזה.

מצב Headless כבר נותן GUI חופשי לגמרי, ומטפל בחיבור מחדש, ברישום ובאודיו במקומכם. בחרו בנתיב הזה רק כשאתם לא בדפדפן (React Native, Electron) או כשאתם חייבים שליטה ישירה ב-RTCPeerConnection.

1. קבלת פרטי החיבור

זהה לכל שאר ההטמעות: השרת שלכם מנפיק טוקן חד-פעמי, הלקוח פודה אותו. מה שמעניין כאן הוא מה שחוזר — extension ו-realtime.

// YOUR SERVER — the API key must never reach the client
const mint = await fetch('https://aicalls.txi.co.il/api/v1/embed/token', {
  method: 'POST',
  headers: { Authorization: `Bearer ${process.env.PBX_API_KEY}`,
             'Content-Type': 'application/json' },
  body: JSON.stringify({ crmUserId: 'agent-42', layout: 'headless' }),
}).then(r => r.json())

// CLIENT — burn it once, within 5 minutes
const s = await fetch('https://aicalls.txi.co.il/api/embed/exchange', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ token: mint.exchangeToken }),
}).then(r => r.json())

const { sipUsername, sipPassword, sipDomain } = s.extension
const { wssUrl, iceServers } = s.realtime
// sessionJwt (valid 1h) authenticates your REST + Socket.IO calls

2. רישום מול המרכזייה

מכאן זה SIP רגיל. הדוגמאות ב-SIP.js 0.21 — אותה ספריה שהחייגן שלנו רץ עליה בפרודקשן.

import { UserAgent, Registerer, Inviter, SessionState } from 'sip.js'

const ua = new UserAgent({
  uri: UserAgent.makeURI(`sip:${sipUsername}@${sipDomain}`),
  transportOptions: { server: wssUrl },        // wss://aicalls.txi.co.il:7443
  authorizationUsername: sipUsername,
  authorizationPassword: sipPassword,
  sessionDescriptionHandlerFactoryOptions: {
    peerConnectionConfiguration: { iceServers },   // pass ours through as-is
  },
})

await ua.start()

// 600s, NOT the 120s default. Phones sleep, and a missed refresh IS an
// unregistration — the agent silently stops receiving calls.
const registerer = new Registerer(ua, { expires: 600 })
await registerer.register()

3. שיחה יוצאת

async function dial(number) {
  const target = UserAgent.makeURI(`sip:${number}@${sipDomain}`)
  const inviter = new Inviter(ua, target, {
    sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false } },
  })

  inviter.stateChange.addListener((state) => {
    if (state === SessionState.Established) attachAudio(inviter)
    if (state === SessionState.Terminated)  detachAudio()
  })

  await inviter.invite()
  return inviter            // keep it: hangup / DTMF / hold all live here
}

// hang up whatever state it is in
async function hangup(session) {
  const bye = { [SessionState.Initial]: () => session.cancel(),
                [SessionState.Establishing]: () => session.cancel(),
                [SessionState.Established]: () => session.bye() }
  await bye[session.state]?.()
}

4. אודיו — הנקודה שנופלים בה

SIP.js מנהל את המשא ומתן אבל לא מנגן כלום. בלי הקוד הזה השיחה תתחבר, המונה ירוץ, ואף אחד לא ישמע אף אחד.

const audioEl = document.createElement('audio')
audioEl.autoplay = true
document.body.appendChild(audioEl)

function attachAudio(session) {
  const pc = session.sessionDescriptionHandler.peerConnection
  const remote = new MediaStream()
  pc.getReceivers().forEach(r => r.track && remote.addTrack(r.track))
  audioEl.srcObject = remote
  audioEl.play().catch(() => {
    // Autoplay is blocked until the user has interacted with the page.
    // Create the element on the click that starts the call, not on load.
  })
}

function detachAudio() { audioEl.srcObject = null }

5. שיחה נכנסת

ua.delegate = {
  onInvite: (invitation) => {
    const from = invitation.remoteIdentity.uri.user       // the caller's number

    invitation.stateChange.addListener((state) => {
      if (state === SessionState.Established) attachAudio(invitation)
      if (state === SessionState.Terminated)  detachAudio()
    })

    showIncomingUi(from, {
      accept: () => invitation.accept({
        sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false } },
      }),
      reject: () => invitation.reject(),          // 486 Busy Here
    })
  },
}

DTMF, השתקה, העברה

// DTMF — RFC 2833 over the media path
session.sessionDescriptionHandler.sendDtmf('1')

// mute: stop sending, stay connected
session.sessionDescriptionHandler.peerConnection
  .getSenders().forEach(s => { if (s.track) s.track.enabled = false })

// blind transfer to another extension
session.refer(UserAgent.makeURI(`sip:1002@${sipDomain}`))

מה שהמצב ה-Headless עושה ואתם תצטרכו לכתוב

זו העלות האמיתית של הנתיב הזה, והיא לא בקוד שלמעלה — היא בארבעת הדברים האלה:

חיבור מחדש

רשת נופלת, לפטופ נרדם, WiFi מתחלף. חייבים להאזין ל-transport.stateChange, לחבר מחדש עם השהיה מצטברת, ולרשום מחדש. בלי זה הנציג "מחובר" ולא מקבל שיחות.

רישום שפג

expires הוא הבטחה שאתם חייבים לקיים. רעננו במחצית הזמן, לא בסוף.

הרשאת מיקרופון

בקשו אותה מוקדם ותציגו מצב ברור כשנדחתה — אחרת השיחה נכשלת בלי הסבר.

מצב השיחה מול השרת

החייגן שלנו מדווח לוח מחוונים חי. אם אתם בונים לבד, שדרו את המצב דרך Socket.IO עם ה-sessionJwt כדי שהשיחות יופיעו במרכז הבקרה ובדוחות.