Merge branch 'feat/superior-voice'

lct-51: голосовой маршрут ДДС — вышестоящий начальник через WebRTC 6103
This commit is contained in:
GGlamer 2026-09-27 23:51:36 +03:00 • committed by GitHub
commit 3c19eb95a7
9 changed files with 63 additions and 12 deletions

View file

@ -25,6 +25,7 @@ SIP_6002_PASSWORD=
SIP_6003_PASSWORD= SIP_6003_PASSWORD=
SIP_6101_PASSWORD= SIP_6101_PASSWORD=
SIP_6102_PASSWORD= SIP_6102_PASSWORD=
SIP_6103_PASSWORD=
# IP/DNS сервера, который WebRTC-клиенты видят в локальной сети. При запуске # IP/DNS сервера, который WebRTC-клиенты видят в локальной сети. При запуске
# только на той же машине оставьте 127.0.0.1; RTP-порты должны отображаться 1:1. # только на той же машине оставьте 127.0.0.1; RTP-порты должны отображаться 1:1.
SIP_EXTERNAL_MEDIA_ADDRESS=127.0.0.1 SIP_EXTERNAL_MEDIA_ADDRESS=127.0.0.1

View file

@ -47,7 +47,7 @@ sip: ## Собрать и поднять локальный Asterisk SIP/VoIP
offline-sip: ## Поднять SIP из уже собранного образа без сети offline-sip: ## Поднять SIP из уже собранного образа без сети
$(COMPOSE) -f docker-compose.yml -f docker-compose.sip.yml up --pull never --no-build -d sip $(COMPOSE) -f docker-compose.yml -f docker-compose.sip.yml up --pull never --no-build -d sip
sip-credentials: ## Показать локальные пароли софтфонов 6001–6003 и WebRTC 6101–6102 sip-credentials: ## Показать локальные пароли софтфонов 6001–6003 и WebRTC 6101–6103
$(COMPOSE) -f docker-compose.yml -f docker-compose.sip.yml exec -T sip cat /var/lib/lct-sip/credentials.env $(COMPOSE) -f docker-compose.yml -f docker-compose.sip.yml exec -T sip cat /var/lib/lct-sip/credentials.env
sip-test: ## Проверить регистрацию, звонок, RTP и задержку (args="--rtp-host-offset 1000") sip-test: ## Проверить регистрацию, звонок, RTP и задержку (args="--rtp-host-offset 1000")

View file

@ -9,6 +9,7 @@ services:
SIP_6003_PASSWORD: ${SIP_6003_PASSWORD:-} SIP_6003_PASSWORD: ${SIP_6003_PASSWORD:-}
SIP_6101_PASSWORD: ${SIP_6101_PASSWORD:-} SIP_6101_PASSWORD: ${SIP_6101_PASSWORD:-}
SIP_6102_PASSWORD: ${SIP_6102_PASSWORD:-} SIP_6102_PASSWORD: ${SIP_6102_PASSWORD:-}
SIP_6103_PASSWORD: ${SIP_6103_PASSWORD:-}
SIP_EXTERNAL_MEDIA_ADDRESS: ${SIP_EXTERNAL_MEDIA_ADDRESS:-127.0.0.1} SIP_EXTERNAL_MEDIA_ADDRESS: ${SIP_EXTERNAL_MEDIA_ADDRESS:-127.0.0.1}
SIP_RTP_START: ${SIP_RTP_START:-10000} SIP_RTP_START: ${SIP_RTP_START:-10000}
SIP_RTP_END: ${SIP_RTP_END:-10099} SIP_RTP_END: ${SIP_RTP_END:-10099}

View file

