fix(apiserver): publish dial events under the call ref TrackCall registers

handleDial published to the calls.track subject keyed by channel.id, while
trackCall registers the client stream keyed by the ref CreateCall returned.
The two identifiers never match, so every dial event was dropped and the
statusStream from Calls.createCall stayed empty for the life of the call.

Resolve the CALL_REF channel variable, which the originate in runCallManager
and the local-ctx-common dialplan both set, and publish under that. Channels
with no call ref have no TrackCall request waiting on them, so the event is
skipped instead of published under an id nothing can match.

Refs #883
This commit is contained in:
dualfroz committed 2026-09-13 15:19:22 +02:00
1 parent e6e485bb10
commit 943658caef
2 files changed
+178 -1

No files matched your search

+14 -1
View File
@@ -143,7 +143,20 @@ class VoiceDispatcher {
}
async handleDial(event: Dial, channel: Channel) {
createHandleDialEventsWithNats(this.nc)(channel.id, event);
// TrackCall registers the stream under the ref CreateCall handed back to
// the client, so the event has to carry that ref and not the channel id.
const callRef = (
await createGetChannelVarWithoutThrow(channel)(ChannelVar.CALL_REF)
)?.value;
if (!callRef) {
logger.silly("no callRef found, ignoring handleDial event", {
channelId: channel?.id
});
return;
}
createHandleDialEventsWithNats(this.nc)(callRef, event);
}
async isHandledElsewhere(channel: Channel) {
@@ -0,0 +1,164 @@
/*
* Copyright (C) 2025 by Fonoster Inc (https://fonoster.com)
* http://github.com/fonoster/fonoster
*
* This file is part of Fonoster
*
* Licensed under the MIT License (the "License");
* you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* https://opensource.org/licenses/MIT
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DialStatus } from "@fonoster/common";
import * as chai from "chai";
import { expect } from "chai";
import chaiAsPromised from "chai-as-promised";
import type { Channel, Dial } from "ari-client";
import { NatsConnection } from "nats";
import { createSandbox } from "sinon";
import sinonChai from "sinon-chai";
import { getAriStub } from "../voice/helper";
chai.use(chaiAsPromised);
chai.use(sinonChai);
const sandbox = createSandbox();
const CALL_REF = "c21ff1ab-5b46-4d99-8879-fad1e1d02d0a";
const CHANNEL_ID = "df561aee-dcc1-44f4-a3c0-6caa09f2be0a";
// A NATS double that delivers whatever is published straight back to the
// subscriber, so the publisher and the subscriber run as one path.
const createLoopbackNats = () => {
const subscription = { callback: (_err: unknown, _msg: unknown) => {} };
const publish = sandbox
.stub()
.callsFake((_subject: string, payload: string) => {
subscription.callback(null, { json: () => JSON.parse(payload) });
});
return {
publish,
subscribe: sandbox.stub().returns(subscription)
} as unknown as NatsConnection & { publish: sinon.SinonStub };
};
const createChannel = (callRef?: string) =>
({
id: CHANNEL_ID,
getChannelVar: sandbox
.stub()
.callsFake(async ({ variable }: { variable: string }) => {
if (variable === "CALL_REF" && callRef) {
return { value: callRef };
}
throw new Error(`channel variable ${variable} not set`);
})
}) as unknown as Channel;
describe("@calls/trackCall for API-originated calls", function () {
afterEach(function () {
return sandbox.restore();
});
it("delivers dial events to the stream registered by TrackCall", async function () {
// Arrange
const { createTrackCall } = await import("../../src/calls/createTrackCall");
const { VoiceDispatcher } = await import("../../src/voice/VoiceDispatcher");
const nc = createLoopbackNats();
const call = {
write: sandbox.stub(),
end: sandbox.stub(),
request: { ref: CALL_REF }
};
const trackCall = createTrackCall(nc);
trackCall(call, () => {});
const voiceDispatcher = new VoiceDispatcher(
getAriStub(sandbox),
nc,
sandbox.stub()
);
// Act
await voiceDispatcher.handleDial(
{ dialstatus: "PROGRESS" } as unknown as Dial,
createChannel(CALL_REF)
);
// Assert
expect(call.write).to.have.been.calledOnceWith({
ref: CALL_REF,
status: DialStatus.PROGRESS
});
});
it("stops tracking the call after a final dial status", async function () {
// Arrange
const { createTrackCall } = await import("../../src/calls/createTrackCall");
const { VoiceDispatcher } = await import("../../src/voice/VoiceDispatcher");
const nc = createLoopbackNats();
const call = {
write: sandbox.stub(),
end: sandbox.stub(),
request: { ref: CALL_REF }
};
createTrackCall(nc)(call, () => {});
const voiceDispatcher = new VoiceDispatcher(
getAriStub(sandbox),
nc,
sandbox.stub()
);
// Act
await voiceDispatcher.handleDial(
{ dialstatus: "BUSY" } as unknown as Dial,
createChannel(CALL_REF)
);
// Assert
expect(call.write).to.have.been.calledOnceWith({
ref: CALL_REF,
status: DialStatus.BUSY
});
await voiceDispatcher.handleDial(
{ dialstatus: "PROGRESS" } as unknown as Dial,
createChannel(CALL_REF)
);
expect(call.write).to.have.been.calledOnce;
});
it("publishes nothing for a channel without a call ref", async function () {
// Arrange
const { VoiceDispatcher } = await import("../../src/voice/VoiceDispatcher");
const nc = createLoopbackNats();
const voiceDispatcher = new VoiceDispatcher(
getAriStub(sandbox),
nc,
sandbox.stub()
);
// Act
await voiceDispatcher.handleDial(
{ dialstatus: "PROGRESS" } as unknown as Dial,
createChannel()
);
// Assert
expect(nc.publish).to.not.have.been.called;
});
});