diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e5849a32..41f5141b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,8 +6,8 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci diff --git a/lib/tasks/conference.js b/lib/tasks/conference.js index d1bd26c2..42580f58 100644 --- a/lib/tasks/conference.js +++ b/lib/tasks/conference.js @@ -60,6 +60,8 @@ class Conference extends Task { this.emitter = new Emitter(); this.results = {}; + this.coaching = []; + this.speakOnlyTo = this.data.speakOnlyTo; // transferred from another server in order to bridge to a local caller? if (this.data._ && this.data._.connectTime) { @@ -348,7 +350,7 @@ class Conference extends Task { Object.assign(opts, {flags: { ...(this.endConferenceOnExit && {endconf: true}), ...(this.startConferenceOnEnter && {moderator: true}), - ...((this.joinMuted || this.data.speakOnlyTo) && {joinMuted: true}), + ...((this.joinMuted || this.speakOnlyTo) && {joinMuted: true}), }}); /** @@ -361,7 +363,7 @@ class Conference extends Task { try { const {memberId, confUuid} = await this.ep.join(this.confName, opts); this.logger.debug({memberId, confUuid}, `Conference:_joinConference: successfully joined ${this.confName}`); - this.memberId = memberId; + this.memberId = parseInt(memberId, 10); this.confUuid = confUuid; // set a tag for this member, if provided @@ -395,8 +397,8 @@ class Conference extends Task { .catch((err) => {}); } - if (this.data.speakOnlyTo) { - this.setCoachMode(this.data.speakOnlyTo); + if (this.speakOnlyTo) { + this.setCoachMode(this.speakOnlyTo); } } catch (err) { this.logger.error(err, `Failed to join conference ${this.confName}`); @@ -586,7 +588,7 @@ class Conference extends Task { const response = await this.ep.api('conference', [this.confName, 'get', 'count']); if (response.body && confNoMatch(response.body)) this.participantCount = 0; else if (response.body && /^\d+$/.test(response.body)) this.participantCount = parseInt(response.body) - 1; - this.logger.debug({response}, `Conference:_doFinalMemberCheck conference count ${this.participantCount}`); + this.logger.debug(`Conference:_doFinalMemberCheck conference count ${this.participantCount}`); } catch (err) { this.logger.info({err}, 'Conference:_doFinalMemberCheck error retrieving count (we were probably kicked'); } @@ -699,7 +701,12 @@ class Conference extends Task { // conference event handlers _onAddMember(logger, cs, evt) { - logger.debug({evt}, `Conference:_onAddMember - member added to conference ${this.confName}`); + const memberId = parseInt(evt.getHeader('Member-ID')) ; + if (this.speakOnlyTo) { + logger.debug(`Conference:_onAddMember - member ${memberId} added to ${this.confName}, updating coaching mode`); + this.setCoachMode(this.speakOnlyTo).catch(() => {}); + } + else logger.debug(`Conference:_onAddMember - member ${memberId} added to conference ${this.confName}`); } _onDelMember(logger, cs, evt) { const memberId = parseInt(evt.getHeader('Member-ID')) ; @@ -734,13 +741,46 @@ class Conference extends Task { } } + _onTag(logger, cs, evt) { + const memberId = parseInt(evt.getHeader('Member-ID')) ; + const tag = evt.getHeader('Tag') || ''; + if (memberId !== this.memberId && this.speakOnlyTo) { + logger.info(`Conference:_onTag - member ${memberId} set tag to '${tag }'; updating coach mode accordingly`); + this.setCoachMode(this.speakOnlyTo).catch(() => {}); + } + } + + /** + * Set the conference to "coaching" mode, where the audio of the participant is only heard + * by a subset of the participants in the conference. + * We do this by first getting all of the members who do *not* have this tag, and then + * we configure this members audio to not be sent to them. + * @param {string} speakOnlyTo - tag of the members who should receive our audio + * + * N.B.: this feature requires jambonz patches to freeswitch mod_conference + */ async setCoachMode(speakOnlyTo) { + this.speakOnlyTo = speakOnlyTo; + if (!this.memberId) { + this.logger.info('Conference:_setCoachMode: no member id yet'); + return; + } try { - const response = await this.ep.api('conference', [this.confName, 'gettag', speakOnlyTo, 'nomatch']); - this.logger.info(`Conference:_setCoachMode: my audio will only be sent to particpants ${response}`); - await this.ep.api('conference', [this.confName, 'relate', this.memberId, response, 'nospeak']); - this.speakOnlyTo = speakOnlyTo; - this.coaching = response; + const members = (await this.ep.getNonMatchingConfParticipants(this.confName, speakOnlyTo)) + .filter((m) => m !== this.memberId); + if (members.length === 0) { + this.logger.info({members}, 'Conference:_setCoachMode: all participants have the tag, so all will hear me'); + if (this.coaching.length) { + await this.ep.api('conference', [this.confName, 'relate', this.memberId, this.coaching.join(','), 'clear']); + this.coaching = []; + } + } + else { + const memberList = members.join(','); + this.logger.info(`Conference:_setCoachMode: my audio will NOT be sent to ${memberList}`); + await this.ep.api('conference', [this.confName, 'relate', this.memberId, memberList, 'nospeak']); + this.coaching = members; + } } catch (err) { this.logger.error({err, speakOnlyTo}, '_setCoachMode: Error'); } @@ -748,14 +788,14 @@ class Conference extends Task { async clearCoachMode() { try { - if (!this.coaching) { + if (this.coaching.length === 0) { this.logger.info('Conference:_clearCoachMode: no coaching mode to clear'); return; } this.logger.info(`Conference:_clearCoachMode: now sending my audio to all, including ${this.coaching}`); await this.ep.api('conference', [this.confName, 'relate', this.memberId, this.coaching, 'clear']); this.speakOnlyTo = null; - this.coaching = null; + this.coaching = []; } catch (err) { this.logger.error({err}, '_clearCoachMode: Error'); } diff --git a/package-lock.json b/package-lock.json index 75ac826a..db866a52 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,7 @@ "bent": "^7.3.12", "debug": "^4.3.4", "deepcopy": "^2.1.0", - "drachtio-fsmrf": "^3.0.40", + "drachtio-fsmrf": "^3.0.41", "drachtio-srf": "^4.5.31", "express": "^4.19.2", "express-validator": "^7.0.1", @@ -4606,9 +4606,9 @@ } }, "node_modules/drachtio-fsmrf": { - "version": "3.0.40", - "resolved": "https://registry.npmjs.org/drachtio-fsmrf/-/drachtio-fsmrf-3.0.40.tgz", - "integrity": "sha512-Mlteu/e1fa1Y4ClkVehMGnto+AKp5NhgIgwKSkFlaCi7Xl8qOqK5IbzgHyPZ2pDE2q7euieNGo+vtB2dUMIIog==", + "version": "3.0.41", + "resolved": "https://registry.npmjs.org/drachtio-fsmrf/-/drachtio-fsmrf-3.0.41.tgz", + "integrity": "sha512-LemyjXtOnd5tOcQSmMgGtKUSdFAM3pLkEwGtV1pRnRhLTS3oERqQwuqRv0LurfZQ38WpuMOrUBhCSb3+uW9t/w==", "dependencies": { "camel-case": "^4.1.2", "debug": "^2.6.9", @@ -13678,9 +13678,9 @@ } }, "drachtio-fsmrf": { - "version": "3.0.40", - "resolved": "https://registry.npmjs.org/drachtio-fsmrf/-/drachtio-fsmrf-3.0.40.tgz", - "integrity": "sha512-Mlteu/e1fa1Y4ClkVehMGnto+AKp5NhgIgwKSkFlaCi7Xl8qOqK5IbzgHyPZ2pDE2q7euieNGo+vtB2dUMIIog==", + "version": "3.0.41", + "resolved": "https://registry.npmjs.org/drachtio-fsmrf/-/drachtio-fsmrf-3.0.41.tgz", + "integrity": "sha512-LemyjXtOnd5tOcQSmMgGtKUSdFAM3pLkEwGtV1pRnRhLTS3oERqQwuqRv0LurfZQ38WpuMOrUBhCSb3+uW9t/w==", "requires": { "camel-case": "^4.1.2", "debug": "^2.6.9", diff --git a/package.json b/package.json index 14299196..96b7956c 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "bent": "^7.3.12", "debug": "^4.3.4", "deepcopy": "^2.1.0", - "drachtio-fsmrf": "^3.0.40", + "drachtio-fsmrf": "^3.0.41", "drachtio-srf": "^4.5.31", "express": "^4.19.2", "express-validator": "^7.0.1",