@ -12,12 +12,19 @@ const LABEL: Record<SipStatus, string> = {
error: "ошибка", error: "ошибка",
}; };
/** Абонент быстрого набора: пульт ДДС звонит бригаде и начальнику одной кнопкой. */
export interface SipContact {
label: string;
number: string;
}
export function SipPhone({ export function SipPhone({
defaultExtension = "6101", defaultTarget = "6102", title = "Локальный SIP/WebRTC", defaultExtension = "6101", defaultTarget = "6102", title = "Локальный SIP/WebRTC", contacts = [],
}: { }: {
defaultExtension?: string; defaultExtension?: string;
defaultTarget?: string; defaultTarget?: string;
title?: string; title?: string;
contacts?: SipContact[];
}) { }) {
const phone = useSipPhone(); const phone = useSipPhone();
const [extension, setExtension] = useState(defaultExtension); const [extension, setExtension] = useState(defaultExtension);
@ -46,6 +53,10 @@ export function SipPhone({
<label>Номер собеседника<input aria-label="Номер SIP собеседника" inputMode="numeric" value={target} <label>Номер собеседника<input aria-label="Номер SIP собеседника" inputMode="numeric" value={target}
disabled={inCall} onChange={(event) => setTarget(event.target.value)} /></label> disabled={inCall} onChange={(event) => setTarget(event.target.value)} /></label>
{phone.status === "registered" && <button type="button" onClick={() => phone.dial(target)}>Позвонить</button>} {phone.status === "registered" && <button type="button" onClick={() => phone.dial(target)}>Позвонить</button>}
{phone.status === "registered" && contacts.map((contact) => <button type="button" key={contact.number}
onClick={() => { setTarget(contact.number); phone.dial(contact.number); }}>
Позвонить: {contact.label} · {contact.number}
</button>)}
{phone.status === "incoming" && <button type="button" className="sip-answer" onClick={phone.answer}>Ответить · {phone.peer}</button>} {phone.status === "incoming" && <button type="button" className="sip-answer" onClick={phone.answer}>Ответить · {phone.peer}</button>}
{inCall && phone.status !== "incoming" && <span>{phone.peer || target}</span>} {inCall && phone.status !== "incoming" && <span>{phone.peer || target}</span>}
{inCall && <button type="button" className="sip-hangup" onClick={phone.hangup}>Завершить</button>} {inCall && <button type="button" className="sip-hangup" onClick={phone.hangup}>Завершить</button>}

View file

@ -613,9 +613,11 @@ export function Dds() {
</div><aside className="dds-right"> </div><aside className="dds-right">
{pane === "phone" && <section className="dds-panel dds-phone"><div className="dds-panel-title"><h2>IP-телефон</h2><span>SIP/WebRTC + учебный протокол</span></div> {pane === "phone" && <section className="dds-panel dds-phone"><div className="dds-panel-title"><h2>IP-телефон</h2><span>SIP/WebRTC + учебный протокол</span></div>
<Suspense fallback={<p className="dds-hint">Загружаем IP-телефон…</p>}> <Suspense fallback={<p className="dds-hint">Загружаем IP-телефон…</p>}>
<SipPhone defaultExtension="6101" defaultTarget="6102" title="Реальный локальный канал ДДС" /> <SipPhone defaultExtension="6101" defaultTarget="6102" title="Реальный локальный канал ДДС"
contacts={[{ label: "старший группы", number: "6102" },
{ label: "вышестоящий начальник", number: "6103" }]} />
</Suspense> </Suspense>
<p className="dds-hint">На втором рабочем месте откройте <a href="/phone" target="_blank" rel="noreferrer">телефон старшего группы</a> и зарегистрируйте 6102. Пароли 6101/6102 выдаёт локальный администратор командой <code>make sip-credentials</code>.</p> <p className="dds-hint">На втором рабочем месте откройте <a href="/phone" target="_blank" rel="noreferrer">телефон старшего группы</a> (6102); преподаватель в роли вышестоящего начальника — <a href="/phone?role=superior" target="_blank" rel="noreferrer">телефон 6103</a>. Пароли 6101–6103 выдаёт локальный администратор командой <code>make sip-credentials</code>.</p>
<h3 className="dds-protocol-title">Учебный протокол доклада</h3> <h3 className="dds-protocol-title">Учебный протокол доклада</h3>
<p className="dds-hint">Связь используется при необходимости. Разговор и учебный доклад сохраняются в журнале, но не блокируют изменение статуса.</p> <p className="dds-hint">Связь используется при необходимости. Разговор и учебный доклад сохраняются в журнале, но не блокируют изменение статуса.</p>
<label htmlFor="dds-crew">Дежурная бригада</label> <label htmlFor="dds-crew">Дежурная бригада</label>

View file

@ -1,9 +1,22 @@
import { SipPhone } from "@/features/sip/SipPhone"; import { SipPhone } from "@/features/sip/SipPhone";
// Второе рабочее место голосовой связи ДДС. Старший группы — по умолчанию;
// вышестоящего начальника (?role=superior) играет преподаватель: это
// обязательный маршрут по ответу заказчика (П.7).
const ROLES = {
crew: { title: "Телефон старшего группы", extension: "6102", heading: "IP-телефон старшего группы" },
superior: { title: "Телефон вышестоящего начальника", extension: "6103", heading: "IP-телефон вышестоящего начальника" },
} as const;
export function Phone() { export function Phone() {
const role = new URLSearchParams(window.location.search).get("role") === "superior" ? "superior" : "crew";
const { title, extension, heading } = ROLES[role];
return <main className="page phone-page"> return <main className="page phone-page">
<h1>IP-телефон старшего группы</h1> <h1>{heading}</h1>
<p>Откройте этот экран на втором рабочем месте, зарегистрируйте номер 6102 и примите вызов ДДС 6101.</p> <p>Откройте этот экран на втором рабочем месте, зарегистрируйте номер {extension} и примите вызов ДДС 6101.</p>
<SipPhone defaultExtension="6102" defaultTarget="6101" title="Телефон старшего группы" /> <SipPhone key={role} defaultExtension={extension} defaultTarget="6101" title={title} />
<p className="ref">{role === "crew"
? <>Роль вышестоящего начальника — <a href="/phone?role=superior">телефон 6103</a>.</>
: <>Роль старшего группы — <a href="/phone">телефон 6102</a>.</>}</p>
</main>; </main>;
} }

View file

@ -10,6 +10,7 @@ override_6002=${SIP_6002_PASSWORD:-}
override_6003=${SIP_6003_PASSWORD:-} override_6003=${SIP_6003_PASSWORD:-}
override_6101=${SIP_6101_PASSWORD:-} override_6101=${SIP_6101_PASSWORD:-}
override_6102=${SIP_6102_PASSWORD:-} override_6102=${SIP_6102_PASSWORD:-}
override_6103=${SIP_6103_PASSWORD:-}
if [ -s "$credentials" ]; then if [ -s "$credentials" ]; then
# Файл содержит только сгенерированные hex-значения без shell-конструкций. # Файл содержит только сгенерированные hex-значения без shell-конструкций.
. "$credentials" . "$credentials"
@ -19,6 +20,7 @@ SIP_6002_PASSWORD=${override_6002:-${SIP_6002_PASSWORD:-$(openssl rand -hex 16)}
SIP_6003_PASSWORD=${override_6003:-${SIP_6003_PASSWORD:-$(openssl rand -hex 16)}} SIP_6003_PASSWORD=${override_6003:-${SIP_6003_PASSWORD:-$(openssl rand -hex 16)}}
SIP_6101_PASSWORD=${override_6101:-${SIP_6101_PASSWORD:-$(openssl rand -hex 16)}} SIP_6101_PASSWORD=${override_6101:-${SIP_6101_PASSWORD:-$(openssl rand -hex 16)}}
SIP_6102_PASSWORD=${override_6102:-${SIP_6102_PASSWORD:-$(openssl rand -hex 16)}} SIP_6102_PASSWORD=${override_6102:-${SIP_6102_PASSWORD:-$(openssl rand -hex 16)}}
SIP_6103_PASSWORD=${override_6103:-${SIP_6103_PASSWORD:-$(openssl rand -hex 16)}}
SIP_EXTERNAL_MEDIA_ADDRESS=${SIP_EXTERNAL_MEDIA_ADDRESS:-127.0.0.1} SIP_EXTERNAL_MEDIA_ADDRESS=${SIP_EXTERNAL_MEDIA_ADDRESS:-127.0.0.1}
SIP_RTP_START=${SIP_RTP_START:-10000} SIP_RTP_START=${SIP_RTP_START:-10000}
SIP_RTP_END=${SIP_RTP_END:-10099} SIP_RTP_END=${SIP_RTP_END:-10099}
@ -36,12 +38,13 @@ SIP_6002_PASSWORD=$SIP_6002_PASSWORD
SIP_6003_PASSWORD=$SIP_6003_PASSWORD SIP_6003_PASSWORD=$SIP_6003_PASSWORD
SIP_6101_PASSWORD=$SIP_6101_PASSWORD SIP_6101_PASSWORD=$SIP_6101_PASSWORD
SIP_6102_PASSWORD=$SIP_6102_PASSWORD SIP_6102_PASSWORD=$SIP_6102_PASSWORD
SIP_6103_PASSWORD=$SIP_6103_PASSWORD
EOF EOF
chmod 600 "$credentials" chmod 600 "$credentials"
export SIP_6001_PASSWORD SIP_6002_PASSWORD SIP_6003_PASSWORD SIP_6101_PASSWORD SIP_6102_PASSWORD SIP_EXTERNAL_MEDIA_ADDRESS export SIP_6001_PASSWORD SIP_6002_PASSWORD SIP_6003_PASSWORD SIP_6101_PASSWORD SIP_6102_PASSWORD SIP_6103_PASSWORD SIP_EXTERNAL_MEDIA_ADDRESS
export SIP_RTP_START SIP_RTP_END export SIP_RTP_START SIP_RTP_END
envsubst '${SIP_6001_PASSWORD} ${SIP_6002_PASSWORD} ${SIP_6003_PASSWORD} ${SIP_6101_PASSWORD} ${SIP_6102_PASSWORD} ${SIP_EXTERNAL_MEDIA_ADDRESS}' \ envsubst '${SIP_6001_PASSWORD} ${SIP_6002_PASSWORD} ${SIP_6003_PASSWORD} ${SIP_6101_PASSWORD} ${SIP_6102_PASSWORD} ${SIP_6103_PASSWORD} ${SIP_EXTERNAL_MEDIA_ADDRESS}' \
< /opt/lct-sip/pjsip.conf.template > /etc/asterisk/pjsip.conf < /opt/lct-sip/pjsip.conf.template > /etc/asterisk/pjsip.conf
chmod 640 /etc/asterisk/pjsip.conf chmod 640 /etc/asterisk/pjsip.conf
chown root:asterisk /etc/asterisk/pjsip.conf chown root:asterisk /etc/asterisk/pjsip.conf

View file

@ -12,9 +12,10 @@ exten => _600[1-3],1,NoOp(LCT training call ${CALLERID(num)} -> ${EXTEN})
same => n,StopMixMonitor() same => n,StopMixMonitor()
same => n,Hangup() same => n,Hangup()
; 6101/6102 — браузерные WebRTC-абоненты. Тот же учебный контекст и запись, ; 6101–6103 — браузерные WebRTC-абоненты: ДДС, старший группы, вышестоящий
; но отдельные endpoints не ломают обычные UDP/TCP-софтфоны 6001–6003. ; начальник. Тот же учебный контекст и запись, но отдельные endpoints не ломают
exten => _610[1-2],1,NoOp(LCT WebRTC training call ${CALLERID(num)} -> ${EXTEN}) ; обычные UDP/TCP-софтфоны 6001–6003.
exten => _610[1-3],1,NoOp(LCT WebRTC training call ${CALLERID(num)} -> ${EXTEN})
same => n,Set(RECORDING_FILE=${STRFTIME(${EPOCH},UTC,%Y%m%d-%H%M%S)}-${CALLERID(num)}-${EXTEN}-${UNIQUEID}) same => n,Set(RECORDING_FILE=${STRFTIME(${EPOCH},UTC,%Y%m%d-%H%M%S)}-${CALLERID(num)}-${EXTEN}-${UNIQUEID})
same => n,MixMonitor(/recordings/${RECORDING_FILE}.wav,b) same => n,MixMonitor(/recordings/${RECORDING_FILE}.wav,b)
same => n,Dial(PJSIP/${EXTEN},30) same => n,Dial(PJSIP/${EXTEN},30)

View file

@ -157,3 +157,22 @@ type=aor
max_contacts=2 max_contacts=2
remove_existing=yes remove_existing=yes
qualify_frequency=0 qualify_frequency=0
; Вышестоящий начальник ДДС: роль играет преподаватель со страницы
; /phone?role=superior. Обязательный голосовой маршрут по ответу заказчика (П.7).
[6103](webrtc-endpoint)
auth=6103-auth
aors=6103
callerid=Веб вышестоящий начальник <6103>
[6103-auth]
type=auth
auth_type=userpass
username=6103
password=${SIP_6103_PASSWORD}
[6103]
type=aor
max_contacts=2
remove_existing=yes
qualify_frequency=0