Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/great-actors-double.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@rocket.chat/freeswitch': patch
'@rocket.chat/meteor': patch
---

Fixes FreeSwitch event parser to automatically reconnect when connection is lost
143 changes: 120 additions & 23 deletions apps/meteor/ee/server/local-services/voip-freeswitch/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,14 @@ import type {
AtLeast,
} from '@rocket.chat/core-typings';
import { isKnownFreeSwitchEventType } from '@rocket.chat/core-typings';
import { getDomain, getUserPassword, getExtensionList, getExtensionDetails, listenToEvents } from '@rocket.chat/freeswitch';
import {
getDomain,
getUserPassword,
getExtensionList,
getExtensionDetails,
FreeSwitchEventClient,
type FreeSwitchOptions,
} from '@rocket.chat/freeswitch';
import type { InsertionModel } from '@rocket.chat/model-typings';
import { FreeSwitchCall, FreeSwitchEvent, Users } from '@rocket.chat/models';
import { objectMap, wrapExceptions } from '@rocket.chat/tools';
Expand All @@ -25,47 +32,135 @@ export class VoipFreeSwitchService extends ServiceClassInternal implements IVoip

private serviceStarter: ServiceStarter;

private eventClient: FreeSwitchEventClient | null = null;

private wasEverConnected = false;

