feat: Outbound Comms endpoints (#36377)

Co-authored-by: Kevin Aleman <11577696+KevLehman@users.noreply.github.com>
This commit is contained in:
Lucas Pelegrino 2025-07-10 10:37:22 -03:00 committed by GitHub
parent 7d153619fc
commit 2cec8acd5b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 129 additions and 28 deletions

View File

@ -0,0 +1,7 @@
---
"@rocket.chat/meteor": minor
"@rocket.chat/apps-engine": minor
"@rocket.chat/core-typings": minor
---
Adds new endpoints for outbound communications

View File

@ -1,10 +1,11 @@
import type { ValidOutboundProvider } from '@rocket.chat/core-typings';
import { Box } from '@rocket.chat/fuselage';
import ContactInfoDetailsEntry from './ContactInfoDetailsEntry';
import { parseOutboundPhoneNumber } from '../../../../../lib/voip/parseOutboundPhoneNumber';
type ContactInfoDetailsGroupProps = {
type: 'phone' | 'email';
type: ValidOutboundProvider;
label: string;
values: string[];
};

View File

@ -0,0 +1,26 @@
import type { IOutboundProvider, ValidOutboundProvider } from '@rocket.chat/core-typings';
import { ValidOutboundProviderList } from '@rocket.chat/core-typings';
import { OutboundMessageProvider } from '../../../../../../server/lib/OutboundMessageProvider';
export class OutboundMessageProviderService {
private readonly provider: OutboundMessageProvider;
constructor() {
this.provider = new OutboundMessageProvider();
}
private isProviderValid(type: any): type is ValidOutboundProvider {
return ValidOutboundProviderList.includes(type);
}
public listOutboundProviders(type?: string): IOutboundProvider[] {
if (type !== undefined && !this.isProviderValid(type)) {
throw new Error('Invalid type');
}
return this.provider.getOutboundMessageProviders(type);
}
}
export const outboundMessageProvider = new OutboundMessageProviderService();

View File

@ -0,0 +1,49 @@
import type { IOutboundProvider } from '@rocket.chat/core-typings';
import { ajv } from '@rocket.chat/rest-typings/src/v1/Ajv';
import { API } from '../../../../../app/api/server';
import { isGETOutboundProviderParams } from '../outboundcomms/rest';
import { outboundMessageProvider } from './lib/outbound';
import type { ExtractRoutesFromAPI } from '../../../../../app/api/server/ApiClass';
const outboundCommsEndpoints = API.v1.get(
'omnichannel/outbound/providers',
{
response: {
200: ajv.compile<{ providers: IOutboundProvider[] }>({
providers: {
type: 'array',
items: {
type: 'object',
properties: {
providerId: {
type: 'string',
},
providerName: {
type: 'string',
},
supportsTemplates: {
type: 'boolean',
},
providerType: {
type: 'string',
},
},
},
},
}),
},
query: isGETOutboundProviderParams,
authRequired: true,
},
async function action() {
const { type } = this.queryParams;
const providers = outboundMessageProvider.listOutboundProviders(type);
return API.v1.success({
providers,
});
},
);
export type OutboundCommsEndpoints = ExtractRoutesFromAPI<typeof outboundCommsEndpoints>;

View File

@ -1,24 +1,15 @@
import type { IOutboundProvider, IOutboundMessage, IOutboundProviderMetadata } from '@rocket.chat/core-typings';
import type { IOutboundMessage } from '@rocket.chat/core-typings';
import Ajv from 'ajv';
import type { OutboundCommsEndpoints } from '../api/outbound';
const ajv = new Ajv({
coerceTypes: true,
});
declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface Endpoints {
'/v1/omnichannel/outbound/providers': {
GET: (params: GETOutboundProviderParams) => IOutboundProvider[];
};
'/v1/omnichannel/outbound/providers/:id/metadata': {
GET: () => IOutboundProviderMetadata;
};
'/v1/omnichannel/outbound/providers/:id/message': {
// Note: we may need to adapt this type when the API is implemented and UI starts to use it
POST: (params: POSTOutboundMessageParams) => void;
};
}
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
interface Endpoints extends OutboundCommsEndpoints {}
}
type GETOutboundProviderParams = { type?: string };

View File

