Skip to content

Commit 2bb06e1

Browse files
authored
feat: Handle *6 & *7 DTMF commands in SIP gateway (#600)
* feat: Handle *6 DTMF command in SIP gateway When a SIP caller presses *6, jibri now detects it via a named FIFO (/tmp/jibri_pjsua_dtmf) written to by pjsua and acts in the Jitsi Meet conference: - Visitor mode: toggle raise hand - Currently unmuted: mute audio only - Currently muted, both audio+video blocked by AV moderation: raise hand - Currently muted, audio or video allowed: unmute whichever is permitted Also logs the full visited URL (including fragment params) on page load. * fix: Make toggleVideoMute async to match toggleAudioMute muteVideo() returns a Promise, so the previous synchronous implementation could return before the toggle actually completed. Video unmuting also requires re-acquiring the camera device asynchronously. Added Redux store subscription with appropriate timeouts (5s muting, 12s unmuting) and explanatory comments to both toggleVideoMute and toggleAudioMute. * feat: Make DTMF FIFO path configurable Replace the hardcoded /tmp/jibri_pjsua_dtmf constant with a config value at jibri.sip.dtmf-fifo-path, defaulting to the same path. Allows overriding in jibri.conf when the default tmp location is unsuitable. * refactor: Run DTMF FIFO reader on shared IO thread pool Replace raw Thread construction with TaskPools.ioPool.submit, consistent with how other blocking IO tasks are dispatched throughout the codebase. The unblock task on stop is also submitted to the same pool. * fix: Fix ktlint import ordering and brace formatting in PjsuaClient * feat: Add *7 DTMF command for video mute toggle, separate from *6 audio toggle *6 now controls audio only; *7 controls video only. Each respects AV moderation force-mute by raising hand when the track cannot be unmuted.
1 parent 81791e5 commit 2bb06e1

7 files changed

Lines changed: 377 additions & 6 deletions

File tree

src/main/kotlin/org/jitsi/jibri/selenium/JibriSelenium.kt

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,50 @@ class JibriSelenium(
279279

280280
fun sendPresence(): Boolean = CallPage(chromeDriver).sendPresence()
281281

282+
fun handleDtmfStar6() {
283+
logger.info("Handling *6 DTMF command (audio toggle)")
284+
val callPage = CallPage(chromeDriver)
285+
if (callPage.isVisitor()) {
286+
logger.info("In visitor mode, toggling raise hand")
287+
callPage.raiseHand()
288+
} else if (!callPage.isLocalAudioMuted()) {
289+
logger.info("Currently audio unmuted, muting audio")
290+
val result = callPage.toggleAudioMute()
291+
logger.info("toggleAudioMute result: $result")
292+
} else {
293+
val audioForceMuted = callPage.isAudioForceMuted()
294+
if (audioForceMuted) {
295+
logger.info("Audio force muted by AV moderation, raising hand to request unmute")
296+
callPage.raiseHand()
297+
} else {
298+
val result = callPage.toggleAudioMute()
299+
logger.info("toggleAudioMute result: $result")
300+
}
301+
}
302+
}
303+
304+
fun handleDtmfStar7() {
305+
logger.info("Handling *7 DTMF command (video toggle)")
306+
val callPage = CallPage(chromeDriver)
307+
if (callPage.isVisitor()) {
308+
logger.info("In visitor mode, toggling raise hand")
309+
callPage.raiseHand()
310+
} else if (!callPage.isLocalVideoMuted()) {
311+
logger.info("Currently video unmuted, muting video")
312+
val result = callPage.toggleVideoMute()
313+
logger.info("toggleVideoMute result: $result")
314+
} else {
315+
val videoForceMuted = callPage.isVideoForceMuted()
316+
if (videoForceMuted) {
317+
logger.info("Video force muted by AV moderation, raising hand to request unmute")
318+
callPage.raiseHand()
319+
} else {
320+
val result = callPage.toggleVideoMute()
321+
logger.info("toggleVideoMute result: $result")
322+
}
323+
}
324+
}
325+
282326
/**
283327
* Join a web call with Selenium
284328
*/

src/main/kotlin/org/jitsi/jibri/selenium/pageobjects/CallPage.kt

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import org.openqa.selenium.TimeoutException
2323
import org.openqa.selenium.remote.RemoteWebDriver
2424
import org.openqa.selenium.support.PageFactory
2525
import org.openqa.selenium.support.ui.WebDriverWait
26+
import java.util.concurrent.TimeUnit
2627
import kotlin.time.measureTimedValue
2728

2829
/**
@@ -362,6 +363,217 @@ class CallPage(driver: RemoteWebDriver) : AbstractPageObject(driver) {
362363
}
363364
}
364365

366+
fun isVisitor(): Boolean {
367+
val result = driver.executeScript(
368+
"""
369+
try {
370+
return APP.store.getState()['features/visitors']?.iAmVisitor === true;
371+
} catch (e) {
372+
return false;
373+
}
374+
""".trimMargin()
375+
)
376+
return result as? Boolean ?: false
377+
}
378+
379+
fun isLocalAudioMuted(): Boolean {
380+
val result = driver.executeScript(
381+
"""
382+
try {
383+
return APP.conference.isLocalAudioMuted();
384+
} catch (e) {
385+
return true;
386+
}
387+
""".trimMargin()
388+
)
389+
return result as? Boolean ?: true
390+
}
391+
392+
fun isLocalVideoMuted(): Boolean {
393+
val result = driver.executeScript(
394+
"""
395+
try {
396+
return APP.conference.isLocalVideoMuted();
397+
} catch (e) {
398+
return true;
399+
}
400+
""".trimMargin()
401+
)
402+
return result as? Boolean ?: true
403+
}
404+
405+
/** Returns true if AV moderation is enabled for audio and the local participant is not approved to unmute. */
406+
fun isAudioForceMuted(): Boolean {
407+
val result = driver.executeScript(
408+
"""
409+
try {
410+
var state = APP.store.getState();
411+
var avMod = state['features/av-moderation'];
412+
if (!avMod || !avMod.audioModerationEnabled) return false;
413+
var local = state['features/base/participants']?.local;
414+
if (local && local.role === 'moderator') return false;
415+
return avMod.audioUnmuteApproved !== true;
416+
} catch (e) {
417+
return false;
418+
}
419+
""".trimMargin()
420+
)
421+
return result as? Boolean ?: false
422+
}
423+
424+
/** Returns true if AV moderation is enabled for video and the local participant is not approved to unmute. */
425+
fun isVideoForceMuted(): Boolean {
426+
val result = driver.executeScript(
427+
"""
428+
try {
429+
var state = APP.store.getState();
430+
var avMod = state['features/av-moderation'];
431+
if (!avMod || !avMod.videoModerationEnabled) return false;
432+
var local = state['features/base/participants']?.local;
433+
if (local && local.role === 'moderator') return false;
434+
return avMod.videoUnmuteApproved !== true;
435+
} catch (e) {
436+
return false;
437+
}
438+
""".trimMargin()
439+
)
440+
return result as? Boolean ?: false
441+
}
442+
443+
// Toggles video mute and waits for the Redux store to confirm the state change.
444+
// muteVideo() returns a Promise — firing it synchronously and returning immediately
445+
// would not guarantee the toggle completed. Unmuting requires re-acquiring the camera
446+
// device, which is slower (12s timeout) than muting/releasing it (5s). On timeout,
447+
// extra state is returned to help diagnose why the change did not complete.
448+
fun toggleVideoMute(): Any? {
449+
driver.manage().timeouts().setScriptTimeout(20, TimeUnit.SECONDS)
450+
return driver.executeAsyncScript(
451+
"""
452+
var done = arguments[0];
453+
try {
454+
var isMuted = APP.conference.isLocalVideoMuted();
455+
var unsubscribe;
456+
var timer;
457+
var cleanup = function() { clearTimeout(timer); if (unsubscribe) unsubscribe(); };
458+
459+
var waitForChange = function(ms, timeoutMsg) {
460+
timer = setTimeout(function() {
461+
cleanup();
462+
var st = APP.store.getState();
463+
var vt = st['features/base/tracks'].filter(function(t) { return t.local && t.mediaType === 'video'; });
464+
done(timeoutMsg + ' mediaMuted=' + st['features/base/media'].video.muted +
465+
' videoTracks=' + vt.length + ' hasJitsiTrack=' + vt.some(function(t) { return !!t.jitsiTrack; }));
466+
}, ms);
467+
unsubscribe = APP.store.subscribe(function() {
468+
var nowMuted = APP.conference.isLocalVideoMuted();
469+
if (nowMuted !== isMuted) {
470+
cleanup();
471+
done('ok wasMuted=' + isMuted + ' nowMuted=' + nowMuted);
472+
}
473+
});
474+
};
475+
476+
if (!isMuted) {
477+
// Prefer muting via the track directly so the Promise rejection is catchable.
478+
// Fall back to a Redux dispatch when no track is present yet.
479+
var localVideo = APP.store.getState()['features/base/tracks']
480+
.find(function(t) { return t.local && t.mediaType === 'video' && t.jitsiTrack; });
481+
if (localVideo) {
482+
waitForChange(5000, 'timeout-muting');
483+
localVideo.jitsiTrack.mute().catch(function(e) { cleanup(); done('error-mute: ' + e); });
484+
} else {
485+
waitForChange(5000, 'timeout-mute-no-track');
486+
APP.store.dispatch({ type: 'SET_VIDEO_MUTED', muted: true });
487+
}
488+
} else {
489+
// ensureTrack tells Jitsi to re-acquire the camera if no track exists yet.
490+
waitForChange(12000, 'timeout-unmuting');
491+
APP.store.dispatch({ type: 'SET_VIDEO_MUTED', muted: false, ensureTrack: true });
492+
}
493+
} catch (e) {
494+
done('error: ' + e.message);
495+
}
496+
""".trimMargin()
497+
)
498+
}
499+
500+
// Toggles audio mute and waits for the Redux store to confirm the state change.
501+
// Audio track operations (especially unmuting/re-acquiring the microphone) are async —
502+
// dispatching and returning immediately would not guarantee the toggle completed.
503+
// Unmuting has a longer timeout (12s) than muting (5s) because re-acquiring a device
504+
// is slower than releasing it. On timeout, extra state is returned to help diagnose
505+
// why the change did not complete.
506+
fun toggleAudioMute(): Any? {
507+
driver.manage().timeouts().setScriptTimeout(20, TimeUnit.SECONDS)
508+
return driver.executeAsyncScript(
509+
"""
510+
var done = arguments[0];
511+
try {
512+
var isMuted = APP.conference.isLocalAudioMuted();
513+
var unsubscribe;
514+
var timer;
515+
var cleanup = function() { clearTimeout(timer); if (unsubscribe) unsubscribe(); };
516+
517+
var waitForChange = function(ms, timeoutMsg) {
518+
timer = setTimeout(function() {
519+
cleanup();
520+
var st = APP.store.getState();
521+
var at = st['features/base/tracks'].filter(function(t) { return t.local && t.mediaType === 'audio'; });
522+
done(timeoutMsg + ' mediaMuted=' + st['features/base/media'].audio.muted +
523+
' audioTracks=' + at.length + ' hasJitsiTrack=' + at.some(function(t) { return !!t.jitsiTrack; }));
524+
}, ms);
525+
unsubscribe = APP.store.subscribe(function() {
526+
var nowMuted = APP.conference.isLocalAudioMuted();
527+
if (nowMuted !== isMuted) {
528+
cleanup();
529+
done('ok wasMuted=' + isMuted + ' nowMuted=' + nowMuted);
530+
}
531+
});
532+
};
533+
534+
if (!isMuted) {
535+
// Prefer muting via the track directly so the Promise rejection is catchable.
536+
// Fall back to a Redux dispatch when no track is present yet.
537+
var localAudio = APP.store.getState()['features/base/tracks']
538+
.find(function(t) { return t.local && t.mediaType === 'audio' && t.jitsiTrack; });
539+
if (localAudio) {
540+
waitForChange(5000, 'timeout-muting');
541+
localAudio.jitsiTrack.mute().catch(function(e) { cleanup(); done('error-mute: ' + e); });
542+
} else {
543+
waitForChange(5000, 'timeout-mute-no-track');
544+
APP.store.dispatch({ type: 'SET_AUDIO_MUTED', muted: true });
545+
}
546+
} else {
547+
// ensureTrack tells Jitsi to re-acquire the microphone if no track exists yet.
548+
waitForChange(12000, 'timeout-unmuting');
549+
APP.store.dispatch({ type: 'SET_AUDIO_MUTED', muted: false, ensureTrack: true });
550+
}
551+
} catch (e) {
552+
done('error: ' + e.message);
553+
}
554+
""".trimMargin()
555+
)
556+
}
557+
558+
fun raiseHand(): Boolean {
559+
val result = driver.executeScript(
560+
"""
561+
try {
562+
const local = APP.store.getState()['features/base/participants']?.local;
563+
const isRaised = local && local.raisedHandTimestamp > 0;
564+
APP.store.dispatch({
565+
type: 'LOCAL_PARTICIPANT_RAISE_HAND',
566+
raisedHandTimestamp: isRaised ? 0 : Date.now()
567+
});
568+
return true;
569+
} catch (e) {
570+
return e.message;
571+
}
572+
""".trimMargin()
573+
)
574+
return result is Boolean && result
575+
}
576+
365577
/**
366578
* Add the given key, value pair to the presence map and send a new presence
367579
* message

src/main/kotlin/org/jitsi/jibri/service/impl/SipGatewayJibriService.kt

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,19 @@ class SipGatewayJibriService(
9797
*/
9898
private val pjsuaClient = pjsuaClient ?: PjsuaClient(
9999
logger,
100-
PjsuaClientParams(sipGatewayServiceParams.sipClientParams)
100+
PjsuaClientParams(sipGatewayServiceParams.sipClientParams),
101+
onDtmfCommand = { command ->
102+
when (command) {
103+
"*6" -> {
104+
logger.info("Received *6 DTMF command from pjsua")
105+
this.jibriSelenium.handleDtmfStar6()
106+
}
107+
"*7" -> {
108+
logger.info("Received *7 DTMF command from pjsua")
109+
this.jibriSelenium.handleDtmfStar7()
110+
}
111+
}
112+
}
101113
)
102114

103115
/**

0 commit comments

Comments
 (0)