constructor() {
super();

this.serviceStarter = new ServiceStarter(() => this.startEvents());
this.serviceStarter = new ServiceStarter(
async () => {
// Delay start to ensure setting values are up-to-date in the cache
setImmediate(() => this.startEvents());
},
async () => this.stopEvents(),
);
this.onEvent('watch.settings', async ({ setting }): Promise<void> => {
if (setting._id === 'VoIP_TeamCollab_Enabled' && setting.value === true) {
void this.serviceStarter.start();
if (setting._id === 'VoIP_TeamCollab_Enabled') {
if (setting.value !== true) {
void this.serviceStarter.stop();
return;
}

if (setting.value === true) {
void this.serviceStarter.start();
return;
}
}

if (setting._id === 'VoIP_TeamCollab_FreeSwitch_Host') {
// Re-connect if the host changes
if (this.eventClient && this.eventClient.host !== setting.value) {
this.stopEvents();
}

if (setting.value) {
void this.serviceStarter.start();
}
}

// If any other freeswitch setting changes, only reconnect if it's not yet connected
if (setting._id.startsWith('VoIP_TeamCollab_FreeSwitch_')) {
if (!this.eventClient?.isReady()) {
this.stopEvents();
void this.serviceStarter.start();
}
}
});
}

private listening = false;

public async started(): Promise<void> {
void this.serviceStarter.start();
}

private async startEvents(): Promise<void> {
if (this.listening) {
if (this.eventClient) {
if (!this.eventClient.isDone()) {
return;
}

const client = this.eventClient;
this.eventClient = null;
client.endConnection();
}

const options = wrapExceptions(() => this.getConnectionSettings()).suppress();
if (!options) {
this.wasEverConnected = false;
return;
}

try {
// #ToDo: Reconnection
// #ToDo: Only connect from one rocket.chat instance
await listenToEvents(
async (...args) => wrapExceptions(() => this.onFreeSwitchEvent(...args)).suppress(),
this.getConnectionSettings(),
);
this.listening = true;
} catch (_e) {
this.listening = false;
this.initializeEventClient(options);
}

private retryEventsLater(): void {
// Try to re-establish connection after some time
setTimeout(
() => {
void this.startEvents();
},
this.wasEverConnected ? 3000 : 20_000,
);
}

private initializeEventClient(options: FreeSwitchOptions): void {
const client = FreeSwitchEventClient.listenToEvents(options);
this.eventClient = client;

client.on('ready', () => {
if (this.eventClient !== client) {
return;
}
this.wasEverConnected = true;
});

client.on('end', () => {
if (this.eventClient && this.eventClient !== client) {
return;
}

this.eventClient = null;
this.retryEventsLater();
});

client.on('event', async ({ eventName, eventData }) => {
if (this.eventClient !== client) {
return;
}

await wrapExceptions(() =>
this.onFreeSwitchEvent(eventName as string, eventData as unknown as Record<string, string | undefined>),
).suppress();
});
}

private stopEvents(): void {
if (!this.eventClient) {
return;
}

this.eventClient.endConnection();
this.wasEverConnected = false;
this.eventClient = null;
}

private getConnectionSettings(): { host: string; port: number; password: string; timeout: number } {
if (!settings.get('VoIP_TeamCollab_Enabled') && !process.env.FREESWITCHIP) {
private getConnectionSettings(): FreeSwitchOptions {
if (!settings.get('VoIP_TeamCollab_Enabled')) {
throw new Error('VoIP is disabled.');
}

const host = process.env.FREESWITCHIP || settings.get<string>('VoIP_TeamCollab_FreeSwitch_Host');
const host = settings.get<string>('VoIP_TeamCollab_FreeSwitch_Host');
if (!host) {
throw new Error('VoIP is not properly configured.');
}
Expand All @@ -75,14 +170,16 @@ export class VoipFreeSwitchService extends ServiceClassInternal implements IVoip
const password = settings.get<string>('VoIP_TeamCollab_FreeSwitch_Password');

return {
host,
port,
socketOptions: {
host,
port,
},
password,
timeout,
};
}

private async onFreeSwitchEvent(eventName: string, data: Record<string, string | undefined>): Promise<void> {
public async onFreeSwitchEvent(eventName: string, data: Record<string, string | undefined>): Promise<void> {
const uniqueId = data['Unique-ID'];
if (!uniqueId) {
return;
Expand Down
2 changes: 1 addition & 1 deletion packages/freeswitch/src/FreeSwitchOptions.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export type FreeSwitchOptions = { host?: string; port?: number; password?: string; timeout?: number };
export type FreeSwitchOptions = { socketOptions: { host: string; port: number }; password: string; timeout?: number };
4 changes: 2 additions & 2 deletions packages/freeswitch/src/commands/getDomain.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { StringMap } from 'esl';

import type { FreeSwitchOptions } from '../FreeSwitchOptions';
import { FreeSwitchApiClient } from '../esl';
import { logger } from '../logger';
import { runCommand } from '../runCommand';

export function getCommandGetDomain(): string {
return 'eval ${domain}';
Expand All @@ -20,6 +20,6 @@ export function parseDomainResponse(response: StringMap): string {
}

export async function getDomain(options: FreeSwitchOptions): Promise<string> {
const response = await runCommand(options, getCommandGetDomain());
const response = await FreeSwitchApiClient.runSingleCommand(options, getCommandGetDomain());
return parseDomainResponse(response);
}
4 changes: 2 additions & 2 deletions packages/freeswitch/src/commands/getExtensionDetails.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { FreeSwitchExtension } from '@rocket.chat/core-typings';

import type { FreeSwitchOptions } from '../FreeSwitchOptions';
import { runCommand } from '../runCommand';
import { FreeSwitchApiClient } from '../esl';
import { mapUserData } from '../utils/mapUserData';
import { parseUserList } from '../utils/parseUserList';

Expand All @@ -14,7 +14,7 @@ export async function getExtensionDetails(
requestParams: { extension: string; group?: string },
): Promise<FreeSwitchExtension> {
const { extension, group } = requestParams;
const response = await runCommand(options, getCommandListFilteredUser(extension, group));
const response = await FreeSwitchApiClient.runSingleCommand(options, getCommandListFilteredUser(extension, group));

const users = parseUserList(response);

Expand Down
4 changes: 2 additions & 2 deletions packages/freeswitch/src/commands/getExtensionList.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { FreeSwitchExtension } from '@rocket.chat/core-typings';

import type { FreeSwitchOptions } from '../FreeSwitchOptions';
import { runCommand } from '../runCommand';
import { FreeSwitchApiClient } from '../esl';
import { mapUserData } from '../utils/mapUserData';
import { parseUserList } from '../utils/parseUserList';

Expand All @@ -10,7 +10,7 @@ export function getCommandListUsers(): string {
}

export async function getExtensionList(options: FreeSwitchOptions): Promise<FreeSwitchExtension[]> {
const response = await runCommand(options, getCommandListUsers());
const response = await FreeSwitchApiClient.runSingleCommand(options, getCommandListUsers());
const users = parseUserList(response);

return users.map((item) => mapUserData(item));
Expand Down
4 changes: 2 additions & 2 deletions packages/freeswitch/src/commands/getUserPassword.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import type { StringMap } from 'esl';

import type { FreeSwitchOptions } from '../FreeSwitchOptions';
import { logger } from '../logger';
import { runCallback } from '../runCommand';
import { getCommandGetDomain, parseDomainResponse } from './getDomain';
import { FreeSwitchApiClient } from '../esl';

export function getCommandGetUserPassword(user: string, domain = 'rocket.chat'): string {
return `user_data ${user}@${domain} param password`;
Expand All @@ -21,7 +21,7 @@ export function parsePasswordResponse(response: StringMap): string {
}

export async function getUserPassword(options: FreeSwitchOptions, user: string): Promise<string> {
return runCallback(options, async (runCommand) => {
return FreeSwitchApiClient.runCallback(options, async (runCommand) => {
const domainResponse = await runCommand(getCommandGetDomain());
const domain = parseDomainResponse(domainResponse);

Expand Down
87 changes: 0 additions & 87 deletions packages/freeswitch/src/connect.ts

This file was deleted.

Loading
Loading