@ -3,16 +3,17 @@ import type {
IOutboundMessageProviders,
IOutboundPhoneMessageProvider,
} from '@rocket.chat/apps-engine/definition/outboundComunication';
import type { ValidOutboundProvider, IOutboundProvider } from '@rocket.chat/core-typings';
interface IOutboundMessageProvider {
registerPhoneProvider(provider: IOutboundPhoneMessageProvider): void;
registerEmailProvider(provider: IOutboundEmailMessageProvider): void;
getOutboundMessageProviders(type?: 'phone' | 'email'): IOutboundMessageProviders[];
getOutboundMessageProviders(type?: ValidOutboundProvider): IOutboundProvider[];
unregisterProvider(appId: string, providerType: string): void;
}
export class OutboundMessageProvider implements IOutboundMessageProvider {
private readonly outboundMessageProviders: Map<'phone' | 'email', IOutboundMessageProviders[]>;
private readonly outboundMessageProviders: Map<ValidOutboundProvider, IOutboundMessageProviders[]>;
constructor() {
this.outboundMessageProviders = new Map([
@ -29,15 +30,27 @@ export class OutboundMessageProvider implements IOutboundMessageProvider {
this.outboundMessageProviders.set('email', [...(this.outboundMessageProviders.get('email') || []), provider]);
}
public getOutboundMessageProviders(type?: 'phone' | 'email'): IOutboundMessageProviders[] {
public getOutboundMessageProviders(type?: ValidOutboundProvider): IOutboundProvider[] {
if (type) {
return Array.from(this.outboundMessageProviders.get(type)?.values() || []);
return Array.from(this.outboundMessageProviders.get(type)?.values() || []).map((provider) => ({
providerId: provider.appId,
providerName: provider.name,
providerType: provider.type,
...(provider.supportsTemplates && { supportsTemplates: provider.supportsTemplates }),
}));
}
return Array.from(this.outboundMessageProviders.values()).flatMap((providers) => providers);
return Array.from(this.outboundMessageProviders.values())
.flatMap((providers) => providers)
.map((provider) => ({
providerId: provider.appId,
providerName: provider.name,
supportsTemplates: provider.supportsTemplates,
providerType: provider.type,
}));
}
public unregisterProvider(appId: string, providerType: 'phone' | 'email'): void {
public unregisterProvider(appId: string, providerType: ValidOutboundProvider): void {
const providers = this.outboundMessageProviders.get(providerType);
if (!providers) {
return;

View File

@ -3,6 +3,7 @@ import type {
IOutboundPhoneMessageProvider,
} from '@rocket.chat/apps-engine/definition/outboundComunication';
import { expect } from 'chai';
import { describe, it, beforeEach } from 'mocha';
import sinon from 'sinon';
import { OutboundMessageProvider } from '../../../../server/lib/OutboundMessageProvider';
@ -28,7 +29,11 @@ describe('OutboundMessageProvider', () => {
const providers = outboundMessageProvider.getOutboundMessageProviders('phone');
expect(providers).to.have.lengthOf(1);
expect(providers[0]).to.deep.equal(phoneProvider);
expect(providers[0]).to.deep.equal({
providerId: '123',
providerName: 'Test Phone Provider',
providerType: 'phone',
});
});
it('should successfully register a email provider', () => {
@ -44,7 +49,11 @@ describe('OutboundMessageProvider', () => {
const providers = outboundMessageProvider.getOutboundMessageProviders('email');
expect(providers).to.have.lengthOf(1);
expect(providers[0]).to.deep.equal(emailProvider);
expect(providers[0]).to.deep.equal({
providerId: '123',
providerName: 'Test Email Provider',
providerType: 'email',
});
});
it('should list currently registered providers [unfiltered]', () => {
@ -69,8 +78,8 @@ describe('OutboundMessageProvider', () => {
const providers = outboundMessageProvider.getOutboundMessageProviders();
expect(providers).to.have.lengthOf(2);
expect(providers.some((provider) => provider.type === 'phone')).to.be.true;
expect(providers.some((provider) => provider.type === 'email')).to.be.true;
expect(providers.some((provider) => provider.providerType === 'phone')).to.be.true;
expect(providers.some((provider) => provider.providerType === 'email')).to.be.true;
});
it('should list currently registered providers [filtered by type]', () => {
@ -95,7 +104,7 @@ describe('OutboundMessageProvider', () => {
const providers = outboundMessageProvider.getOutboundMessageProviders('phone');
expect(providers).to.have.lengthOf(1);
expect(providers[0].type).to.equal('phone');
expect(providers[0].providerType).to.equal('phone');
});
it('should unregister a provider', () => {
@ -127,6 +136,6 @@ describe('OutboundMessageProvider', () => {
registeredProviders = outboundMessageProvider.getOutboundMessageProviders('phone');
expect(registeredProviders).to.have.lengthOf(1);
expect(registeredProviders.some((provider) => provider.appId !== '123')).to.be.true;
expect(registeredProviders.some((provider) => provider.providerId !== '123')).to.be.true;
});
});

View File

@ -13,6 +13,7 @@ interface IOutboundMessageProviderBase {
appId: string;
name: string;
documentationUrl?: string;
supportsTemplates?: boolean;
sendOutboundMessage(message: IOutboundMessage): Promise<void>;
}

View File

@ -107,10 +107,14 @@ type TemplateParameter =
export type IOutboundProvider = {
providerId: string;
providerName: string;
supportsTemplates: boolean;
supportsTemplates?: boolean;
providerType: 'phone' | 'email';
};
export type IOutboundProviderMetadata = IOutboundProvider & {
templates: Record<string, IOutboundProviderTemplate[]>;
};
export const ValidOutboundProviderList = ['phone', 'email'] as const;
export type ValidOutboundProvider = (typeof ValidOutboundProviderList)[number];