├── lib ├── version.json ├── fax │ ├── validators │ │ ├── index.ts │ │ ├── validatePdfFileContent.ts │ │ └── validatePdfFileContent.test.ts │ ├── index.ts │ ├── errors │ │ └── handleFaxError.ts │ ├── fax.types.ts │ ├── testfiles │ │ └── validPDFBuffer.ts │ ├── fax.ts │ └── fax.test.ts ├── fluent │ ├── index.ts │ └── webhook.ts ├── rtcm │ ├── validator │ │ ├── index.ts │ │ ├── validateDTMFSequence.ts │ │ └── validateDTMFSequence.test.ts │ ├── index.ts │ ├── errors │ │ └── handleRtcmError.ts │ ├── rtcm.types.ts │ ├── rtcm.ts │ └── rtcm.test.ts ├── sms │ ├── index.ts │ ├── validators │ │ ├── validateSendAt.ts │ │ └── validateSendAt.test.ts │ ├── errors │ │ └── handleSmsError.ts │ ├── sms.types.ts │ ├── sms.ts │ └── sms.test.ts ├── call │ ├── index.ts │ ├── call.types.ts │ ├── errors │ │ └── handleCallError.ts │ ├── call.ts │ ├── validators │ │ ├── validateCallData.ts │ │ └── validateCallData.test.ts │ └── call.test.ts ├── devices │ ├── index.ts │ ├── devices.ts │ ├── devices.types.ts │ └── devices.test.ts ├── history │ ├── index.ts │ ├── errors │ │ └── handleHistoryError.ts │ ├── history.ts │ ├── history.types.ts │ └── history.test.ts ├── numbers │ ├── index.ts │ ├── errors │ │ └── handleNumbersError.ts │ ├── numbers.ts │ ├── numbers.types.ts │ └── numbers.test.ts ├── webhook │ ├── index.ts │ ├── webhook.errors.ts │ ├── signatureVerifier.ts │ ├── audioUtils.ts │ ├── webhook.types.ts │ └── webhook.ts ├── contacts │ ├── index.ts │ ├── helpers │ │ ├── Address.ts │ │ └── vCardHelper.ts │ ├── errors │ │ └── handleContactsError.ts │ ├── contacts.types.ts │ ├── contacts.test.examples.ts │ └── contacts.ts ├── core │ ├── errors │ │ ├── index.ts │ │ ├── handleError.ts │ │ ├── ErrorMessage.ts │ │ └── handleError.test.ts │ ├── sipgateIOClient │ │ ├── index.ts │ │ ├── sipgateIOClient.types.ts │ │ └── sipgateIOClient.ts │ ├── index.ts │ ├── validator │ │ ├── validator.types.ts │ │ ├── index.ts │ │ ├── validateTokenID.ts │ │ ├── validatePassword.ts │ │ ├── validatePersonalAccessToken.ts │ │ ├── validateEmail.ts │ │ ├── validatePhoneNumber.ts │ │ ├── validateOAuthToken.ts │ │ ├── validatePassword.test.ts │ │ ├── validatePhoneNumber.test.ts │ │ ├── validateTokenID.test.ts │ │ ├── validateExtension.ts │ │ ├── validateExtension.test.ts │ │ ├── validatePersonalAccessToken.test.ts │ │ ├── validateEmail.test.ts │ │ └── validateOAuthToken.test.ts │ └── core.types.ts ├── voicemails │ ├── index.ts │ ├── voicemails.types.ts │ ├── voicemails.ts │ └── voicemails.test.ts ├── webhook-settings │ ├── index.ts │ ├── validators │ │ ├── ValidatorMessages.ts │ │ ├── validateWebhookUrl.ts │ │ ├── validateWhitelistExtensions.ts │ │ └── validateWebhookUrl.test.ts │ ├── errors │ │ └── handleWebhookSettingError.ts │ ├── webhookSettings.types.ts │ ├── webhookSettings.ts │ └── webhookSettings.test.ts ├── index.ts ├── utils.ts ├── utils.test.ts └── browser.ts ├── .prettierrc ├── .husky └── pre-commit ├── run_unit_tests.sh ├── update_version.sh ├── .github ├── PULL_REQUEST_TEMPLATE.md ├── workflows │ ├── ci.yml │ └── github-actions.yml └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── tsconfig.json ├── jest.config.js ├── .gitignore ├── LICENSE ├── .eslintrc.json ├── package.json ├── CHANGELOG.md └── bundle └── sipgate-io.test.js /lib/version.json: -------------------------------------------------------------------------------- 1 | { "version": "2.15.2" } 2 | -------------------------------------------------------------------------------- /lib/fax/validators/index.ts: -------------------------------------------------------------------------------- 1 | export * from '.'; 2 | -------------------------------------------------------------------------------- /lib/fluent/index.ts: -------------------------------------------------------------------------------- 1 | export { FluentWebhookServer } from './webhook'; 2 | -------------------------------------------------------------------------------- /lib/rtcm/validator/index.ts: -------------------------------------------------------------------------------- 1 | export * from './validateDTMFSequence'; 2 | -------------------------------------------------------------------------------- /lib/fax/index.ts: -------------------------------------------------------------------------------- 1 | export * from './fax'; 2 | export * from './fax.types'; 3 | -------------------------------------------------------------------------------- /lib/sms/index.ts: -------------------------------------------------------------------------------- 1 | export * from './sms'; 2 | export * from './sms.types'; 3 | -------------------------------------------------------------------------------- /lib/call/index.ts: -------------------------------------------------------------------------------- 1 | export * from './call.types'; 2 | export * from './call'; 3 | -------------------------------------------------------------------------------- /lib/rtcm/index.ts: -------------------------------------------------------------------------------- 1 | export * from './rtcm'; 2 | export * from './rtcm.types'; 3 | -------------------------------------------------------------------------------- /lib/devices/index.ts: -------------------------------------------------------------------------------- 1 | export * from './devices'; 2 | export * from './devices.types'; 3 | -------------------------------------------------------------------------------- /lib/history/index.ts: -------------------------------------------------------------------------------- 1 | export * from './history'; 2 | export * from './history.types'; 3 | -------------------------------------------------------------------------------- /lib/numbers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './numbers'; 2 | export * from './numbers.types'; 3 | -------------------------------------------------------------------------------- /lib/webhook/index.ts: -------------------------------------------------------------------------------- 1 | export * from './webhook'; 2 | export * from './webhook.types'; 3 | -------------------------------------------------------------------------------- /lib/contacts/index.ts: -------------------------------------------------------------------------------- 1 | export * from './contacts'; 2 | export * from './contacts.types'; 3 | -------------------------------------------------------------------------------- /lib/core/errors/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ErrorMessage'; 2 | export * from './handleError'; 3 | -------------------------------------------------------------------------------- /lib/voicemails/index.ts: -------------------------------------------------------------------------------- 1 | export * from './voicemails'; 2 | export * from './voicemails.types'; 3 | -------------------------------------------------------------------------------- /lib/webhook-settings/index.ts: -------------------------------------------------------------------------------- 1 | export * from './webhookSettings'; 2 | export * from './webhookSettings.types'; 3 | -------------------------------------------------------------------------------- /lib/core/sipgateIOClient/index.ts: -------------------------------------------------------------------------------- 1 | export * from './sipgateIOClient'; 2 | export * from './sipgateIOClient.types'; 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "singleQuote": true, 4 | "useTabs": true, 5 | "printWidth": 80 6 | } 7 | -------------------------------------------------------------------------------- /lib/core/index.ts: -------------------------------------------------------------------------------- 1 | export * from './errors'; 2 | export * from './core.types'; 3 | export * from './sipgateIOClient'; 4 | -------------------------------------------------------------------------------- /lib/core/validator/validator.types.ts: -------------------------------------------------------------------------------- 1 | export type ValidationResult = 2 | | { isValid: true } 3 | | { isValid: false; cause: string }; 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | ./update_version.sh && ./run_unit_tests.sh && ./node_modules/.bin/lint-staged 5 | -------------------------------------------------------------------------------- /run_unit_tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | nodeVersion=$(node -v) 4 | 5 | if [[ $nodeVersion == *"v10"* ]]; then 6 | npm run test:unit:noDom 7 | else 8 | npm run test:unit 9 | fi 10 | 11 | -------------------------------------------------------------------------------- /update_version.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | grep '"version":' package.json | sed -e 's/\(.*\),/{\1}/' > lib/version.json 4 | ./node_modules/.bin/prettier --write lib/version.json 5 | git add lib/version.json 6 | -------------------------------------------------------------------------------- /lib/webhook-settings/validators/ValidatorMessages.ts: -------------------------------------------------------------------------------- 1 | export enum ValidatorMessages { 2 | INVALID_EXTENSION_FOR_WEBHOOKS = "Whitelist allows only 'p' and 'g' extensions", 3 | INVALID_WEBHOOK_URL = 'Invalid webhook URL', 4 | } 5 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## What is the purpose of the change 2 | 3 | Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat. 4 | 5 | ## Brief change log 6 | 7 | - 1… 8 | - 2… 9 | -------------------------------------------------------------------------------- /lib/voicemails/voicemails.types.ts: -------------------------------------------------------------------------------- 1 | export interface VoicemailsModule { 2 | getVoicemails(): Promise; 3 | } 4 | 5 | export interface Voicemail { 6 | id: string; 7 | alias: string; 8 | belongsToEndpoint: { 9 | extension: string; 10 | type: string; 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /lib/core/validator/index.ts: -------------------------------------------------------------------------------- 1 | export * from './validator.types'; 2 | export * from './validateEmail'; 3 | export * from './validateExtension'; 4 | export * from './validateOAuthToken'; 5 | export * from './validateTokenID'; 6 | export * from './validatePassword'; 7 | export * from './validatePhoneNumber'; 8 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Run tests on pull request and auto merge dependabot pull requests 2 | 3 | on: pull_request 4 | 5 | permissions: write-all 6 | 7 | jobs: 8 | dependabot_merge: 9 | uses: sipgate-io/dependabot-automerge/.github/workflows/dependabot_automerge.yml@main 10 | secrets: inherit 11 | -------------------------------------------------------------------------------- /lib/core/core.types.ts: -------------------------------------------------------------------------------- 1 | export interface Pagination { 2 | offset?: number; 3 | limit?: number; 4 | } 5 | 6 | export interface PagedResponse { 7 | response: T; 8 | hasMore: boolean; 9 | } 10 | 11 | export interface UserInfo { 12 | sub: string; 13 | domain: string; 14 | masterSipId: string; 15 | locale: string; 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "declaration": true, 6 | "sourceMap": true, 7 | "outDir": "./dist", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "resolveJsonModule": true 11 | }, 12 | "include": ["./lib/**/*"], 13 | "exclude": ["node_modules", "./lib/**/*.test*.ts"] 14 | } 15 | -------------------------------------------------------------------------------- /lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from './browser'; 2 | 3 | export * from './fluent'; 4 | 5 | export { 6 | createWebhookModule, 7 | WebhookResponse, 8 | RejectReason, 9 | NewCallEvent, 10 | AnswerEvent, 11 | HangUpEvent, 12 | HangUpCause, 13 | WebhookDirection, 14 | DataEvent, 15 | ResponseObject, 16 | HandlerCallback, 17 | WebhookServer, 18 | ServerOptions, 19 | } from './webhook'; 20 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { Buffer } from 'buffer'; 2 | 3 | export const toBase64 = (input: string): string => { 4 | if (typeof btoa !== 'undefined') { 5 | return btoa(input); 6 | } else { 7 | return Buffer.from(input, 'binary').toString('base64'); 8 | } 9 | }; 10 | 11 | export const fromBase64 = (input: string): string => { 12 | return Buffer.from(input, 'base64').toString('binary'); 13 | }; 14 | -------------------------------------------------------------------------------- /lib/devices/devices.ts: -------------------------------------------------------------------------------- 1 | import { Device, DevicesModule } from './devices.types'; 2 | import { SipgateIOClient } from '../core'; 3 | 4 | export const createDevicesModule = ( 5 | client: SipgateIOClient 6 | ): DevicesModule => ({ 7 | getDevices: (webuserId): Promise => { 8 | return client 9 | .get<{ items: Device[] }>(`${webuserId}/devices`) 10 | .then((response) => response.items); 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /lib/rtcm/validator/validateDTMFSequence.ts: -------------------------------------------------------------------------------- 1 | import { RtcmErrorMessage } from '../errors/handleRtcmError'; 2 | import { ValidationResult } from '../../core/validator'; 3 | 4 | export const validateDTMFSequence = (sequence: string): ValidationResult => { 5 | if (/^[0-9A-D#*]+$/g.test(sequence)) { 6 | return { isValid: true }; 7 | } 8 | 9 | return { isValid: false, cause: RtcmErrorMessage.DTMF_INVALID_SEQUENCE }; 10 | }; 11 | -------------------------------------------------------------------------------- /lib/voicemails/voicemails.ts: -------------------------------------------------------------------------------- 1 | import { SipgateIOClient } from '../core/sipgateIOClient'; 2 | import { Voicemail, VoicemailsModule } from './voicemails.types'; 3 | 4 | export const createVoicemailsModule = ( 5 | client: SipgateIOClient 6 | ): VoicemailsModule => ({ 7 | getVoicemails(): Promise { 8 | return client 9 | .get<{ items: Voicemail[] }>('voicemails') 10 | .then((response) => response.items); 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /lib/numbers/errors/handleNumbersError.ts: -------------------------------------------------------------------------------- 1 | import { HttpError, handleCoreError } from '../../core'; 2 | 3 | export enum NumbersErrorMessage { 4 | BAD_REQUEST = 'Invalid pagination input', 5 | } 6 | 7 | export const handleNumbersError = (error: HttpError): Error => { 8 | if (error.response && error.response.status === 400) { 9 | return new Error(NumbersErrorMessage.BAD_REQUEST); 10 | } 11 | 12 | return handleCoreError(error); 13 | }; 14 | -------------------------------------------------------------------------------- /lib/core/validator/validateTokenID.ts: -------------------------------------------------------------------------------- 1 | import { ErrorMessage } from '../errors'; 2 | import { ValidationResult } from './validator.types'; 3 | 4 | export const validateTokenID = (tokenID: string): ValidationResult => { 5 | if (!tokenID.match(/^token-[a-zA-Z\d]{6}$/g)) { 6 | return { 7 | isValid: false, 8 | cause: `${ErrorMessage.VALIDATOR_INVALID_TOKEN_ID}: ${ 9 | tokenID || '' 10 | }`, 11 | }; 12 | } 13 | 14 | return { isValid: true }; 15 | }; 16 | -------------------------------------------------------------------------------- /lib/fax/errors/handleFaxError.ts: -------------------------------------------------------------------------------- 1 | import { HttpError, handleCoreError } from '../../core'; 2 | 3 | export enum FaxErrorMessage { 4 | FAX_NOT_FOUND = 'Fax was not found', 5 | NOT_A_FAX = 'History item is not a fax', 6 | } 7 | 8 | export const handleFaxError = (error: HttpError): Error => { 9 | if (error.response && error.response.status === 404) { 10 | return new Error(FaxErrorMessage.FAX_NOT_FOUND); 11 | } 12 | 13 | return handleCoreError(error); 14 | }; 15 | -------------------------------------------------------------------------------- /lib/core/errors/handleError.ts: -------------------------------------------------------------------------------- 1 | import { ErrorMessage } from './ErrorMessage'; 2 | import { HttpError } from '../sipgateIOClient'; 3 | 4 | export const handleCoreError = (error: HttpError): Error => { 5 | if (error.response && error.response.status === 401) { 6 | return new Error(ErrorMessage.HTTP_401); 7 | } 8 | 9 | if (error.response && error.response.status === 403) { 10 | return new Error(ErrorMessage.HTTP_403); 11 | } 12 | 13 | return new Error(error.message); 14 | }; 15 | -------------------------------------------------------------------------------- /lib/core/validator/validatePassword.ts: -------------------------------------------------------------------------------- 1 | import { ErrorMessage } from '../errors'; 2 | import { ValidationResult } from './validator.types'; 3 | 4 | const validatePassword = (password: string): ValidationResult => { 5 | const passwordIsValid = password.length > 0 && !password.includes(' '); 6 | 7 | if (!passwordIsValid) { 8 | return { 9 | isValid: false, 10 | cause: ErrorMessage.VALIDATOR_INVALID_PASSWORD, 11 | }; 12 | } 13 | 14 | return { isValid: true }; 15 | }; 16 | export { validatePassword }; 17 | -------------------------------------------------------------------------------- /lib/rtcm/errors/handleRtcmError.ts: -------------------------------------------------------------------------------- 1 | import { HttpError, handleCoreError } from '../../core'; 2 | 3 | export enum RtcmErrorMessage { 4 | CALL_NOT_FOUND = 'The requested Call could not be found', 5 | DTMF_INVALID_SEQUENCE = 'The provided DTMF sequence is invalid', 6 | } 7 | 8 | export const handleRtcmError = (error: HttpError): Error => { 9 | if (error.response && error.response.status === 404) { 10 | return new Error(RtcmErrorMessage.CALL_NOT_FOUND); 11 | } 12 | 13 | return handleCoreError(error); 14 | }; 15 | -------------------------------------------------------------------------------- /lib/webhook-settings/validators/validateWebhookUrl.ts: -------------------------------------------------------------------------------- 1 | import { ValidationResult } from '../../core/validator'; 2 | import { ValidatorMessages } from './ValidatorMessages'; 3 | 4 | const validateWebhookUrl = (url: string): ValidationResult => { 5 | const webhookUrlRegex = new RegExp(/^(http|https):\/\//i); 6 | 7 | if (!webhookUrlRegex.test(url)) { 8 | return { 9 | cause: `${ValidatorMessages.INVALID_WEBHOOK_URL}: ${url}`, 10 | isValid: false, 11 | }; 12 | } 13 | return { isValid: true }; 14 | }; 15 | export { validateWebhookUrl }; 16 | -------------------------------------------------------------------------------- /lib/devices/devices.types.ts: -------------------------------------------------------------------------------- 1 | export interface DevicesModule { 2 | getDevices: (webuserId: string) => Promise; 3 | } 4 | 5 | export type DeviceType = 'REGISTER' | 'MOBILE' | 'EXTERNAL'; 6 | 7 | export interface ActiveRouting { 8 | id: string; 9 | alias: string; 10 | } 11 | 12 | export interface Device { 13 | id: string; 14 | alias: string; 15 | type: DeviceType; 16 | dnd: boolean; 17 | online: boolean; 18 | callerId?: string; 19 | owner?: string; 20 | activePhonelines?: ActiveRouting[]; 21 | activeGroups?: ActiveRouting[]; 22 | } 23 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | projects: [ 3 | { 4 | preset: 'ts-jest', 5 | roots: ['./lib'], 6 | displayName: 'dom', 7 | testEnvironment: 'jsdom', 8 | testMatch: ['**/*.test?(.browser).ts'], 9 | }, 10 | { 11 | preset: 'ts-jest', 12 | roots: ['./lib'], 13 | displayName: 'node', 14 | testEnvironment: 'node', 15 | testMatch: ['**/*.test?(.node).ts'], 16 | }, 17 | { 18 | roots: ['./bundle'], 19 | displayName: 'bundle', 20 | testEnvironment: 'jsdom', 21 | testMatch: ['**/*.test.js'], 22 | }, 23 | ], 24 | }; 25 | -------------------------------------------------------------------------------- /lib/core/errors/ErrorMessage.ts: -------------------------------------------------------------------------------- 1 | export enum ErrorMessage { 2 | VALIDATOR_INVALID_EXTENSION = 'Invalid extension', 3 | VALIDATOR_INVALID_EMAIL = 'Invalid email', 4 | VALIDATOR_INVALID_PASSWORD = 'Invalid password', 5 | VALIDATOR_INVALID_PHONE_NUMBER = 'Invalid phone number (please provide number in E.164 format):', 6 | VALIDATOR_INVALID_OAUTH_TOKEN = 'The provided OAuth token is invalid', 7 | VALIDATOR_INVALID_TOKEN_ID = 'Invalid token id', 8 | VALIDATOR_INVALID_PERSONAL_ACCESS_TOKEN = 'Invalid personal access token', 9 | 10 | HTTP_401 = 'Unauthorized', 11 | HTTP_403 = 'Forbidden', 12 | } 13 | -------------------------------------------------------------------------------- /lib/core/validator/validatePersonalAccessToken.ts: -------------------------------------------------------------------------------- 1 | import { ErrorMessage } from '../errors'; 2 | import { ValidationResult } from './validator.types'; 3 | 4 | export const validatePersonalAccessToken = ( 5 | token: string 6 | ): ValidationResult => { 7 | if ( 8 | !token.match( 9 | /^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/gi 10 | ) 11 | ) { 12 | return { 13 | isValid: false, 14 | cause: `${ErrorMessage.VALIDATOR_INVALID_PERSONAL_ACCESS_TOKEN}: ${ 15 | token || '' 16 | }`, 17 | }; 18 | } 19 | 20 | return { isValid: true }; 21 | }; 22 | -------------------------------------------------------------------------------- /lib/webhook-settings/errors/handleWebhookSettingError.ts: -------------------------------------------------------------------------------- 1 | import { HttpError, handleCoreError } from '../../core'; 2 | 3 | export enum WebhookSettingsErrorMessage { 4 | WEBHOOK_SETTINGS_FEATURE_NOT_BOOKED = 'sipgateIO is not booked for your account (or insufficient scopes)', 5 | } 6 | 7 | export const handleWebhookSettingsError = ( 8 | error: HttpError 9 | ): Error => { 10 | if (error.response && error.response.status === 403) { 11 | return new Error( 12 | WebhookSettingsErrorMessage.WEBHOOK_SETTINGS_FEATURE_NOT_BOOKED 13 | ); 14 | } 15 | return handleCoreError(error); 16 | }; 17 | -------------------------------------------------------------------------------- /lib/core/validator/validateEmail.ts: -------------------------------------------------------------------------------- 1 | import { ErrorMessage } from '../errors'; 2 | import { ValidationResult } from './validator.types'; 3 | 4 | const validateEmail = (email: string): ValidationResult => { 5 | const emailRegex = new RegExp( 6 | /^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i 7 | ); 8 | 9 | if (!emailRegex.test(email)) { 10 | return { 11 | cause: `${ErrorMessage.VALIDATOR_INVALID_EMAIL}: ${email || ''}`, 12 | isValid: false, 13 | }; 14 | } 15 | 16 | return { isValid: true }; 17 | }; 18 | export { validateEmail }; 19 | -------------------------------------------------------------------------------- /lib/numbers/numbers.ts: -------------------------------------------------------------------------------- 1 | import { NumberResponse, NumbersModule } from './numbers.types'; 2 | import { Pagination } from '../core'; 3 | import { SipgateIOClient } from '..'; 4 | import { handleNumbersError } from './errors/handleNumbersError'; 5 | 6 | export const createNumbersModule = ( 7 | client: SipgateIOClient 8 | ): NumbersModule => ({ 9 | async getAllNumbers(pagination?: Pagination): Promise { 10 | return client 11 | .get('/numbers', { 12 | params: { 13 | ...pagination, 14 | }, 15 | }) 16 | .catch((error) => Promise.reject(handleNumbersError(error))); 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /lib/contacts/helpers/Address.ts: -------------------------------------------------------------------------------- 1 | import { Address } from '../contacts.types'; 2 | import { Email, PhoneNumber } from './../contacts.types'; 3 | 4 | export interface ContactVCard { 5 | firstname: string; 6 | lastname: string; 7 | phoneNumber: string; 8 | email?: string; 9 | organization: string[][]; 10 | address?: Address; 11 | } 12 | 13 | export interface AddressImport extends Address { 14 | type: string[]; 15 | } 16 | 17 | export interface ContactImport { 18 | firstname: string; 19 | lastname: string; 20 | organizations: string[][]; 21 | phoneNumbers: PhoneNumber[]; 22 | emails?: Email[]; 23 | addresses?: AddressImport[]; 24 | } 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Build artifacts ### 2 | dist/ 3 | 4 | ### Node.js ### 5 | node_modules/ 6 | 7 | .npm 8 | npm-debug.log* 9 | 10 | ### Typescript ### 11 | *.tsbuildinfo 12 | 13 | 14 | ### Visual Studio Code ### 15 | .vscode/ 16 | 17 | ### Jetbrains ### 18 | .idea/ 19 | 20 | ### Vim ### 21 | # Swap 22 | [._]*.s[a-v][a-z] 23 | [._]*.sw[a-p] 24 | [._]s[a-rt-v][a-z] 25 | [._]ss[a-gi-z] 26 | [._]sw[a-p] 27 | 28 | # Session 29 | Session.vim 30 | Sessionx.vim 31 | 32 | # Temporary 33 | .netrwhist 34 | *~ 35 | # Auto-generated tag files 36 | tags 37 | # Persistent undo 38 | [._]*.un~ 39 | 40 | #Bundle 41 | bundle/sipgate-io.js 42 | bundle/sipgate-io.min.js -------------------------------------------------------------------------------- /lib/call/call.types.ts: -------------------------------------------------------------------------------- 1 | interface BaseCallData { 2 | deviceId?: string; 3 | callerId?: string; 4 | } 5 | 6 | interface Callee { 7 | to: string; 8 | } 9 | 10 | interface Caller { 11 | from: string; 12 | } 13 | 14 | export type CallData = BaseCallData & Callee & Caller; 15 | 16 | export interface InitiateNewCallSessionResponse { 17 | sessionId: string; 18 | } 19 | 20 | export interface CallModule { 21 | initiate: ( 22 | newCallRequest: CallData 23 | ) => Promise; 24 | } 25 | 26 | export interface CallDTO { 27 | caller: string; 28 | callee: string; 29 | callerId?: string; 30 | deviceId?: string; 31 | } 32 | -------------------------------------------------------------------------------- /lib/history/errors/handleHistoryError.ts: -------------------------------------------------------------------------------- 1 | import { HttpError, handleCoreError } from '../../core'; 2 | 3 | export enum HistoryErrorMessage { 4 | BAD_REQUEST = 'Invalid filter or pagination input', 5 | EVENT_NOT_FOUND = 'The requested history event could not be found', 6 | } 7 | 8 | export const handleHistoryError = (error: HttpError): Error => { 9 | if (error.response && error.response.status === 400) { 10 | return new Error(HistoryErrorMessage.BAD_REQUEST); 11 | } 12 | 13 | if (error.response && error.response.status === 404) { 14 | return new Error(HistoryErrorMessage.EVENT_NOT_FOUND); 15 | } 16 | 17 | return handleCoreError(error); 18 | }; 19 | -------------------------------------------------------------------------------- /lib/webhook-settings/validators/validateWhitelistExtensions.ts: -------------------------------------------------------------------------------- 1 | import { ExtensionType, validateExtension } from '../../core/validator'; 2 | import { ValidatorMessages } from './ValidatorMessages'; 3 | 4 | const validateWhitelistExtensions = (extensions: string[]): void => { 5 | extensions.forEach((extension) => { 6 | const validationResult = validateExtension(extension, [ 7 | ExtensionType.PERSON, 8 | ExtensionType.GROUP, 9 | ]); 10 | if (!validationResult.isValid) { 11 | throw new Error( 12 | `${ValidatorMessages.INVALID_EXTENSION_FOR_WEBHOOKS}\n${validationResult.cause}: ${extension}` 13 | ); 14 | } 15 | }); 16 | }; 17 | 18 | export { validateWhitelistExtensions }; 19 | -------------------------------------------------------------------------------- /lib/webhook-settings/webhookSettings.types.ts: -------------------------------------------------------------------------------- 1 | export interface WebhookSettingsModule { 2 | setIncomingUrl: (url: string) => Promise; 3 | setOutgoingUrl: (url: string) => Promise; 4 | setWhitelist: (extensions: string[]) => Promise; 5 | setLog: (value: boolean) => Promise; 6 | clearIncomingUrl: () => Promise; 7 | clearOutgoingUrl: () => Promise; 8 | clearWhitelist: () => Promise; 9 | disableWhitelist: () => Promise; 10 | getWebhookSettings: () => Promise; 11 | } 12 | 13 | export interface WebhookSettings { 14 | incomingUrl: string; 15 | outgoingUrl: string; 16 | log: boolean; 17 | whitelist: string[] | null; 18 | } 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '[Bug] ' 5 | labels: bug 6 | assignees: sipgateio-team 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior. 14 | 15 | **Expected behavior** 16 | A clear and concise description of what you expected to happen. 17 | 18 | **Environment (please complete the following information):** 19 | 20 | - OS: [e.g. Windows, Linux, macOS] 21 | - Node.js Version [e.g. 6, 8, 10.15.3] 22 | - Library Version [e.g. 1.0.0] 23 | 24 | **Additional context** 25 | Add any other context about the problem here. 26 | -------------------------------------------------------------------------------- /lib/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { fromBase64, toBase64 } from './utils'; 2 | 3 | describe('utility functions', () => { 4 | it('should round-trip correctly', () => { 5 | const input = 'this is ä test'; 6 | expect(fromBase64(toBase64(input))).toBe(input); 7 | }); 8 | 9 | it('should correctly convert from string to base64', () => { 10 | const input = 'an example sentence'; 11 | const expected = 'YW4gZXhhbXBsZSBzZW50ZW5jZQ=='; 12 | expect(toBase64(input)).toBe(expected); 13 | }); 14 | 15 | it('should correctly convert from base64 to string', () => { 16 | const input = 'YW4gZXhhbXBsZSBzZW50ZW5jZQ=='; 17 | const expected = 'an example sentence'; 18 | expect(fromBase64(input)).toBe(expected); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '[FEATURE] ' 5 | labels: enhancement 6 | assignees: sipgateio-team 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /lib/fax/validators/validatePdfFileContent.ts: -------------------------------------------------------------------------------- 1 | import { ValidationResult } from '../../core/validator'; 2 | 3 | export enum FaxValidationMessage { 4 | INVALID_PDF_MIME_TYPE = 'Invalid PDF file', 5 | } 6 | 7 | // taken from https://github.com/MaraniMatias/isPDF 8 | const isValidPDF = (buffer: Buffer): boolean => { 9 | return buffer.lastIndexOf('%PDF-') === 0 && buffer.lastIndexOf('%%EOF') > -1; 10 | }; 11 | 12 | const validatePdfFileContent = (content: Buffer): ValidationResult => { 13 | const isPdf = isValidPDF(content); 14 | if (!isPdf) { 15 | return { 16 | cause: FaxValidationMessage.INVALID_PDF_MIME_TYPE, 17 | isValid: false, 18 | }; 19 | } 20 | 21 | return { isValid: true }; 22 | }; 23 | export { validatePdfFileContent }; 24 | -------------------------------------------------------------------------------- /lib/core/validator/validatePhoneNumber.ts: -------------------------------------------------------------------------------- 1 | import { ErrorMessage } from '../errors'; 2 | import { PhoneNumberFormat, PhoneNumberUtil } from 'google-libphonenumber'; 3 | import { ValidationResult } from './validator.types'; 4 | 5 | const validatePhoneNumber = (phoneNumber: string): ValidationResult => { 6 | const phoneNumberUtil = PhoneNumberUtil.getInstance(); 7 | try { 8 | const parsedPhoneNumber = phoneNumberUtil.parse(phoneNumber); 9 | phoneNumberUtil.format(parsedPhoneNumber, PhoneNumberFormat.E164); 10 | 11 | return { isValid: true }; 12 | } catch (exception) { 13 | return { 14 | cause: `${ErrorMessage.VALIDATOR_INVALID_PHONE_NUMBER}: ${phoneNumber}`, 15 | isValid: false, 16 | }; 17 | } 18 | }; 19 | export { validatePhoneNumber }; 20 | -------------------------------------------------------------------------------- /lib/webhook/webhook.errors.ts: -------------------------------------------------------------------------------- 1 | export enum WebhookErrorMessage { 2 | AUDIO_FORMAT_ERROR = 'Invalid audio format. Please use 16bit PCM WAVE mono audio at 8kHz.', 3 | INVALID_ORIGIN = 'Caution! IP address is not from sipgate', 4 | SERVERADDRESS_DOES_NOT_MATCH = 'Given serverAddress does not match with Webhook URLs at console.sipgate.com. Follow-Up events will likely fail.', 5 | SERVERADDRESS_MISSING_FOR_FOLLOWUPS = 'No serverAddress set. No Follow-Up Events will be sent.', 6 | SIPGATE_SIGNATURE_VERIFICATION_FAILED = 'Signature verification failed.', 7 | INVALID_DTMF_MAX_DIGITS = 'Invalid DTMF maxDigits. The max digits should be equal or greater than 1', 8 | INVALID_DTMF_TIMEOUT = 'Invalid DTMF timeout. The timeout should be equal or greater than 0', 9 | } 10 | -------------------------------------------------------------------------------- /lib/core/validator/validateOAuthToken.ts: -------------------------------------------------------------------------------- 1 | import { ErrorMessage } from '../errors'; 2 | import { ValidationResult } from './validator.types'; 3 | import { fromBase64 } from '../../utils'; 4 | 5 | export const validateOAuthToken = (token: string): ValidationResult => { 6 | if (!isValidToken(token)) { 7 | return { 8 | isValid: false, 9 | cause: ErrorMessage.VALIDATOR_INVALID_OAUTH_TOKEN, 10 | }; 11 | } 12 | 13 | return { isValid: true }; 14 | }; 15 | 16 | const isValidToken = (token: string): boolean => { 17 | try { 18 | const base64EncodedPayload = token 19 | .split('.')[1] 20 | .replace('-', '+') 21 | .replace('_', '/'); 22 | 23 | JSON.parse(fromBase64(base64EncodedPayload)); 24 | 25 | return true; 26 | } catch (error) { 27 | return false; 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /lib/sms/validators/validateSendAt.ts: -------------------------------------------------------------------------------- 1 | import { SmsErrorMessage } from '../errors/handleSmsError'; 2 | import { ValidationResult } from '../../core/validator'; 3 | 4 | const validateSendAt = (sendAt: Date): ValidationResult => { 5 | if (Number.isNaN(sendAt.getTime())) { 6 | return { 7 | cause: SmsErrorMessage.TIME_INVALID, 8 | isValid: false, 9 | }; 10 | } 11 | 12 | if (sendAt.getTime() < Date.now()) { 13 | return { 14 | cause: SmsErrorMessage.TIME_MUST_BE_IN_FUTURE, 15 | isValid: false, 16 | }; 17 | } 18 | 19 | if (sendAt.getTime() > Date.now() + 30 * 24 * 60 * 60 * 1000) { 20 | return { 21 | cause: SmsErrorMessage.TIME_TOO_FAR_IN_FUTURE, 22 | isValid: false, 23 | }; 24 | } 25 | 26 | return { isValid: true }; 27 | }; 28 | 29 | export { validateSendAt }; 30 | -------------------------------------------------------------------------------- /lib/numbers/numbers.types.ts: -------------------------------------------------------------------------------- 1 | import { Pagination } from '../core'; 2 | 3 | export interface NumbersModule { 4 | getAllNumbers: (pagination?: Pagination) => Promise; 5 | } 6 | 7 | export interface NumberResponse { 8 | items: NumberResponseItem[]; 9 | } 10 | 11 | export interface NumberResponseItem { 12 | id: string; 13 | number: string; 14 | localized: string; 15 | type: NumberResponseItemType; 16 | endpointId: string; 17 | endpointAlias: string; 18 | endpointUrl: string; 19 | mnpState?: NumberMnpState; 20 | portId?: number; 21 | } 22 | 23 | export enum NumberResponseItemType { 24 | MOBILE = 'MOBILE', 25 | LANDLINE = 'LANDLINE', 26 | QUICKDIAL = 'QUICKDIAL', 27 | INTERNATIONAL = 'INTERNATIONAL', 28 | } 29 | 30 | export interface NumberMnpState { 31 | isReleased: boolean; 32 | releasedUntil: Date; 33 | } 34 | -------------------------------------------------------------------------------- /lib/call/errors/handleCallError.ts: -------------------------------------------------------------------------------- 1 | import { HttpError, handleCoreError } from '../../core'; 2 | 3 | export enum CallErrorMessage { 4 | CALL_INVALID_EXTENSION = 'Cannot access extension - not found or forbidden', 5 | CALL_INSUFFICIENT_FUNDS = 'Insufficient funds', 6 | CALL_BAD_REQUEST = 'Invalid Call object', 7 | } 8 | 9 | export const handleCallError = (error: HttpError): Error => { 10 | if (error.response && error.response.status === 400) { 11 | return new Error(CallErrorMessage.CALL_BAD_REQUEST); 12 | } 13 | 14 | if (error.response && error.response.status === 402) { 15 | return new Error(CallErrorMessage.CALL_INSUFFICIENT_FUNDS); 16 | } 17 | 18 | if (error.response && error.response.status === 403) { 19 | return new Error(CallErrorMessage.CALL_INVALID_EXTENSION); 20 | } 21 | 22 | return handleCoreError(error); 23 | }; 24 | -------------------------------------------------------------------------------- /lib/sms/validators/validateSendAt.test.ts: -------------------------------------------------------------------------------- 1 | import { SmsErrorMessage } from '../errors/handleSmsError'; 2 | import { validateSendAt } from './validateSendAt'; 3 | 4 | describe('ValidateSendAt', () => { 5 | const inOneDay = new Date(Date.now() + 24 * 60 * 60 * 1000); 6 | const threeSecondsAgo = new Date(Date.now() - 3 * 1000); 7 | const inFortyDays = new Date(Date.now() + 40 * 24 * 60 * 60 * 1000); 8 | 9 | test.each` 10 | input | expected 11 | ${inOneDay} | ${{ isValid: true }} 12 | ${threeSecondsAgo} | ${{ isValid: false, cause: SmsErrorMessage.TIME_MUST_BE_IN_FUTURE }} 13 | ${inFortyDays} | ${{ isValid: false, cause: SmsErrorMessage.TIME_TOO_FAR_IN_FUTURE }} 14 | `( 15 | 'validator returns $expected when $input is validated', 16 | ({ input, expected }) => { 17 | expect(validateSendAt(input)).toEqual(expected); 18 | } 19 | ); 20 | }); 21 | -------------------------------------------------------------------------------- /lib/fax/validators/validatePdfFileContent.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FaxValidationMessage, 3 | validatePdfFileContent, 4 | } from './validatePdfFileContent'; 5 | import validPDFBuffer from '../testfiles/validPDFBuffer'; 6 | 7 | import { Buffer } from 'buffer'; 8 | 9 | describe('PDF file validation', () => { 10 | test('should return valid ValidationResult if pdf file content is valid', async () => { 11 | const validPdfFileContents = validPDFBuffer; 12 | 13 | expect(validatePdfFileContent(validPdfFileContents)).toEqual({ 14 | isValid: true, 15 | }); 16 | }); 17 | 18 | test('should return invalid ValidationResult if file content is invalid', async () => { 19 | const invalidPdfFileContents = Buffer.from('12ABC34'); 20 | 21 | expect(validatePdfFileContent(invalidPdfFileContents)).toEqual({ 22 | cause: FaxValidationMessage.INVALID_PDF_MIME_TYPE, 23 | isValid: false, 24 | }); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /lib/sms/errors/handleSmsError.ts: -------------------------------------------------------------------------------- 1 | import { HttpError, handleCoreError } from '../../core'; 2 | 3 | export enum SmsErrorMessage { 4 | INVALID_MESSAGE = 'Invalid SMS message', 5 | INVALID_EXTENSION = 'Invalid SMS extension', 6 | TIME_MUST_BE_IN_FUTURE = 'Scheduled time must be in future', 7 | TIME_TOO_FAR_IN_FUTURE = 'Scheduled time should not be further than 30 days in the future', 8 | TIME_INVALID = 'Invalid date format', 9 | NO_ASSIGNED_ID = 'smsId must be assigned', 10 | NO_DEFAULT_SENDER_ID = 'No default SmsId set', 11 | NUMBER_NOT_VERIFIED = 'Number is not verified yet', 12 | NUMBER_NOT_REGISTERED = 'Number is not registered as a sender ID in your account', 13 | } 14 | 15 | export const handleSmsError = (error: HttpError): Error => { 16 | if (error.response && error.response.status === 403) { 17 | return new Error(SmsErrorMessage.INVALID_EXTENSION); 18 | } 19 | return handleCoreError(error); 20 | }; 21 | -------------------------------------------------------------------------------- /lib/core/validator/validatePassword.test.ts: -------------------------------------------------------------------------------- 1 | import { ErrorMessage } from '../errors'; 2 | import { validatePassword } from './validatePassword'; 3 | 4 | describe('ValidatePassword', () => { 5 | test.each` 6 | input | expected 7 | ${'validPassword'} | ${{ isValid: true }} 8 | ${'invalid password'} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_PASSWORD }} 9 | ${' '} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_PASSWORD }} 10 | ${''} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_PASSWORD }} 11 | `( 12 | 'validator returns $expected when $input is validated', 13 | ({ input, expected }) => { 14 | const output = validatePassword(input); 15 | expect(output.isValid).toEqual(expected.isValid); 16 | 17 | if (output.isValid === false) { 18 | expect(output.cause).toContain(expected.cause); 19 | } 20 | } 21 | ); 22 | }); 23 | -------------------------------------------------------------------------------- /lib/core/validator/validatePhoneNumber.test.ts: -------------------------------------------------------------------------------- 1 | import { ErrorMessage } from '../errors'; 2 | import { validatePhoneNumber } from './validatePhoneNumber'; 3 | 4 | describe('Phone validation', () => { 5 | test.each` 6 | input | expected 7 | ${'+4915739777777'} | ${{ isValid: true }} 8 | ${'+4915739777777'} | ${{ isValid: true }} 9 | ${'text'} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_PHONE_NUMBER }} 10 | ${' '} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_PHONE_NUMBER }} 11 | ${''} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_PHONE_NUMBER }} 12 | `( 13 | 'validator returns $expected when $input is validated', 14 | ({ input, expected }) => { 15 | const output = validatePhoneNumber(input); 16 | expect(output.isValid).toEqual(expected.isValid); 17 | 18 | if (output.isValid === false) { 19 | expect(output.cause).toContain(expected.cause); 20 | } 21 | } 22 | ); 23 | }); 24 | -------------------------------------------------------------------------------- /lib/core/validator/validateTokenID.test.ts: -------------------------------------------------------------------------------- 1 | import { ErrorMessage } from '../errors'; 2 | import { validateTokenID } from '.'; 3 | 4 | describe('Token id validation', () => { 5 | test.each` 6 | input | expected 7 | ${'token-6DSA7A'} | ${{ isValid: true }} 8 | ${'token-93426892'} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_TOKEN_ID }} 9 | ${'text'} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_TOKEN_ID }} 10 | ${' '} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_TOKEN_ID }} 11 | ${''} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_TOKEN_ID }} 12 | `( 13 | 'validator returns $expected when $input is validated', 14 | ({ input, expected }) => { 15 | const output = validateTokenID(input); 16 | expect(output.isValid).toEqual(expected.isValid); 17 | 18 | if (output.isValid === false) { 19 | expect(output.cause).toContain(expected.cause); 20 | } 21 | } 22 | ); 23 | }); 24 | -------------------------------------------------------------------------------- /lib/voicemails/voicemails.test.ts: -------------------------------------------------------------------------------- 1 | import { SipgateIOClient } from '../core/sipgateIOClient'; 2 | import { Voicemail } from './voicemails.types'; 3 | import { createVoicemailsModule } from './voicemails'; 4 | 5 | describe('voicemails module', () => { 6 | it('extracts the `items` from the API response', async () => { 7 | const testVoicemails: Voicemail[] = [ 8 | { 9 | id: 'v0', 10 | alias: 'Voicemail von Peterle Drobusch-Lukfgx', 11 | belongsToEndpoint: { 12 | extension: 'w0', 13 | type: 'USER', 14 | }, 15 | }, 16 | ]; 17 | const mockClient = {} as SipgateIOClient; 18 | 19 | mockClient.get = jest 20 | .fn() 21 | .mockImplementationOnce(() => Promise.resolve({ items: testVoicemails })); 22 | 23 | const voicemailsModule = createVoicemailsModule(mockClient); 24 | 25 | const voicemails = await voicemailsModule.getVoicemails(); 26 | expect(voicemails).toEqual(testVoicemails); 27 | 28 | expect(mockClient.get).toHaveBeenCalledWith(`voicemails`); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /lib/call/call.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CallDTO, 3 | CallData, 4 | CallModule, 5 | InitiateNewCallSessionResponse, 6 | } from './call.types'; 7 | import { SipgateIOClient } from '../core/sipgateIOClient'; 8 | import { handleCallError } from './errors/handleCallError'; 9 | import { validateCallData } from './validators/validateCallData'; 10 | 11 | export const createCallModule = (httpClient: SipgateIOClient): CallModule => ({ 12 | async initiate(callData: CallData): Promise { 13 | const callDataValidation = validateCallData(callData); 14 | if (!callDataValidation.isValid) { 15 | throw new Error(callDataValidation.cause); 16 | } 17 | const callDTO: CallDTO = { 18 | callee: callData.to, 19 | caller: callData.from, 20 | callerId: callData.callerId, 21 | deviceId: callData.deviceId, 22 | }; 23 | return httpClient 24 | .post('/sessions/calls', callDTO) 25 | .catch((error) => Promise.reject(handleCallError(error))); 26 | }, 27 | }); 28 | -------------------------------------------------------------------------------- /lib/core/validator/validateExtension.ts: -------------------------------------------------------------------------------- 1 | import { ErrorMessage } from '../errors'; 2 | import { ValidationResult } from './validator.types'; 3 | 4 | enum ExtensionType { 5 | APPLICATION = 'a', 6 | CONFERENCE_ROOM = 'c', 7 | REGISTER = 'e', 8 | FAX = 'f', 9 | GROUP = 'g', 10 | IVR = 'h', 11 | SIM = 'i', 12 | SMS = 's', 13 | PERSON = 'p', 14 | QUEUE = 'q', 15 | CALLTHROUGH = 'r', 16 | TRUNKING = 't', 17 | VOICEMAIL = 'v', 18 | WEBUSER = 'w', 19 | EXTERNAL = 'x', 20 | MOBILE = 'y', 21 | } 22 | 23 | const validateExtension = ( 24 | extension: string, 25 | validTypes: ExtensionType[] = Object.values(ExtensionType) 26 | ): ValidationResult => { 27 | for (const type of validTypes) { 28 | const extensionRegEx = new RegExp(`^${type}(0|[1-9][0-9]*)$`); 29 | 30 | if (extensionRegEx.test(extension)) { 31 | return { isValid: true }; 32 | } 33 | } 34 | 35 | return { 36 | cause: `${ErrorMessage.VALIDATOR_INVALID_EXTENSION}: ${extension}`, 37 | isValid: false, 38 | }; 39 | }; 40 | 41 | export { validateExtension, ExtensionType }; 42 | -------------------------------------------------------------------------------- /lib/core/validator/validateExtension.test.ts: -------------------------------------------------------------------------------- 1 | import { ErrorMessage } from '../errors'; 2 | import { ExtensionType, validateExtension } from './validateExtension'; 3 | 4 | describe('ValidateExtension', () => { 5 | test.each` 6 | input | expected 7 | ${'f0'} | ${{ isValid: true }} 8 | ${'f01'} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_EXTENSION }} 9 | ${'b'} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_EXTENSION }} 10 | ${'b1'} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_EXTENSION }} 11 | ${' '} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_EXTENSION }} 12 | ${''} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_EXTENSION }} 13 | `( 14 | 'validator returns $expected when $input is validated', 15 | ({ input, expected }) => { 16 | const output = validateExtension(input, [ExtensionType.FAX]); 17 | expect(output.isValid).toEqual(expected.isValid); 18 | 19 | if (output.isValid === false) { 20 | expect(output.cause).toContain(expected.cause); 21 | } 22 | } 23 | ); 24 | }); 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 sipgate GmbH 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/sms/sms.types.ts: -------------------------------------------------------------------------------- 1 | export interface SMSModule { 2 | send: (sms: ShortMessage, sendAt?: Date) => Promise; 3 | getSmsExtensions: (webuserId: string) => Promise; 4 | } 5 | 6 | interface GenericShortMessage { 7 | message: string; 8 | } 9 | 10 | interface Recipient { 11 | to: string; 12 | } 13 | 14 | interface PhoneNumber { 15 | from: string; 16 | } 17 | 18 | interface DefaultWithPhoneNumber { 19 | smsId?: undefined; 20 | } 21 | 22 | type WithPhoneNumber = DefaultWithPhoneNumber & PhoneNumber; 23 | 24 | interface WithSmsId { 25 | smsId: string; 26 | phoneNumber?: undefined; 27 | from?: undefined; 28 | } 29 | 30 | export type ShortMessage = GenericShortMessage & 31 | Recipient & 32 | (WithPhoneNumber | WithSmsId); 33 | 34 | export interface ShortMessageDTO { 35 | smsId: string; 36 | recipient: string; 37 | message: string; 38 | sendAt?: number; 39 | } 40 | 41 | export interface SmsExtension { 42 | id: string; 43 | alias: string; 44 | callerId: string; 45 | } 46 | 47 | export interface SmsSenderId { 48 | id: number; 49 | phonenumber: string; 50 | verified: boolean; 51 | defaultNumber: boolean; 52 | } 53 | -------------------------------------------------------------------------------- /lib/fax/fax.types.ts: -------------------------------------------------------------------------------- 1 | export interface FaxModule { 2 | send: (fax: Fax) => Promise; 3 | getFaxStatus: (sessionId: string) => Promise; 4 | getFaxlines(): Promise; 5 | getFaxlinesByWebUser(webuserId: string): Promise; 6 | } 7 | 8 | export interface FaxlinesResponse { 9 | items: Faxline[]; 10 | } 11 | 12 | export interface Faxline { 13 | id: string; 14 | alias: string; 15 | tagline: string; 16 | canSend: boolean; 17 | canReceive: boolean; 18 | } 19 | 20 | interface FaxObject { 21 | fileContent: Buffer; 22 | filename?: string; 23 | faxlineId: string; 24 | } 25 | 26 | interface Recipient { 27 | to: string; 28 | } 29 | 30 | export type Fax = FaxObject & Recipient; 31 | 32 | export interface SendFaxSessionResponse { 33 | sessionId: string; 34 | } 35 | 36 | export interface FaxDTO { 37 | faxlineId: string; 38 | recipient: string; 39 | filename?: string; 40 | base64Content: string; 41 | } 42 | 43 | export interface HistoryFaxResponse { 44 | type: 'FAX'; 45 | faxStatusType: FaxStatus; 46 | } 47 | 48 | export enum FaxStatus { 49 | SENT = 'SENT', 50 | PENDING = 'PENDING', 51 | FAILED = 'FAILED', 52 | SENDING = 'SENDING', 53 | SCHEDULED = 'SCHEDULED', 54 | } 55 | -------------------------------------------------------------------------------- /lib/devices/devices.test.ts: -------------------------------------------------------------------------------- 1 | import { Device } from './devices.types'; 2 | import { SipgateIOClient } from '../core/sipgateIOClient'; 3 | import { createDevicesModule } from './devices'; 4 | 5 | describe('getDevices', () => { 6 | const mockuserId = 'w0'; 7 | const mockClient: SipgateIOClient = {} as SipgateIOClient; 8 | 9 | it('extracts the `items` from the API response', async () => { 10 | const testDevices: Device[] = [ 11 | { 12 | id: 'e53', 13 | alias: 'VoIP-Service-Phones', 14 | type: 'REGISTER', 15 | online: false, 16 | dnd: false, 17 | activePhonelines: [], 18 | activeGroups: [], 19 | }, 20 | { 21 | id: 'e14', 22 | alias: 'VoIP-Central-Phone', 23 | type: 'REGISTER', 24 | online: false, 25 | dnd: false, 26 | activePhonelines: [], 27 | activeGroups: [], 28 | }, 29 | ]; 30 | 31 | mockClient.get = jest 32 | .fn() 33 | .mockImplementationOnce(() => Promise.resolve({ items: testDevices })); 34 | 35 | const devicesModule = createDevicesModule(mockClient); 36 | 37 | const faxlines = await devicesModule.getDevices(mockuserId); 38 | expect(faxlines).toEqual(testDevices); 39 | 40 | expect(mockClient.get).toHaveBeenCalledWith(`${mockuserId}/devices`); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /lib/webhook/signatureVerifier.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto'; 2 | 3 | const publicKey = `-----BEGIN PUBLIC KEY----- 4 | MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAvFdlnqRfO5TmqzjtsFVC 5 | qPz+oW+poi3lXQFvMJJMEAtlU4MbX+yuIkgnC+Qt3AQWIK+oWSEQiybg+p39wEMl 6 | 16PZxEzf9gONQvTg1XhyHnwqYUFQj/AoxPKGYC3jmdjZ224SrESiTN3CdxoOc3vk 7 | cXiQstYE7SxnksPtzfk0aIOkicim5/tAP9izNQ18/zmX3ChjK2k72gf5dDlq+qZ8 8 | pRiHAPZ64TySH6cLQDhaoh6Nbq8FEG3usXXPClbUMh2DZaTMPafpd+LNuTngJ/y+ 9 | KoZasVuFfXAeBqGA6l5d4Uedfr99Z6qjwDrJ8sU4z2Do9bJrbuqP+mOXEebhSI1K 10 | vi8trEfrIt8hnDBGsBaB9NEO3kYxvDT2/PhC9fNYFocjEpZNd7/mUqTJDYlpa4UC 11 | NDQaU+ATdXG8/P5sGAegks6MQTu+qe9XTO0CV48eswDkz6g3UbLOwmrJK59SjD3J 12 | yAZ52sluwyDKRWw/PPTt8BTQLjvkdbiLUlc4ehJ4b1X7D7diYWHDPeFL47cAvaXh 13 | dXVrZDeBJYibvD1jRx4ES0KlfhwzNXBYUoM/Movg9zMM2PzyHHKS//xIjwUC/FJ6 14 | /6eykno0Hy2B1ev6s3hdY6VkN2zd7Wf+EK4DxRliwsg1U99szFE3ewLfexF+Uag5 15 | M+TfPtOUumI/NHMTvsRW9+ECAwEAAQ== 16 | -----END PUBLIC KEY-----`; 17 | 18 | export const isSipgateSignature = ( 19 | signature: string, 20 | body: string 21 | ): boolean => { 22 | const verifier = crypto.createVerify('RSA-SHA256'); 23 | const signatureBuffer = Buffer.from(signature, 'base64'); 24 | verifier.update(body); 25 | return verifier.verify(publicKey, signatureBuffer); 26 | }; 27 | -------------------------------------------------------------------------------- /lib/core/validator/validatePersonalAccessToken.test.ts: -------------------------------------------------------------------------------- 1 | import { ErrorMessage } from '../errors'; 2 | import { validatePersonalAccessToken } from './validatePersonalAccessToken'; 3 | 4 | describe('Personal Access Token validation', () => { 5 | test.each` 6 | input | expected 7 | ${'b03b4d88-640d-49ba-be33-dc8adb97e5e0'} | ${{ isValid: true }} 8 | ${'34615f1f-57cd-85f0-83e8d387569b'} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_PERSONAL_ACCESS_TOKEN }} 9 | ${'text'} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_PERSONAL_ACCESS_TOKEN }} 10 | ${' '} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_PERSONAL_ACCESS_TOKEN }} 11 | ${''} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_PERSONAL_ACCESS_TOKEN }} 12 | `( 13 | 'validator returns $expected when $input is validated', 14 | ({ input, expected }) => { 15 | const output = validatePersonalAccessToken(input); 16 | expect(output.isValid).toEqual(expected.isValid); 17 | 18 | if (output.isValid === false) { 19 | expect(output.cause).toContain(expected.cause); 20 | } 21 | } 22 | ); 23 | }); 24 | -------------------------------------------------------------------------------- /lib/webhook-settings/validators/validateWebhookUrl.test.ts: -------------------------------------------------------------------------------- 1 | import { ValidatorMessages } from './ValidatorMessages'; 2 | import { validateWebhookUrl } from './validateWebhookUrl'; 3 | 4 | describe('ValidateWebhookUrl', () => { 5 | test.each` 6 | input | expected 7 | ${'http://'} | ${{ isValid: true }} 8 | ${'https://'} | ${{ isValid: true }} 9 | ${'https://valid.com'} | ${{ isValid: true }} 10 | ${'httptest://'} | ${{ isValid: false, cause: ValidatorMessages.INVALID_WEBHOOK_URL }} 11 | ${''} | ${{ isValid: false, cause: ValidatorMessages.INVALID_WEBHOOK_URL }} 12 | ${'httpsx'} | ${{ isValid: false, cause: ValidatorMessages.INVALID_WEBHOOK_URL }} 13 | ${'://'} | ${{ isValid: false, cause: ValidatorMessages.INVALID_WEBHOOK_URL }} 14 | ${'www.url.com'} | ${{ isValid: false, cause: ValidatorMessages.INVALID_WEBHOOK_URL }} 15 | `( 16 | 'validator returns $expected when $input is validated', 17 | ({ input, expected }) => { 18 | const output = validateWebhookUrl(input); 19 | expect(output.isValid).toEqual(expected.isValid); 20 | 21 | if (output.isValid === false) { 22 | expect(output.cause).toContain(expected.cause); 23 | } 24 | } 25 | ); 26 | }); 27 | -------------------------------------------------------------------------------- /lib/rtcm/rtcm.types.ts: -------------------------------------------------------------------------------- 1 | export interface RTCMModule { 2 | getEstablishedCalls: () => Promise; 3 | mute: (call: GenericCall, status: boolean) => Promise; 4 | record: (call: GenericCall, recordOptions: RecordOptions) => Promise; 5 | announce: (call: GenericCall, announcement: string) => Promise; 6 | transfer: ( 7 | call: GenericCall, 8 | transferOptions: TransferOptions 9 | ) => Promise; 10 | sendDTMF: (call: GenericCall, sequence: string) => Promise; 11 | hold: (call: GenericCall, status: boolean) => Promise; 12 | hangUp: (call: GenericCall) => Promise; 13 | } 14 | 15 | export interface TransferOptions { 16 | attended: boolean; 17 | phoneNumber: string; 18 | } 19 | 20 | export interface RecordOptions { 21 | announcement: boolean; 22 | value: boolean; 23 | } 24 | 25 | export interface Participant { 26 | participantId: string; 27 | phoneNumber: string; 28 | muted: boolean; 29 | hold: boolean; 30 | owner: boolean; 31 | } 32 | 33 | export interface GenericCall { 34 | callId: string; 35 | } 36 | 37 | export interface RTCMCall extends GenericCall { 38 | muted: boolean; 39 | recording: boolean; 40 | hold: boolean; 41 | participants: Participant[]; 42 | } 43 | 44 | export interface RTCMCallsResponse { 45 | data: RTCMCall[]; 46 | } 47 | -------------------------------------------------------------------------------- /lib/contacts/errors/handleContactsError.ts: -------------------------------------------------------------------------------- 1 | import { HttpError, handleCoreError } from '../../core'; 2 | 3 | export enum ContactsErrorMessage { 4 | CONTACTS_INVALID_CSV = 'Invalid CSV string', 5 | CONTACTS_MISSING_HEADER_FIELD = 'Missing header field in CSV', 6 | CONTACTS_MISSING_VALUES = 'Missing values in CSV', 7 | CONTACTS_VCARD_MISSING_BEGIN = 'vCard does not contain a valid BEGIN tag', 8 | CONTACTS_VCARD_MISSING_END = 'vCard does not contain a valid END tag', 9 | CONTACTS_VCARD_FAILED_TO_PARSE = 'Failed to parse VCard', 10 | CONTACTS_INVALID_VCARD_VERSION = 'Invalid VCard Version given', 11 | CONTACTS_MISSING_NAME_ATTRIBUTE = 'Names not given', 12 | CONTACTS_MISSING_TEL_ATTRIBUTE = 'No phone number given', 13 | CONTACTS_INVALID_AMOUNT_OF_NAMES = 'Missing Name Fields', 14 | CONTACTS_INVALID_AMOUNT_OF_PHONE_NUMBERS = 'Only one phone number is allowed', 15 | CONTACTS_INVALID_AMOUNT_OF_ADDRESSES = 'Only one address is allowed', 16 | CONTACTS_INVALID_AMOUNT_OF_ADDRESS_VALUES = 'Address Fields are invalid', 17 | CONTACTS_INVALID_AMOUNT_OF_EMAILS = 'Only one email is allowed', 18 | } 19 | 20 | export const handleContactsError = (error: HttpError): Error => { 21 | if (error.response && error.response.status === 500) { 22 | return Error(`${ContactsErrorMessage.CONTACTS_INVALID_CSV}`); 23 | } 24 | 25 | return handleCoreError(error); 26 | }; 27 | -------------------------------------------------------------------------------- /lib/core/validator/validateEmail.test.ts: -------------------------------------------------------------------------------- 1 | import { ErrorMessage } from '../errors'; 2 | import { validateEmail } from './validateEmail'; 3 | 4 | describe('ValidateEmail', () => { 5 | test.each` 6 | input | expected 7 | ${'validEmail@test.de'} | ${{ isValid: true }} 8 | ${'invalidEmail'} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_EMAIL }} 9 | ${'@test.de'} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_EMAIL }} 10 | ${'@'} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_EMAIL }} 11 | ${' '} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_EMAIL }} 12 | ${''} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_EMAIL }} 13 | `( 14 | 'validator returns $expected when $input is validated', 15 | ({ input, expected }) => { 16 | const output = validateEmail(input); 17 | expect(output.isValid).toEqual(expected.isValid); 18 | 19 | if (output.isValid === false) { 20 | expect(output.cause).toContain(expected.cause); 21 | } 22 | } 23 | ); 24 | 25 | it('uses to display an empty email', () => { 26 | const output = validateEmail(''); 27 | 28 | expect(output.isValid === false); 29 | if (output.isValid !== false) return; 30 | 31 | expect(output.cause).toContain(''); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /lib/browser.ts: -------------------------------------------------------------------------------- 1 | export { 2 | sipgateIO, 3 | SipgateIOClient, 4 | AuthCredentials, 5 | BasicAuthCredentials, 6 | OAuthCredentials, 7 | PersonalAccessTokenCredentials, 8 | Pagination, 9 | } from './core'; 10 | export { 11 | createCallModule, 12 | CallData, 13 | InitiateNewCallSessionResponse, 14 | } from './call'; 15 | export { 16 | createContactsModule, 17 | ContactResponse, 18 | ContactImport, 19 | ContactsExportFilter, 20 | Address, 21 | Email, 22 | PhoneNumber, 23 | } from './contacts'; 24 | export { 25 | createFaxModule, 26 | Fax, 27 | SendFaxSessionResponse, 28 | FaxStatus, 29 | Faxline, 30 | } from './fax'; 31 | export { 32 | createHistoryModule, 33 | HistoryDirection, 34 | HistoryEntryType, 35 | HistoryFilter, 36 | HistoryEntry, 37 | CallHistoryEntry, 38 | FaxHistoryEntry, 39 | SmsHistoryEntry, 40 | VoicemailHistoryEntry, 41 | HistoryEntryUpdateOptions, 42 | CallStatusType, 43 | Endpoint, 44 | RoutedEndpoint, 45 | Starred, 46 | FaxStatusType, 47 | Recording, 48 | } from './history'; 49 | export { createNumbersModule } from './numbers'; 50 | export { 51 | createRTCMModule, 52 | RTCMCall, 53 | GenericCall, 54 | RecordOptions, 55 | TransferOptions, 56 | Participant, 57 | } from './rtcm'; 58 | export { createSMSModule, ShortMessage } from './sms'; 59 | export { createSettingsModule, WebhookSettings } from './webhook-settings'; 60 | export { createVoicemailsModule } from './voicemails'; 61 | export { createDevicesModule } from './devices'; 62 | -------------------------------------------------------------------------------- /lib/core/validator/validateOAuthToken.test.ts: -------------------------------------------------------------------------------- 1 | import { ErrorMessage } from '../errors'; 2 | import { validateOAuthToken } from './validateOAuthToken'; 3 | 4 | describe('ValidateOAuthToken', () => { 5 | const validOAuthToken = 6 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiJmM2U3ZzI0NC0yNDQ3LTQ1ODctOWZjYy05ZWY1MjQ3aDE3NHMiLCJleHAiOjE1NDY2NTQ4MjcsIm5iZiI6MCwiaWF0IjoxNTY1NDgzMjE4LCJpc3MiOiJodHRwczovL2xvZ2luLnNpcGdhdGUuY29tL2F1dGgvcmVhbG1zL3RoaXJkLXBhcnR5Iiwic3ViIjoiZjoyZTc0ODY1Ny1mNTV6LTg5Z3MtOWdmMi1ydDU4MjRoMjQ1MTg6ODQ1Mjg0NiIsInR5cCI6IkJlYXJlciIsImF6cCI6InNpcGdhdGUtc3dhZ2dlci11aSIsIm5vbmNlIjoiOTgyMTU3MSIsImF1dGhfdGltZSI6MTU2NTQyODU0OCwic2Vzc2lvbl9zdGF0ZSI6Ijg1ZzR6MXM3LTc4ZzItNDM4NS05ZTFnLXIxODdmMjc0ZWQ5ayIsImFjciI6IjAiLCJzY29wZSI6ImFsbCJ9.axEQX90FLk4W89y92C9eQnwMV3wfewk5zaPCszj46YA'; 7 | 8 | test.each` 9 | input | expected 10 | ${validOAuthToken} | ${{ isValid: true }} 11 | ${`header.invalidPayload.${validOAuthToken}`} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_OAUTH_TOKEN }} 12 | ${''} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_OAUTH_TOKEN }} 13 | `( 14 | 'validator returns $expected when $input is validated', 15 | ({ input, expected }) => { 16 | const output = validateOAuthToken(input); 17 | expect(output.isValid).toEqual(expected.isValid); 18 | 19 | if (output.isValid === false) { 20 | expect(output.cause).toContain(expected.cause); 21 | } 22 | } 23 | ); 24 | }); 25 | -------------------------------------------------------------------------------- /lib/webhook/audioUtils.ts: -------------------------------------------------------------------------------- 1 | import { parseStream } from 'music-metadata'; 2 | import axios from 'axios'; 3 | 4 | export interface ValidateOptions { 5 | container?: string; 6 | codec?: string; 7 | bitsPerSample?: number; 8 | sampleRate?: number; 9 | numberOfChannels?: number; 10 | duration?: number; 11 | } 12 | 13 | interface ValidateResult { 14 | isValid: boolean; 15 | metadata: ValidateOptions; 16 | } 17 | 18 | export const getAudioMetadata = async ( 19 | url: string 20 | ): Promise => { 21 | const response = await axios({ 22 | method: 'get', 23 | url: url, 24 | responseType: 'stream', 25 | }); 26 | const metadata = await parseStream(response.data); 27 | 28 | return metadata.format as ValidateOptions; 29 | }; 30 | 31 | const validateAudio = ( 32 | metadata: ValidateOptions, 33 | validateOptions: ValidateOptions 34 | ): boolean => { 35 | for (const key in validateOptions) { 36 | if ( 37 | validateOptions[key as keyof ValidateOptions] !== 38 | metadata[key as keyof ValidateOptions] 39 | ) { 40 | return false; 41 | } 42 | } 43 | return true; 44 | }; 45 | 46 | export const validateAnnouncementAudio = async ( 47 | urlToAnnouncement: string 48 | ): Promise => { 49 | const validateOptions = { 50 | container: 'WAVE', 51 | codec: 'PCM', 52 | bitsPerSample: 16, 53 | sampleRate: 8000, 54 | numberOfChannels: 1, 55 | }; 56 | 57 | const metadata = await getAudioMetadata(urlToAnnouncement); 58 | 59 | return { 60 | isValid: validateAudio(metadata, validateOptions), 61 | metadata, 62 | }; 63 | }; 64 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "jest": true, 6 | "jasmine": true, 7 | "node": true 8 | }, 9 | "extends": ["eslint:recommended", "plugin:prettier/recommended"], 10 | "globals": { 11 | "Atomics": "readonly", 12 | "SharedArrayBuffer": "readonly" 13 | }, 14 | "parser": "@typescript-eslint/parser", 15 | "parserOptions": { 16 | "ecmaVersion": 2016, 17 | "sourceType": "module" 18 | }, 19 | "plugins": [ 20 | "prettier", 21 | "node", 22 | "import", 23 | "promise", 24 | "autofix", 25 | "sort-imports-es6-autofix", 26 | "@typescript-eslint" 27 | ], 28 | "rules": { 29 | "autofix/no-debugger": "error", 30 | "autofix/sort-vars": "error", 31 | "camelcase": "warn", 32 | "eqeqeq": "error", 33 | "no-unused-expressions": "error", 34 | "no-unused-labels": "error", 35 | "no-unused-vars": "off", 36 | "@typescript-eslint/no-unused-vars": ["error"], 37 | "prefer-const": "error", 38 | "no-duplicate-imports": "error", 39 | "prefer-destructuring": [ 40 | "error", 41 | { 42 | "AssignmentExpression": { 43 | "array": false, 44 | "object": true 45 | }, 46 | "VariableDeclarator": { 47 | "array": false, 48 | "object": true 49 | } 50 | }, 51 | { 52 | "enforceForRenamedProperties": false 53 | } 54 | ], 55 | "prefer-template": "error", 56 | "prettier/prettier": "error", 57 | "sort-imports-es6-autofix/sort-imports-es6": [ 58 | 2, 59 | { 60 | "ignoreCase": false, 61 | "ignoreMemberSort": false, 62 | "memberSyntaxSortOrder": ["none", "all", "multiple", "single"] 63 | } 64 | ], 65 | "use-isnan": "error" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /lib/core/sipgateIOClient/sipgateIOClient.types.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios'; 2 | 3 | /** 4 | * @deprecated 5 | * @since 2.2.0 6 | */ 7 | export interface BasicAuthCredentials { 8 | username: string; 9 | password: string; 10 | } 11 | 12 | export interface OAuthCredentials { 13 | token: string; 14 | } 15 | 16 | export interface PersonalAccessTokenCredentials { 17 | tokenId: string; 18 | token: string; 19 | } 20 | 21 | export interface Webuser { 22 | id: string; 23 | firstname: string; 24 | lastname: string; 25 | email: string; 26 | defaultDevice: string; 27 | busyOnBusy: string; 28 | addressId: string; 29 | directDialIds: string[]; 30 | timezone: string; 31 | admin: string; 32 | } 33 | 34 | export type AuthCredentials = 35 | | BasicAuthCredentials 36 | | OAuthCredentials 37 | | PersonalAccessTokenCredentials; 38 | 39 | export type HttpRequestConfig = AxiosRequestConfig; 40 | export type HttpResponse = AxiosResponse; 41 | 42 | export type HttpError = AxiosError; 43 | 44 | export interface SipgateIOClient { 45 | get: (url: string, config?: HttpRequestConfig) => Promise; 46 | post: ( 47 | url: string, 48 | data?: unknown, 49 | config?: HttpRequestConfig 50 | ) => Promise; 51 | put: ( 52 | url: string, 53 | data?: unknown, 54 | config?: HttpRequestConfig 55 | ) => Promise; 56 | delete: (url: string, config?: HttpRequestConfig) => Promise; 57 | patch: ( 58 | url: string, 59 | data?: unknown, 60 | config?: HttpRequestConfig 61 | ) => Promise; 62 | getAuthenticatedWebuserId: () => Promise; 63 | getWebUsers: () => Promise; 64 | } 65 | -------------------------------------------------------------------------------- /lib/fax/testfiles/validPDFBuffer.ts: -------------------------------------------------------------------------------- 1 | export default Buffer.from( 2 | 'JVBERi0xLjQKJcOkw7zDtsOfCjIgMCBvYmoKPDwvTGVuZ3RoIDMgMCBSL0ZpbHRlci9GbGF0ZURlY29kZT4+CnN0cmVhbQp4nDPQM1Qo5ypUMA' + 3 | 'BCM0MjBXNLI4WiVK5wLYU8rkAFAF+qBkgKZW5kc3RyZWFtCmVuZG9iagoKMyAwIG9iagozNgplbmRvYmoKCjUgMCBvYmoKPDwKPj4KZW5kb2Jq' + 4 | 'Cgo2IDAgb2JqCjw8L0ZvbnQgNSAwIFIKL1Byb2NTZXRbL1BERi9UZXh0XQo+PgplbmRvYmoKCjEgMCBvYmoKPDwvVHlwZS9QYWdlL1BhcmVudC' + 5 | 'A0IDAgUi9SZXNvdXJjZXMgNiAwIFIvTWVkaWFCb3hbMCAwIDYxMS45NzE2NTM1NDMzMDcgNzkxLjk3MTY1MzU0MzMwN10vR3JvdXA8PC9TL1Ry' + 6 | 'YW5zcGFyZW5jeS9DUy9EZXZpY2VSR0IvSSB0cnVlPj4vQ29udGVudHMgMiAwIFI+PgplbmRvYmoKCjQgMCBvYmoKPDwvVHlwZS9QYWdlcwovUm' + 7 | 'Vzb3VyY2VzIDYgMCBSCi9NZWRpYUJveFsgMCAwIDYxMSA3OTEgXQovS2lkc1sgMSAwIFIgXQovQ291bnQgMT4+CmVuZG9iagoKNyAwIG9iago8' + 8 | 'PC9UeXBlL0NhdGFsb2cvUGFnZXMgNCAwIFIKL09wZW5BY3Rpb25bMSAwIFIgL1hZWiBudWxsIG51bGwgMF0KL0xhbmcoZW4tVVMpCj4+CmVuZG' + 9 | '9iagoKOCAwIG9iago8PC9DcmVhdG9yPEZFRkYwMDU3MDA3MjAwNjkwMDc0MDA2NTAwNzI+Ci9Qcm9kdWNlcjxGRUZGMDA0QzAwNjkwMDYyMDA3' + 10 | 'MjAwNjUwMDRGMDA2NjAwNjYwMDY5MDA2MzAwNjUwMDIwMDAzNjAwMkUwMDMwPgovQ3JlYXRpb25EYXRlKEQ6MjAxOTA4MjAxMjUwNDgrMDInMD' + 11 | 'AnKT4+CmVuZG9iagoKeHJlZgowIDkKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMjIwIDAwMDAwIG4gCjAwMDAwMDAwMTkgMDAwMDAgbiAK' + 12 | 'MDAwMDAwMDEyNiAwMDAwMCBuIAowMDAwMDAwMzg4IDAwMDAwIG4gCjAwMDAwMDAxNDUgMDAwMDAgbiAKMDAwMDAwMDE2NyAwMDAwMCBuIAowMD' + 13 | 'AwMDAwNDg2IDAwMDAwIG4gCjAwMDAwMDA1ODIgMDAwMDAgbiAKdHJhaWxlcgo8PC9TaXplIDkvUm9vdCA3IDAgUgovSW5mbyA4IDAgUgovSUQg' + 14 | 'WyA8OTA1ODkwQTlDREU0ODU3N0Q0MEM5NERBMzY0NDdBNjg+Cjw5MDU4OTBBOUNERTQ4NTc3RDQwQzk0REEzNjQ0N0E2OD4gXQovRG9jQ2hlY2' + 15 | 'tzdW0gLzdFMENFQjIyN0VFREI4MUI4RUEzRUQ2RjhEQzY1NTk3Cj4+CnN0YXJ0eHJlZgo3NTYKJSVFT0YK', 16 | 'base64' 17 | ); 18 | -------------------------------------------------------------------------------- /lib/core/errors/handleError.test.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError, AxiosResponse } from 'axios'; 2 | import { ErrorMessage } from './ErrorMessage'; 3 | import { handleCoreError } from './handleError'; 4 | 5 | describe('handleCoreError', () => { 6 | it('AuthenticationError', () => { 7 | const response: AxiosResponse = { 8 | data: { 9 | status: 401, //sipgate API response 10 | }, 11 | status: 401, // http status response code 12 | config: {}, 13 | statusText: 'Unauthorized', 14 | headers: {}, 15 | }; 16 | const error: AxiosError = { 17 | name: 'testError', 18 | isAxiosError: true, 19 | message: 'test error message', 20 | config: {}, 21 | response, 22 | toJSON: () => Object, 23 | }; 24 | expect(handleCoreError(error)).toEqual(new Error(ErrorMessage.HTTP_401)); 25 | }); 26 | 27 | it('AccessError', () => { 28 | const response: AxiosResponse = { 29 | data: { 30 | status: 403, //sipgate API response 31 | }, 32 | status: 403, // http status response code 33 | config: {}, 34 | statusText: 'Unauthorized', 35 | headers: {}, 36 | }; 37 | const error: AxiosError = { 38 | name: 'testError', 39 | isAxiosError: true, 40 | message: 'test error message', 41 | config: {}, 42 | response, 43 | toJSON: () => Object, 44 | }; 45 | expect(handleCoreError(error)).toEqual(new Error(ErrorMessage.HTTP_403)); 46 | }); 47 | 48 | it('Catch all errors', () => { 49 | const response: AxiosResponse = { 50 | data: { 51 | status: 987, //sipgate API response 52 | }, 53 | status: 987, // http status response code 54 | config: {}, 55 | statusText: 'Unauthorized', 56 | headers: {}, 57 | }; 58 | const error: AxiosError = { 59 | name: 'testError', 60 | isAxiosError: true, 61 | message: '', 62 | config: {}, 63 | response, 64 | toJSON: () => Object, 65 | }; 66 | expect(handleCoreError(error)).toEqual(new Error()); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /lib/rtcm/validator/validateDTMFSequence.test.ts: -------------------------------------------------------------------------------- 1 | import { RtcmErrorMessage } from '../errors/handleRtcmError'; 2 | import { validateDTMFSequence } from './validateDTMFSequence'; 3 | 4 | describe('dtmf sequence validation', () => { 5 | test.each` 6 | input | expected 7 | ${{ sequence: '12 34' }} | ${{ isValid: false, cause: RtcmErrorMessage.DTMF_INVALID_SEQUENCE }} 8 | ${{ sequence: ' *' }} | ${{ isValid: false, cause: RtcmErrorMessage.DTMF_INVALID_SEQUENCE }} 9 | ${{ sequence: '0291*' }} | ${{ isValid: true }} 10 | ${{ sequence: '*100#' }} | ${{ isValid: true }} 11 | ${{ sequence: '*AaBb#' }} | ${{ isValid: false, cause: RtcmErrorMessage.DTMF_INVALID_SEQUENCE }} 12 | ${{ sequence: 'aa' }} | ${{ isValid: false, cause: RtcmErrorMessage.DTMF_INVALID_SEQUENCE }} 13 | ${{ sequence: 'A' }} | ${{ isValid: true }} 14 | ${{ sequence: 'B' }} | ${{ isValid: true }} 15 | ${{ sequence: 'C' }} | ${{ isValid: true }} 16 | ${{ sequence: 'D' }} | ${{ isValid: true }} 17 | ${{ sequence: '1' }} | ${{ isValid: true }} 18 | ${{ sequence: '2' }} | ${{ isValid: true }} 19 | ${{ sequence: '3' }} | ${{ isValid: true }} 20 | ${{ sequence: '4' }} | ${{ isValid: true }} 21 | ${{ sequence: '5' }} | ${{ isValid: true }} 22 | ${{ sequence: '6' }} | ${{ isValid: true }} 23 | ${{ sequence: '7' }} | ${{ isValid: true }} 24 | ${{ sequence: '8' }} | ${{ isValid: true }} 25 | ${{ sequence: '9' }} | ${{ isValid: true }} 26 | ${{ sequence: '' }} | ${{ isValid: false, cause: RtcmErrorMessage.DTMF_INVALID_SEQUENCE }} 27 | `( 28 | 'validator returns $expected when $input is validated', 29 | ({ input, expected }) => { 30 | const output = validateDTMFSequence(input.sequence); 31 | expect(output.isValid).toEqual(expected.isValid); 32 | 33 | if (output.isValid === false) { 34 | expect(output.cause).toContain(expected.cause); 35 | } 36 | } 37 | ); 38 | }); 39 | -------------------------------------------------------------------------------- /lib/call/validators/validateCallData.ts: -------------------------------------------------------------------------------- 1 | import { CallData } from '../call.types'; 2 | import { ErrorMessage } from '../../core'; 3 | import { 4 | ExtensionType, 5 | ValidationResult, 6 | validateExtension, 7 | validatePhoneNumber, 8 | } from '../../core/validator'; 9 | 10 | export enum ValidationErrors { 11 | INVALID_CALLER = 'Caller is not a valid extension or phone number', 12 | INVALID_CALLER_ID = 'CallerId is not a valid phone number', 13 | INVALID_DEVICE_ID = 'DeviceId is required if caller is not an extension', 14 | } 15 | 16 | const validateCallData = (callData: CallData): ValidationResult => { 17 | const calleeValidationResult = validatePhoneNumber(callData.to); 18 | if (!calleeValidationResult.isValid) { 19 | return { isValid: false, cause: calleeValidationResult.cause }; 20 | } 21 | 22 | const callerPhoneNumberValidationResult = validatePhoneNumber(callData.from); 23 | const callerExtensionValidationResult = validateExtension(callData.from, [ 24 | ExtensionType.MOBILE, 25 | ExtensionType.PERSON, 26 | ExtensionType.EXTERNAL, 27 | ExtensionType.REGISTER, 28 | ]); 29 | if ( 30 | !callerPhoneNumberValidationResult.isValid && 31 | !callerExtensionValidationResult.isValid 32 | ) { 33 | return { 34 | isValid: false, 35 | cause: ValidationErrors.INVALID_CALLER, 36 | }; 37 | } 38 | 39 | if (callData.deviceId) { 40 | const deviceIdValidationResult = validateExtension(callData.deviceId, [ 41 | ExtensionType.MOBILE, 42 | ExtensionType.PERSON, 43 | ExtensionType.EXTERNAL, 44 | ExtensionType.REGISTER, 45 | ]); 46 | if (!deviceIdValidationResult.isValid) { 47 | return { 48 | isValid: false, 49 | cause: ErrorMessage.VALIDATOR_INVALID_EXTENSION, 50 | }; 51 | } 52 | } else { 53 | if (!callerExtensionValidationResult.isValid) { 54 | return { 55 | isValid: false, 56 | cause: ValidationErrors.INVALID_DEVICE_ID, 57 | }; 58 | } 59 | } 60 | 61 | if (callData.callerId) { 62 | const callerIdValidationResult = validatePhoneNumber(callData.callerId); 63 | if (!callerIdValidationResult.isValid) { 64 | return { 65 | isValid: false, 66 | cause: ValidationErrors.INVALID_CALLER_ID, 67 | }; 68 | } 69 | } 70 | 71 | return { isValid: true }; 72 | }; 73 | export { validateCallData }; 74 | -------------------------------------------------------------------------------- /lib/fax/fax.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Fax, 3 | FaxDTO, 4 | FaxModule, 5 | FaxStatus, 6 | Faxline, 7 | FaxlinesResponse, 8 | HistoryFaxResponse, 9 | SendFaxSessionResponse, 10 | } from './fax.types'; 11 | import { FaxErrorMessage, handleFaxError } from './errors/handleFaxError'; 12 | import { SipgateIOClient } from '../core/sipgateIOClient'; 13 | import { validatePdfFileContent } from './validators/validatePdfFileContent'; 14 | 15 | export const createFaxModule = (client: SipgateIOClient): FaxModule => ({ 16 | send(faxObject: Fax): Promise { 17 | const fax = faxObject; 18 | const fileContentValidationResult = validatePdfFileContent(fax.fileContent); 19 | 20 | if (!fileContentValidationResult.isValid) { 21 | throw new Error(fileContentValidationResult.cause); 22 | } 23 | 24 | if (!fax.filename) { 25 | fax.filename = generateFilename(); 26 | } 27 | 28 | const faxDTO: FaxDTO = { 29 | base64Content: fax.fileContent.toString('base64'), 30 | faxlineId: fax.faxlineId, 31 | filename: fax.filename, 32 | recipient: fax.to, 33 | }; 34 | 35 | return client 36 | .post('/sessions/fax', faxDTO) 37 | .catch((error) => Promise.reject(handleFaxError(error))); 38 | }, 39 | getFaxStatus(sessionId: string): Promise { 40 | return client 41 | .get(`/history/${sessionId}`) 42 | .then((data) => { 43 | if (!data.type || data.type !== 'FAX') { 44 | throw new Error(FaxErrorMessage.NOT_A_FAX); 45 | } 46 | 47 | return data.faxStatusType; 48 | }) 49 | .catch((error) => Promise.reject(handleFaxError(error))); 50 | }, 51 | async getFaxlines(): Promise { 52 | const webuserId = await client.getAuthenticatedWebuserId(); 53 | return await client 54 | .get(`${webuserId}/faxlines`) 55 | .then((response) => response.items); 56 | }, 57 | async getFaxlinesByWebUser(webuserId: string): Promise { 58 | return await client 59 | .get<{ items: Faxline[] }>(`${webuserId}/faxlines`) 60 | .then((response) => response.items); 61 | }, 62 | }); 63 | 64 | const generateFilename = (): string => { 65 | const timestamp = new Date() 66 | .toJSON() 67 | .replace(/T/g, '_') 68 | .replace(/[.:-]/g, '') 69 | .slice(0, -6); 70 | return `Fax_${timestamp}`; 71 | }; 72 | -------------------------------------------------------------------------------- /lib/numbers/numbers.test.ts: -------------------------------------------------------------------------------- 1 | import { NumberResponseItemType, createNumbersModule } from '.'; 2 | import { NumbersErrorMessage } from './errors/handleNumbersError'; 3 | import { SipgateIOClient } from '../core'; 4 | 5 | describe('Number Module', () => { 6 | let mockClient: SipgateIOClient; 7 | 8 | beforeEach(() => { 9 | mockClient = {} as SipgateIOClient; 10 | }); 11 | 12 | it('returns all number items', async () => { 13 | mockClient.get = jest.fn().mockImplementationOnce(() => { 14 | return Promise.resolve({ 15 | items: [ 16 | { 17 | id: '12355', 18 | number: '+49157391234567', 19 | localized: '15739123456', 20 | type: NumberResponseItemType.INTERNATIONAL, 21 | endpointId: 'string', 22 | endpointAlias: 'string', 23 | endpointUrl: 'string', 24 | }, 25 | { 26 | id: '41351', 27 | number: '+49145169146', 28 | localized: '145169146', 29 | type: NumberResponseItemType.LANDLINE, 30 | endpointId: 'string', 31 | endpointAlias: 'string', 32 | endpointUrl: 'string', 33 | }, 34 | ], 35 | }); 36 | }); 37 | 38 | const numberModule = createNumbersModule(mockClient); 39 | const actualValues = await numberModule.getAllNumbers(); 40 | await expect(actualValues).toStrictEqual({ 41 | items: [ 42 | { 43 | id: '12355', 44 | number: '+49157391234567', 45 | localized: '15739123456', 46 | type: NumberResponseItemType.INTERNATIONAL, 47 | endpointId: 'string', 48 | endpointAlias: 'string', 49 | endpointUrl: 'string', 50 | }, 51 | { 52 | id: '41351', 53 | number: '+49145169146', 54 | localized: '145169146', 55 | type: NumberResponseItemType.LANDLINE, 56 | endpointId: 'string', 57 | endpointAlias: 'string', 58 | endpointUrl: 'string', 59 | }, 60 | ], 61 | }); 62 | }); 63 | 64 | it('throws an error when the API answers with 400 Bad Request', async () => { 65 | mockClient.get = jest.fn().mockImplementationOnce(() => { 66 | return Promise.reject({ 67 | response: { 68 | status: 400, 69 | }, 70 | }); 71 | }); 72 | 73 | const numberModule = createNumbersModule(mockClient); 74 | 75 | await expect(numberModule.getAllNumbers()).rejects.toThrowError( 76 | NumbersErrorMessage.BAD_REQUEST 77 | ); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /lib/rtcm/rtcm.ts: -------------------------------------------------------------------------------- 1 | import { RTCMCall, RTCMCallsResponse, RTCMModule } from './rtcm.types'; 2 | import { SipgateIOClient } from '../core/sipgateIOClient'; 3 | import { handleRtcmError } from './errors/handleRtcmError'; 4 | import { validateDTMFSequence } from './validator/validateDTMFSequence'; 5 | 6 | export const createRTCMModule = (client: SipgateIOClient): RTCMModule => ({ 7 | getEstablishedCalls: (): Promise => { 8 | return client 9 | .get('/calls') 10 | .then((response) => response.data) 11 | .catch((error) => Promise.reject(handleRtcmError(error))); 12 | }, 13 | mute: async (call, status): Promise => { 14 | await client 15 | .put(`/calls/${call.callId}/muted`, { 16 | value: status, 17 | }) 18 | .catch((error) => Promise.reject(handleRtcmError(error))); 19 | }, 20 | record: async (call, options): Promise => { 21 | await client 22 | .put(`/calls/${call.callId}/recording`, options) 23 | .catch((error) => Promise.reject(handleRtcmError(error))); 24 | }, 25 | announce: async (call, announcement): Promise => { 26 | await client 27 | .post(`/calls/${call.callId}/announcements`, { 28 | url: announcement, 29 | }) 30 | .catch((error) => Promise.reject(handleRtcmError(error))); 31 | }, 32 | transfer: async (call, options): Promise => { 33 | await client 34 | .post(`/calls/${call.callId}/transfer`, options) 35 | .catch((error) => Promise.reject(handleRtcmError(error))); 36 | }, 37 | sendDTMF: async (call, sequence): Promise => { 38 | const upperCasedSequence = sequence.toUpperCase(); 39 | const validationResult = validateDTMFSequence(upperCasedSequence); 40 | if (!validationResult.isValid) { 41 | throw new Error(validationResult.cause); 42 | } 43 | 44 | await client 45 | .post(`/calls/${call.callId}/dtmf`, { 46 | sequence: upperCasedSequence, 47 | }) 48 | .catch((error) => Promise.reject(handleRtcmError(error))); 49 | }, 50 | hold: async (call, status): Promise => { 51 | await client 52 | .put(`/calls/${call.callId}/hold`, { 53 | value: status, 54 | }) 55 | .catch((error) => Promise.reject(handleRtcmError(error))); 56 | }, 57 | hangUp: async (call): Promise => { 58 | await client 59 | .delete(`/calls/${call.callId}`) 60 | .catch((error) => Promise.reject(handleRtcmError(error))); 61 | }, 62 | }); 63 | -------------------------------------------------------------------------------- /lib/fluent/webhook.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AnswerCallback, 3 | DataCallback, 4 | HangUpCallback, 5 | NewCallCallback, 6 | } from '../webhook'; 7 | import { createWebhookModule } from '..'; 8 | 9 | class FluentWebhookServer { 10 | private serverAddress: string = ''; 11 | private serverPort: number = -1; 12 | 13 | private newCallCallback: NewCallCallback | null = null; 14 | private answerCallback: AnswerCallback | null = null; 15 | private hangupCallback: HangUpCallback | null = null; 16 | private dataCallback: DataCallback | null = null; 17 | 18 | public setServerAddress = (address: string) => { 19 | this.serverAddress = address; 20 | 21 | return this; 22 | }; 23 | 24 | public setServerPort = (port: number) => { 25 | this.serverPort = port; 26 | 27 | return this; 28 | }; 29 | 30 | public setOnNewCallListener = (fn: NewCallCallback) => { 31 | if (this.newCallCallback !== null) { 32 | throw new Error('can only handle one newCall listener'); 33 | } 34 | 35 | this.newCallCallback = fn; 36 | 37 | return this; 38 | }; 39 | 40 | public setOnAnswerListener = (fn: AnswerCallback) => { 41 | if (this.answerCallback !== null) { 42 | throw new Error('can only handle one answer listener'); 43 | } 44 | 45 | this.answerCallback = fn; 46 | 47 | return this; 48 | }; 49 | 50 | public setOnHangupListener = (fn: HangUpCallback) => { 51 | if (this.hangupCallback !== null) { 52 | throw new Error('can only handle one hangup listener'); 53 | } 54 | 55 | this.hangupCallback = fn; 56 | 57 | return this; 58 | }; 59 | 60 | public setOnDataListener = (fn: DataCallback) => { 61 | if (this.dataCallback !== null) { 62 | throw new Error('can only handle one data listener'); 63 | } 64 | 65 | this.dataCallback = fn; 66 | 67 | return this; 68 | }; 69 | 70 | public async startServer() { 71 | if (this.serverPort < 0) { 72 | throw new Error('invalid or missing serverPort'); 73 | } 74 | 75 | if (this.serverAddress.length === 0) { 76 | throw new Error('invalid or missing serverAddress'); 77 | } 78 | 79 | const server = await createWebhookModule().createServer({ 80 | port: this.serverPort, 81 | serverAddress: this.serverAddress, 82 | }); 83 | 84 | if (this.newCallCallback !== null) { 85 | server.onNewCall(this.newCallCallback); 86 | } 87 | if (this.answerCallback !== null) { 88 | server.onAnswer(this.answerCallback); 89 | } 90 | if (this.hangupCallback !== null) { 91 | server.onHangUp(this.hangupCallback); 92 | } 93 | if (this.dataCallback !== null) { 94 | server.onData(this.dataCallback); 95 | } 96 | 97 | return server; 98 | } 99 | } 100 | 101 | export { FluentWebhookServer }; 102 | -------------------------------------------------------------------------------- /.github/workflows/github-actions.yml: -------------------------------------------------------------------------------- 1 | name: Build and Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | run-tests: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-node@v1 14 | with: 15 | node-version: '12' 16 | - run: | 17 | npm install 18 | npm run test:unit 19 | npm run test:integration 20 | env: 21 | CI: true 22 | 23 | publish-github-package: 24 | needs: run-tests 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@v2 28 | - uses: actions/setup-node@v1 29 | with: 30 | node-version: '12' 31 | registry-url: https://npm.pkg.github.com/ 32 | - run: npm install 33 | - run: sed -i '2s/sipgateio/\@sipgate-io\/sipgateio/' package.json 34 | - run: npm publish 35 | env: 36 | NODE_AUTH_TOKEN: ${{secrets.ACCESS_TOKEN}} 37 | 38 | publish-github-release: 39 | needs: run-tests 40 | runs-on: ubuntu-latest 41 | steps: 42 | - uses: actions/checkout@v2 43 | - uses: actions/setup-node@v1 44 | with: 45 | node-version: '12' 46 | - run: | 47 | npm install 48 | npm run bundle 49 | - name: Get the version 50 | id: get_version 51 | run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//} 52 | - name: Commit Bundle 53 | run: | 54 | git config --local user.email ${{ secrets.SECRET_MAIL }} 55 | git config --local user.name "sipgateio-team" 56 | git checkout -b bundle/publish 57 | git add --force bundle/sipgate-io.min.js 58 | sed -i 's|https://cdn.jsdelivr.net/gh/sipgate-io/sipgateio-node@latest/bundle/sipgate-io.min.js|https://cdn.jsdelivr.net/gh/sipgate-io/sipgateio-node@${{ steps.get_version.outputs.VERSION }}/bundle/sipgate-io.min.js|g' README.md 59 | git add README.md 60 | git commit -m "publish: version ${{ steps.get_version.outputs.VERSION }}" --no-verify 61 | git tag ${{ steps.get_version.outputs.VERSION }} --force 62 | git push --tags --force 63 | - name: Create GitHub Release 64 | uses: fnkr/github-action-ghr@v1.2 65 | env: 66 | GHR_PATH: bundle/sipgate-io.min.js 67 | GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} 68 | 69 | publish-npm-package: 70 | needs: run-tests 71 | runs-on: ubuntu-latest 72 | steps: 73 | - uses: actions/checkout@v2 74 | - uses: actions/setup-node@v1 75 | with: 76 | node-version: '12' 77 | - run: npm install 78 | - uses: JS-DevTools/npm-publish@v1 79 | with: 80 | token: ${{ secrets.NPM_AUTH_TOKEN }} 81 | -------------------------------------------------------------------------------- /lib/call/call.test.ts: -------------------------------------------------------------------------------- 1 | import { CallData, CallModule } from './call.types'; 2 | import { CallErrorMessage } from './errors/handleCallError'; 3 | import { ErrorMessage } from '../core'; 4 | import { SipgateIOClient } from '../core/sipgateIOClient'; 5 | import { ValidationErrors } from './validators/validateCallData'; 6 | import { createCallModule } from './call'; 7 | 8 | describe('Call Module', () => { 9 | let callModule: CallModule; 10 | let mockClient: SipgateIOClient; 11 | 12 | beforeEach(() => { 13 | mockClient = {} as SipgateIOClient; 14 | callModule = createCallModule(mockClient); 15 | }); 16 | 17 | it('should init a call successfully', async () => { 18 | const expectedSessionId = '123456'; 19 | mockClient.post = jest.fn().mockImplementation(() => { 20 | return Promise.resolve({ 21 | sessionId: expectedSessionId, 22 | status: 200, 23 | }); 24 | }); 25 | const validExtension = 'e0'; 26 | const validCalleeNumber = '+4915177777777'; 27 | const validCallerId = '+4915122222222'; 28 | 29 | const callData: CallData = { 30 | to: validCalleeNumber, 31 | from: validExtension, 32 | callerId: validCallerId, 33 | }; 34 | 35 | await expect(callModule.initiate(callData)).resolves.not.toThrow(); 36 | 37 | const { sessionId } = await callModule.initiate(callData); 38 | expect(sessionId).toEqual(expectedSessionId); 39 | }); 40 | 41 | it('should throw an exception for malformed extension', async () => { 42 | const invalidExtensionId = 'e-18'; 43 | const validCalleeNumber = '+4915177777777'; 44 | const validCallerId = '+4915122222222'; 45 | 46 | const callData: CallData = { 47 | to: validCalleeNumber, 48 | from: invalidExtensionId, 49 | callerId: validCallerId, 50 | }; 51 | 52 | await expect(callModule.initiate(callData)).rejects.toThrowError( 53 | ValidationErrors.INVALID_CALLER 54 | ); 55 | }); 56 | 57 | it('should throw an exception for insufficient funds', async () => { 58 | mockClient.post = jest.fn().mockImplementationOnce(() => { 59 | return Promise.reject({ 60 | response: { 61 | status: 402, 62 | }, 63 | }); 64 | }); 65 | 66 | const validExtension = 'e8'; 67 | const validCalleeNumber = '+4915177777777'; 68 | const validCallerId = '+4915122222222'; 69 | 70 | const callData: CallData = { 71 | to: validCalleeNumber, 72 | from: validExtension, 73 | callerId: validCallerId, 74 | }; 75 | 76 | await expect(callModule.initiate(callData)).rejects.toThrowError( 77 | CallErrorMessage.CALL_INSUFFICIENT_FUNDS 78 | ); 79 | }); 80 | 81 | it('should throw a validation exception for malformed callee number ', async () => { 82 | const validExtensionId = 'e0'; 83 | const invalidCalleeNumber = 'not a phone number'; 84 | const validCallerId = '+494567787889'; 85 | 86 | const callData: CallData = { 87 | to: invalidCalleeNumber, 88 | from: validExtensionId, 89 | callerId: validCallerId, 90 | }; 91 | 92 | await expect(callModule.initiate(callData)).rejects.toThrowError( 93 | ErrorMessage.VALIDATOR_INVALID_PHONE_NUMBER 94 | ); 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /lib/webhook-settings/webhookSettings.ts: -------------------------------------------------------------------------------- 1 | import { SipgateIOClient } from '../core/sipgateIOClient'; 2 | import { 3 | WebhookSettings, 4 | WebhookSettingsModule, 5 | } from './webhookSettings.types'; 6 | import { handleWebhookSettingsError } from './errors/handleWebhookSettingError'; 7 | import { validateWebhookUrl } from './validators/validateWebhookUrl'; 8 | import { validateWhitelistExtensions } from './validators/validateWhitelistExtensions'; 9 | 10 | const SETTINGS_ENDPOINT = 'settings/sipgateio'; 11 | 12 | export const createSettingsModule = ( 13 | client: SipgateIOClient 14 | ): WebhookSettingsModule => ({ 15 | async setIncomingUrl(url): Promise { 16 | const validationResult = validateWebhookUrl(url); 17 | 18 | if (!validationResult.isValid) { 19 | throw new Error(validationResult.cause); 20 | } 21 | 22 | await modifyWebhookSettings( 23 | client, 24 | (settings) => (settings.incomingUrl = url) 25 | ); 26 | }, 27 | 28 | async setOutgoingUrl(url): Promise { 29 | const validationResult = validateWebhookUrl(url); 30 | if (!validationResult.isValid) { 31 | throw new Error(validationResult.cause); 32 | } 33 | 34 | await modifyWebhookSettings( 35 | client, 36 | (settings) => (settings.outgoingUrl = url) 37 | ); 38 | }, 39 | 40 | async setWhitelist(extensions): Promise { 41 | validateWhitelistExtensions(extensions); 42 | 43 | await modifyWebhookSettings( 44 | client, 45 | (settings) => (settings.whitelist = extensions) 46 | ); 47 | }, 48 | 49 | async setLog(value): Promise { 50 | await modifyWebhookSettings(client, (settings) => (settings.log = value)); 51 | }, 52 | 53 | async clearIncomingUrl(): Promise { 54 | await modifyWebhookSettings( 55 | client, 56 | (settings) => (settings.incomingUrl = '') 57 | ); 58 | }, 59 | 60 | async clearOutgoingUrl(): Promise { 61 | await modifyWebhookSettings( 62 | client, 63 | (settings) => (settings.outgoingUrl = '') 64 | ); 65 | }, 66 | 67 | async clearWhitelist(): Promise { 68 | await modifyWebhookSettings( 69 | client, 70 | (settings) => (settings.whitelist = []) 71 | ); 72 | }, 73 | 74 | async disableWhitelist(): Promise { 75 | await modifyWebhookSettings( 76 | client, 77 | (settings) => (settings.whitelist = null) 78 | ); 79 | }, 80 | 81 | getWebhookSettings(): Promise { 82 | return getWebhookSettingsFromClient(client); 83 | }, 84 | }); 85 | 86 | const getWebhookSettingsFromClient = ( 87 | client: SipgateIOClient 88 | ): Promise => { 89 | return client 90 | .get(SETTINGS_ENDPOINT) 91 | .catch((error) => Promise.reject(handleWebhookSettingsError(error))); 92 | }; 93 | 94 | const modifyWebhookSettings = async ( 95 | client: SipgateIOClient, 96 | fn: (s: WebhookSettings) => void 97 | ): Promise => { 98 | await getWebhookSettingsFromClient(client) 99 | .then((settings) => { 100 | fn(settings); 101 | return client.put(SETTINGS_ENDPOINT, settings); 102 | }) 103 | .catch((error) => Promise.reject(handleWebhookSettingsError(error))); 104 | }; 105 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sipgateio", 3 | "version": "2.15.2", 4 | "description": "The official Node.js library for sipgate.io", 5 | "main": "./dist/index.js", 6 | "browser": "./dist/browser.js", 7 | "scripts": { 8 | "lint": "eslint --fix './lib/**/*.ts'", 9 | "format": "prettier --write './lib/**/**.ts'", 10 | "test:dev": "jest --selectProjects node dom --watch", 11 | "test:dev:coverage": "jest --selectProjects node dom --watch --coverage", 12 | "test:unit": "jest --selectProjects node dom", 13 | "test:unit:noDom": "jest --selectProjects node", 14 | "test:unit:coverage": "jest --selectProjects node dom --coverage", 15 | "test:integration": "npm run bundle && jest --selectProjects bundle", 16 | "bundle": "browserify -r ./lib/browser.ts:sipgate-io -p tsify -g uglifyify > ./bundle/sipgate-io.js && terser --compress --mangle -o ./bundle/sipgate-io.min.js -- ./bundle/sipgate-io.js", 17 | "prepare": "husky install && tsc" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/sipgate-io/sipgateio-node.git" 22 | }, 23 | "files": [ 24 | "/dist" 25 | ], 26 | "author": { 27 | "name": "sipgate GmbH", 28 | "email": "io-team@sipgate.de", 29 | "url": "https://sipgate.io" 30 | }, 31 | "license": "MIT", 32 | "bugs": { 33 | "url": "https://github.com/sipgate-io/sipgateio-node/issues" 34 | }, 35 | "homepage": "https://github.com/sipgate-io/sipgateio-node#readme", 36 | "keywords": [ 37 | "sipgateio", 38 | "node", 39 | "sipgate", 40 | "call", 41 | "fax", 42 | "sms", 43 | "text", 44 | "contacts", 45 | "phone", 46 | "telephony", 47 | "rest", 48 | "api", 49 | "client" 50 | ], 51 | "lint-staged": { 52 | "*.ts": [ 53 | "npm run format", 54 | "npm run lint" 55 | ], 56 | "*.{json,md}": [ 57 | "prettier --write" 58 | ] 59 | }, 60 | "devDependencies": { 61 | "@types/google-libphonenumber": "^7.4.23", 62 | "@types/jest": "^27.5.1", 63 | "@types/jsdom": "^16.2.14", 64 | "@types/json2csv": "^5.0.3", 65 | "@types/qs": "^6.9.7", 66 | "@types/vcf": "^2.0.3", 67 | "@typescript-eslint/eslint-plugin": "^5.26.0", 68 | "@typescript-eslint/parser": "^5.26.0", 69 | "axios-mock-adapter": "^1.20.0", 70 | "browserify": "^17.0.0", 71 | "eslint": "^8.16.0", 72 | "eslint-config-prettier": "^8.5.0", 73 | "eslint-plugin-autofix": "1.1.0", 74 | "eslint-plugin-import": "^2.26.0", 75 | "eslint-plugin-node": "^11.1.0", 76 | "eslint-plugin-prettier": "^4.0.0", 77 | "eslint-plugin-promise": "^6.0.0", 78 | "eslint-plugin-sort-imports-es6-autofix": "^0.6.0", 79 | "husky": "^8.0.1", 80 | "jest": "^28.1.0", 81 | "jest-environment-jsdom": "^28.1.0", 82 | "jsdom": "19.0.0", 83 | "lint-staged": "^13.2.1", 84 | "prettier": "^2.6.2", 85 | "terser": "^5.14.0", 86 | "ts-jest": "^28.0.3", 87 | "tsify": "^5.0.4", 88 | "typescript": "^4.7.2", 89 | "uglify-js": "^3.15.5", 90 | "uglifyify": "^5.0.0" 91 | }, 92 | "dependencies": { 93 | "axios": "^0.27.2", 94 | "buffer": "^6.0.3", 95 | "detect-browser": "^5.3.0", 96 | "google-libphonenumber": "^3.2.27", 97 | "json2csv": "^5.0.7", 98 | "music-metadata": "^7.12.3", 99 | "qs": "^6.10.3", 100 | "vcf": "^2.1.1", 101 | "xml-js": "^1.6.11" 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 2.13.0 2 | 3 | ## Features 4 | 5 | - add functions to get common IDs ([9b73faa](https://github.com/sipgate-io/sipgateio-node/commit/9b73faa8629965b9fbb6faa8c4600b16a5ba12ec)) 6 | 7 | # 2.8.0 8 | 9 | ## Features 10 | 11 | - contacts can now be exported to JSON-files ([abf6355](https://github.com/sipgate-io/sipgateio-node/commit/abf63350158f39ce6d28a3e9d486b96df722d1cd)) 12 | 13 | # 2.0.0 14 | 15 | ## Features 16 | 17 | - add plus to phone numbers in webhook events ([e3494aa](https://github.com/sipgate-io/sipgateio-node/commit/e3494aa)) 18 | - specify ./dist/module.js as "browser" in package.json ([eac8175](https://github.com/sipgate-io/sipgateio-node/commit/eac8175)) 19 | - throw error when serverAddress is missing for Follow-up events ([b57db1c](https://github.com/sipgate-io/sipgateio-node/commit/b57db1c)) 20 | - allow string as port in `ServerOptions` ([d10688b](https://github.com/sipgate-io/sipgateio-node/commit/d10688be98da96c0963558836b03e3678f9da9be)) 21 | - add method to retrieve the webuser id of the authenticated webuser ([a16e5de](https://github.com/sipgate-io/sipgateio-node/commit/a16e5de316cdad17d91ecaae72a8764c4c8ea15d)) 22 | - add `getFaxlines` function to fax module ([5d4c0cd](https://github.com/sipgate-io/sipgateio-node/commit/5d4c0cdbbee007e7e3718407595735901ee8e1f7)) 23 | 24 | ## Breaking Changes 25 | 26 | From now on, Node 8 is not supported anymore. It has reached End Of Life in December 2019 and you should upgrade to newer versions. 27 | 28 | We have switched from using `master` as the default branch name to `main`. Unless you directly depended on that branch name there is nothing you should need to be doing. 29 | 30 | - rename `from` and `to` to `startDate` and `endDate` in order to avoid confusion ([8bb8d41](https://github.com/sipgate-io/sipgateio-node/commit/8bb8d410f6d1a5810a6d74631ef0a99e61e9a97d)) 31 | - rename `import` to `create` and `exportAsObject` to `get` in contacts module ([6beadaa](https://github.com/sipgate-io/sipgateio-node/commit/6beadaaccf33df100564d9f78366191d5d675848)) 32 | - the fields `user[]`, `userId[]` und `fullUserId[]` are now called `users`, `userIds` and `originalUserFull` ([a9572d](https://github.com/sipgate-io/sipgateio-node/commit/a9572df5359a81a491f9c2dfcfbbb1c7c5037766)) 33 | - the `get`, `post` etc. methods on the `SipgateIOClient` now return the response directly instead of an axios object 34 | - the http methods `get`, `post` etc. on the `SipgateIOClient` dont choose `any` as the default type parameter anymore ([f1f1931](https://github.com/sipgate-io/sipgateio-node/commit/f1f1931d9b379f34aa3cda02da81c94454a5b542)) 35 | - remove interfaces marked as `@deprecated` ([f7f4c7f](https://github.com/sipgate-io/sipgateio-node/commit/f7f4c7f723428d3b5803732f8f8e60c35b73f919)) 36 | - fix: contact import now accepts an array of string arrays for organizations ([3f55e75](https://github.com/sipgate-io/sipgateio-node/commit/3f55e75)) 37 | - fix: don't export HistoryDirection as Direction ([c80341a](https://github.com/sipgate-io/sipgateio-node/commit/c80341a)) 38 | - fix: export additional types from top-level index.ts ([626209c](https://github.com/sipgate-io/sipgateio-node/commit/626209c)) 39 | - fix: make user, userId and fullUserId optional in type AnswerEvent ([52d96cf](https://github.com/sipgate-io/sipgateio-node/commit/52d96cf)) 40 | - fix: the FaxHistoryEntry now correctly exposes the faxStatus key instead of faxStatusType ([3401bb5](https://github.com/sipgate-io/sipgateio-node/commit/3401bb5)) 41 | -------------------------------------------------------------------------------- /lib/fax/fax.test.ts: -------------------------------------------------------------------------------- 1 | import { FaxDTO } from './fax.types'; 2 | import { FaxErrorMessage } from './errors/handleFaxError'; 3 | import { SipgateIOClient } from '../core/sipgateIOClient'; 4 | import { createFaxModule } from './fax'; 5 | import validPDFBuffer from './testfiles/validPDFBuffer'; 6 | 7 | describe('SendFax', () => { 8 | let mockClient: SipgateIOClient; 9 | 10 | beforeAll(() => { 11 | mockClient = {} as SipgateIOClient; 12 | }); 13 | 14 | test('fax is sent', async () => { 15 | const faxModule = createFaxModule(mockClient); 16 | 17 | mockClient.post = jest 18 | .fn() 19 | .mockImplementationOnce(() => Promise.resolve({ sessionId: '123123' })); 20 | mockClient.get = jest 21 | .fn() 22 | .mockImplementationOnce(() => 23 | Promise.resolve({ type: 'FAX', faxStatusType: 'SENT' }) 24 | ); 25 | 26 | const to = '+4912368712'; 27 | const fileContent = validPDFBuffer; 28 | const faxlineId = 'f0'; 29 | 30 | await expect( 31 | faxModule.send({ 32 | faxlineId, 33 | fileContent, 34 | filename: 'testPdfFileName', 35 | to, 36 | }) 37 | ).resolves.not.toThrow(); 38 | }); 39 | 40 | test('fax is sent without given filename', async () => { 41 | mockClient.post = jest 42 | .fn() 43 | .mockImplementationOnce((_, { filename }: FaxDTO) => { 44 | expect(filename && /^Fax_2\d{7}_\d{4}$/.test(filename)).toBeTruthy(); 45 | return Promise.resolve({ sessionId: 123456 }); 46 | }); 47 | 48 | mockClient.get = jest 49 | .fn() 50 | .mockImplementationOnce(() => 51 | Promise.resolve({ type: 'FAX', faxStatusType: 'SENT' }) 52 | ); 53 | 54 | const faxModule = createFaxModule(mockClient); 55 | 56 | const to = '+4912368712'; 57 | const fileContent = validPDFBuffer; 58 | const faxlineId = 'f0'; 59 | 60 | await faxModule.send({ to, fileContent, faxlineId }); 61 | }); 62 | }); 63 | 64 | describe('GetFaxStatus', () => { 65 | let mockClient: SipgateIOClient; 66 | 67 | beforeAll(() => { 68 | mockClient = {} as SipgateIOClient; 69 | }); 70 | 71 | test('throws exception when fax status could not be fetched', async () => { 72 | mockClient.get = jest.fn().mockImplementationOnce(() => { 73 | return Promise.reject({ 74 | response: { 75 | status: 404, 76 | }, 77 | }); 78 | }); 79 | 80 | const faxModule = createFaxModule(mockClient); 81 | 82 | await expect(faxModule.getFaxStatus('12345')).rejects.toThrowError( 83 | FaxErrorMessage.FAX_NOT_FOUND 84 | ); 85 | }); 86 | }); 87 | 88 | describe('getFaxlines', () => { 89 | const mockuserId = 'w2'; 90 | const mockAuthenticatedWebUserID = 'w0'; 91 | const mockClient: SipgateIOClient = {} as SipgateIOClient; 92 | 93 | test('extracts the `items` from the API response', async () => { 94 | const testFaxlines = [ 95 | { 96 | id: 'f0', 97 | alias: "Alexander Bain's fax", 98 | tagline: 'Example Ltd.', 99 | canSend: false, 100 | canReceive: true, 101 | }, 102 | ]; 103 | 104 | mockClient.get = jest 105 | .fn() 106 | .mockImplementation(() => Promise.resolve({ items: testFaxlines })); 107 | mockClient.getAuthenticatedWebuserId = jest 108 | .fn() 109 | .mockImplementation(() => Promise.resolve(mockAuthenticatedWebUserID)); 110 | 111 | const faxModule = createFaxModule(mockClient); 112 | 113 | const faxlines = await faxModule.getFaxlines(); 114 | expect(faxlines).toEqual(testFaxlines); 115 | expect(mockClient.get).toHaveBeenCalledWith( 116 | `${mockAuthenticatedWebUserID}/faxlines` 117 | ); 118 | 119 | const faxlinesByWebUser = await faxModule.getFaxlinesByWebUser(mockuserId); 120 | expect(faxlinesByWebUser).toEqual(testFaxlines); 121 | expect(mockClient.get).toHaveBeenCalledWith(`${mockuserId}/faxlines`); 122 | }); 123 | }); 124 | -------------------------------------------------------------------------------- /lib/contacts/contacts.types.ts: -------------------------------------------------------------------------------- 1 | import { PagedResponse, Pagination } from '../core'; 2 | 3 | export interface ContactsModule { 4 | create: (contact: ContactImport, scope: Scope) => Promise; 5 | update: (contact: ContactUpdate) => Promise; 6 | deleteAllPrivate: () => Promise; 7 | deleteAllShared: () => Promise; 8 | delete: (id: string) => Promise; 9 | importFromCsvString: (csvContent: string) => Promise; 10 | importVCardString: (vcardContent: string, scope: Scope) => Promise; 11 | paginatedExportAsCsv: ( 12 | scope: ExportScope, 13 | delimiter?: string, 14 | pagination?: Pagination, 15 | filter?: ContactsExportFilter 16 | ) => Promise>; 17 | /** 18 | * @deprecated You should prefer to use `paginatedExportAsCSV` 19 | */ 20 | exportAsCsv: ( 21 | scope: ExportScope, 22 | delimiter?: string, 23 | pagination?: Pagination, 24 | filter?: ContactsExportFilter 25 | ) => Promise; 26 | paginatedExportAsVCards: ( 27 | scope: ExportScope, 28 | pagination?: Pagination, 29 | filter?: ContactsExportFilter 30 | ) => Promise>; 31 | paginatedExportAsJSON: ( 32 | scope: ExportScope, 33 | pagination?: Pagination, 34 | filter?: ContactsExportFilter 35 | ) => Promise>; 36 | /** 37 | * @deprecated You should prefer to use `paginatedExportAsJSON` 38 | */ 39 | exportAsJSON: ( 40 | scope: ExportScope, 41 | pagination?: Pagination, 42 | filter?: ContactsExportFilter 43 | ) => Promise; 44 | /** 45 | * @deprecated You should prefer to use `paginatedExportAsVCards` 46 | */ 47 | exportAsVCards: ( 48 | scope: ExportScope, 49 | pagination?: Pagination, 50 | filter?: ContactsExportFilter 51 | ) => Promise; 52 | paginatedExportAsSingleVCard: ( 53 | scope: ExportScope, 54 | pagination?: Pagination, 55 | filter?: ContactsExportFilter 56 | ) => Promise>; 57 | /** 58 | * @deprecated You should prefer to use `paginatedExportAsSingleVCard` 59 | */ 60 | exportAsSingleVCard: ( 61 | scope: ExportScope, 62 | pagination?: Pagination, 63 | filter?: ContactsExportFilter 64 | ) => Promise; 65 | paginatedGet: ( 66 | scope: ExportScope, 67 | pagination?: Pagination, 68 | filter?: ContactsExportFilter 69 | ) => Promise>; 70 | /** 71 | * @deprecated You should prefer to use `paginatedGet` 72 | */ 73 | get: ( 74 | scope: ExportScope, 75 | pagination?: Pagination, 76 | filter?: ContactsExportFilter 77 | ) => Promise; 78 | } 79 | 80 | export interface ContactImport { 81 | firstname: string; 82 | lastname: string; 83 | address?: Address; 84 | phone?: PhoneNumber; 85 | email?: Email; 86 | picture?: string; 87 | organization?: string[][]; 88 | } 89 | 90 | export interface Email { 91 | email: string; 92 | type: string[]; 93 | } 94 | 95 | export interface PhoneNumber { 96 | number: string; 97 | type: string[]; 98 | } 99 | 100 | export interface Address { 101 | poBox: string; 102 | extendedAddress: string; 103 | streetAddress: string; 104 | locality: string; 105 | region: string; 106 | postalCode: string; 107 | country: string; 108 | } 109 | 110 | export interface ContactsDTO { 111 | name: string; 112 | family: string; 113 | given: string; 114 | picture: string | null; 115 | emails: Email[]; 116 | numbers: PhoneNumber[]; 117 | addresses: Address[]; 118 | organization: string[][]; 119 | scope: Scope; 120 | } 121 | 122 | export type Scope = 'PRIVATE' | 'SHARED'; 123 | 124 | type ExportScope = Scope | 'INTERNAL' | 'ALL'; 125 | 126 | export type ContactUpdate = ContactResponse; 127 | 128 | export interface ContactResponse { 129 | id: string; 130 | name: string; 131 | picture: string | null; 132 | emails: Email[]; 133 | numbers: PhoneNumber[]; 134 | addresses: Address[]; 135 | organization: string[][]; 136 | scope: Scope; 137 | } 138 | 139 | export interface ContactsListResponse { 140 | items: ContactResponse[]; 141 | totalCount: number; 142 | } 143 | 144 | export interface ContactsExportFilter { 145 | phonenumbers: string[]; 146 | } 147 | 148 | export interface ImportCSVRequestDTO { 149 | base64Content: string; 150 | } 151 | -------------------------------------------------------------------------------- /lib/rtcm/rtcm.test.ts: -------------------------------------------------------------------------------- 1 | import { RTCMCall } from './rtcm.types'; 2 | import { RtcmErrorMessage } from './errors/handleRtcmError'; 3 | import { SipgateIOClient } from '../core/sipgateIOClient'; 4 | import { createRTCMModule } from './rtcm'; 5 | 6 | describe('RTCM Module', () => { 7 | let mockClient: SipgateIOClient; 8 | 9 | beforeEach(() => { 10 | mockClient = {} as SipgateIOClient; 11 | }); 12 | 13 | it('validates the DTMF sequence correctly and throws an error if invalid', async () => { 14 | const rtcmModule = createRTCMModule(mockClient); 15 | 16 | await expect( 17 | rtcmModule.sendDTMF({} as RTCMCall, ' A') 18 | ).rejects.toThrowError(RtcmErrorMessage.DTMF_INVALID_SEQUENCE); 19 | }); 20 | 21 | it('uppercases the DTMF sequence and validates it correctly', async () => { 22 | mockClient.post = jest.fn().mockImplementationOnce(() => { 23 | return Promise.resolve({ 24 | response: { 25 | status: 204, 26 | }, 27 | }); 28 | }); 29 | 30 | const rtcmModule = createRTCMModule(mockClient); 31 | 32 | await expect( 33 | rtcmModule.sendDTMF({} as RTCMCall, 'abc') 34 | ).resolves.not.toThrow(); 35 | }); 36 | 37 | it('returns the correct error message if a call could not be found', async () => { 38 | mockClient.put = jest.fn().mockImplementationOnce(() => { 39 | return Promise.reject({ 40 | response: { 41 | status: 404, 42 | }, 43 | }); 44 | }); 45 | 46 | const rtcmModule = createRTCMModule(mockClient); 47 | 48 | await expect(rtcmModule.mute({} as RTCMCall, false)).rejects.toThrowError( 49 | RtcmErrorMessage.CALL_NOT_FOUND 50 | ); 51 | }); 52 | 53 | it('returns the correct error message if a call could not be found', async () => { 54 | mockClient.put = jest.fn().mockImplementationOnce(() => { 55 | return Promise.reject({ 56 | response: { 57 | status: 404, 58 | }, 59 | }); 60 | }); 61 | 62 | const rtcmModule = createRTCMModule(mockClient); 63 | 64 | await expect( 65 | rtcmModule.record({} as RTCMCall, { announcement: true, value: true }) 66 | ).rejects.toThrowError(RtcmErrorMessage.CALL_NOT_FOUND); 67 | }); 68 | 69 | it('returns the correct error message if a call could not be found', async () => { 70 | mockClient.post = jest.fn().mockImplementationOnce(() => { 71 | return Promise.reject({ 72 | response: { 73 | status: 404, 74 | }, 75 | }); 76 | }); 77 | 78 | const rtcmModule = createRTCMModule(mockClient); 79 | 80 | await expect( 81 | rtcmModule.announce( 82 | {} as RTCMCall, 83 | 'https://static.sipgate.com/examples/wav/example.wav' 84 | ) 85 | ).rejects.toThrowError(RtcmErrorMessage.CALL_NOT_FOUND); 86 | }); 87 | it('returns the correct error message if a call could not be found', async () => { 88 | mockClient.post = jest.fn().mockImplementationOnce(() => { 89 | return Promise.reject({ 90 | response: { 91 | status: 404, 92 | }, 93 | }); 94 | }); 95 | 96 | const rtcmModule = createRTCMModule(mockClient); 97 | 98 | await expect( 99 | rtcmModule.transfer({} as RTCMCall, { 100 | attended: true, 101 | phoneNumber: '+49123456789test', 102 | }) 103 | ).rejects.toThrowError(RtcmErrorMessage.CALL_NOT_FOUND); 104 | }); 105 | it('returns the correct error message if a call could not be found', async () => { 106 | mockClient.post = jest.fn().mockImplementationOnce(() => { 107 | return Promise.reject({ 108 | response: { 109 | status: 404, 110 | }, 111 | }); 112 | }); 113 | 114 | const rtcmModule = createRTCMModule(mockClient); 115 | 116 | await expect( 117 | rtcmModule.sendDTMF({} as RTCMCall, 'abc') 118 | ).rejects.toThrowError(RtcmErrorMessage.CALL_NOT_FOUND); 119 | }); 120 | 121 | it('returns the correct error message if a call could not be found', async () => { 122 | mockClient.put = jest.fn().mockImplementationOnce(() => { 123 | return Promise.reject({ 124 | response: { 125 | status: 404, 126 | }, 127 | }); 128 | }); 129 | 130 | const rtcmModule = createRTCMModule(mockClient); 131 | 132 | await expect(rtcmModule.hold({} as RTCMCall, false)).rejects.toThrowError( 133 | RtcmErrorMessage.CALL_NOT_FOUND 134 | ); 135 | }); 136 | 137 | it('returns the correct error message if a call could not be found', async () => { 138 | mockClient.delete = jest.fn().mockImplementationOnce(() => { 139 | return Promise.reject({ 140 | response: { 141 | status: 404, 142 | }, 143 | }); 144 | }); 145 | 146 | const rtcmModule = createRTCMModule(mockClient); 147 | 148 | await expect(rtcmModule.hangUp({} as RTCMCall)).rejects.toThrowError( 149 | RtcmErrorMessage.CALL_NOT_FOUND 150 | ); 151 | }); 152 | }); 153 | -------------------------------------------------------------------------------- /lib/history/history.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseHistoryFilter, 3 | HistoryEntry, 4 | HistoryEntryType, 5 | HistoryEntryUpdateOptionsWithId, 6 | HistoryFilterDTO, 7 | HistoryModule, 8 | HistoryResponse, 9 | HistoryResponseItem, 10 | Starred, 11 | StarredDTO, 12 | } from './history.types'; 13 | import { SipgateIOClient } from '../core/sipgateIOClient'; 14 | import { handleHistoryError } from './errors/handleHistoryError'; 15 | import { validateExtension } from '../core/validator'; 16 | 17 | export const createHistoryModule = ( 18 | client: SipgateIOClient 19 | ): HistoryModule => ({ 20 | async fetchAll(filter = {}, pagination): Promise { 21 | validateFilteredExtension(filter); 22 | 23 | const historyFilterDTO: HistoryFilterDTO & { phonenumber?: string } = { 24 | archived: filter.archived, 25 | connectionIds: filter.connectionIds, 26 | directions: filter.directions, 27 | from: filter.startDate, 28 | starred: mapStarredToDTO(filter.starred), 29 | to: filter.endDate, 30 | types: filter.types, 31 | phonenumber: filter.phonenumber, 32 | }; 33 | 34 | return client 35 | .get('/history', { 36 | params: { 37 | ...historyFilterDTO, 38 | ...pagination, 39 | }, 40 | }) 41 | .then((response) => response.items.map(transformHistoryEntry)) 42 | .catch((error) => Promise.reject(handleHistoryError(error))); 43 | }, 44 | fetchById(entryId): Promise { 45 | return client 46 | .get(`/history/${entryId}`) 47 | .catch((error) => Promise.reject(handleHistoryError(error))); 48 | }, 49 | async deleteById(entryId): Promise { 50 | await client 51 | .delete(`/history/${entryId}`) 52 | .catch((error) => Promise.reject(handleHistoryError(error))); 53 | }, 54 | async deleteByListOfIds(entryIds): Promise { 55 | await client 56 | .delete(`/history`, { 57 | params: { 58 | id: entryIds, 59 | }, 60 | }) 61 | .catch((error) => Promise.reject(handleHistoryError(error))); 62 | }, 63 | async batchUpdateEvents(events, callback): Promise { 64 | const mappedEvents = events.map((event) => { 65 | return { 66 | id: event.id, 67 | ...callback(event), 68 | }; 69 | }); 70 | 71 | const eventsToModify = mappedEvents.filter( 72 | (eventUpdate) => Object.keys(eventUpdate).length > 1 73 | ); 74 | 75 | const eventsWithNote: HistoryEntryUpdateOptionsWithId[] = []; 76 | const eventsWithoutNote: HistoryEntryUpdateOptionsWithId[] = []; 77 | 78 | eventsToModify.forEach((event) => { 79 | if (event.note === undefined) { 80 | eventsWithoutNote.push(event); 81 | } else { 82 | eventsWithNote.push(event); 83 | } 84 | }); 85 | 86 | await Promise.all([ 87 | ...eventsWithNote.map((event) => 88 | client.put(`history/${event.id}`, { 89 | ...event, 90 | id: undefined, 91 | }) 92 | ), 93 | client.put('history', eventsWithoutNote), 94 | ]).catch((error) => Promise.reject(handleHistoryError(error))); 95 | }, 96 | async exportAsCsvString(filter = {}, pagination): Promise { 97 | validateFilteredExtension(filter); 98 | 99 | const historyFilterDTO: HistoryFilterDTO = { 100 | archived: filter.archived, 101 | connectionIds: filter.connectionIds, 102 | directions: filter.directions, 103 | from: filter.startDate, 104 | starred: mapStarredToDTO(filter.starred), 105 | to: filter.endDate, 106 | types: filter.types, 107 | }; 108 | 109 | return client 110 | .get('/history/export', { 111 | params: { 112 | ...historyFilterDTO, 113 | ...pagination, 114 | }, 115 | }) 116 | .catch((error) => Promise.reject(handleHistoryError(error))); 117 | }, 118 | }); 119 | 120 | const validateFilteredExtension = (filter?: BaseHistoryFilter): void => { 121 | if (filter && filter.connectionIds) { 122 | const result = filter.connectionIds 123 | .map((id) => validateExtension(id)) 124 | .find((validationResult) => validationResult.isValid === false); 125 | if (result && result.isValid === false) { 126 | throw new Error(result.cause); 127 | } 128 | } 129 | }; 130 | 131 | const transformHistoryEntry = (entry: HistoryResponseItem): HistoryEntry => { 132 | if (entry.type === HistoryEntryType.FAX) { 133 | const { faxStatusType, ...rest } = entry; 134 | return { ...rest, faxStatus: faxStatusType }; 135 | } 136 | 137 | return entry; 138 | }; 139 | 140 | function mapStarredToDTO( 141 | starred: boolean | Starred | undefined 142 | ): StarredDTO | undefined { 143 | switch (starred) { 144 | case true: 145 | case Starred.STARRED: 146 | return StarredDTO.STARRED; 147 | 148 | case false: 149 | case Starred.UNSTARRED: 150 | return StarredDTO.UNSTARRED; 151 | 152 | case undefined: 153 | return undefined; 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /lib/history/history.types.ts: -------------------------------------------------------------------------------- 1 | import { Pagination } from '../core'; 2 | 3 | export interface HistoryModule { 4 | fetchAll: ( 5 | filter?: HistoryFilter, 6 | pagination?: Pagination 7 | ) => Promise; 8 | fetchById: (entryId: string) => Promise; 9 | deleteByListOfIds: (entryIds: string[]) => Promise; 10 | deleteById: (entryId: string) => Promise; 11 | batchUpdateEvents: ( 12 | events: HistoryEntry[], 13 | callback: (entry: HistoryEntry) => HistoryEntryUpdateOptions 14 | ) => Promise; 15 | exportAsCsvString: ( 16 | filter?: BaseHistoryFilter, 17 | pagination?: Pagination 18 | ) => Promise; 19 | } 20 | 21 | export interface HistoryEntryUpdateOptions { 22 | archived?: boolean; 23 | starred?: boolean; 24 | note?: string; 25 | read?: boolean; 26 | } 27 | 28 | export interface HistoryEntryUpdateOptionsWithId 29 | extends HistoryEntryUpdateOptions { 30 | id: string; 31 | } 32 | 33 | export interface BaseHistoryFilter { 34 | connectionIds?: string[]; 35 | types?: HistoryEntryType[]; 36 | directions?: HistoryDirection[]; 37 | archived?: boolean; 38 | starred?: boolean | Starred; 39 | startDate?: Date; 40 | endDate?: Date; 41 | } 42 | 43 | export interface HistoryFilterDTO { 44 | connectionIds?: string[]; 45 | types?: HistoryEntryType[]; 46 | directions?: HistoryDirection[]; 47 | archived?: boolean; 48 | starred?: StarredDTO; 49 | from?: Date; 50 | to?: Date; 51 | } 52 | 53 | export interface HistoryFilter extends BaseHistoryFilter { 54 | phonenumber?: string; 55 | } 56 | 57 | export enum HistoryEntryType { 58 | CALL = 'CALL', 59 | VOICEMAIL = 'VOICEMAIL', 60 | SMS = 'SMS', 61 | FAX = 'FAX', 62 | } 63 | 64 | export enum HistoryDirection { 65 | INCOMING = 'INCOMING', 66 | OUTGOING = 'OUTGOING', 67 | MISSED_INCOMING = 'MISSED_INCOMING', 68 | MISSED_OUTGOING = 'MISSED_OUTGOING', 69 | } 70 | 71 | /** 72 | * @deprecated You should prefer to use a boolean in the filter 73 | */ 74 | export enum Starred { 75 | STARRED = 'STARRED', 76 | UNSTARRED = 'UNSTARRED', 77 | } 78 | export enum StarredDTO { 79 | STARRED = 'STARRED', 80 | UNSTARRED = 'UNSTARRED', 81 | } 82 | 83 | export interface BaseHistoryEntry { 84 | id: string; 85 | source: string; 86 | target: string; 87 | sourceAlias: string; 88 | targetAlias: string; 89 | type: HistoryEntryType; 90 | created: Date; 91 | lastModified: Date; 92 | direction: HistoryDirection; 93 | incoming: boolean; 94 | status: string; 95 | connectionIds: string[]; 96 | read: boolean; 97 | archived: boolean; 98 | note: string; 99 | endpoints: RoutedEndpoint[]; 100 | starred: boolean; 101 | labels: string[]; 102 | } 103 | 104 | export interface Endpoint { 105 | extension: string; 106 | type: string; 107 | } 108 | 109 | export interface RoutedEndpoint { 110 | type: string; 111 | endpoint: Endpoint; 112 | } 113 | 114 | export interface FaxHistoryEntry extends BaseHistoryEntry { 115 | type: HistoryEntryType.FAX; 116 | faxStatus: FaxStatusType; 117 | scheduled: string; 118 | documentUrl: string; 119 | reportUrl: string; 120 | previewUrl: string; 121 | pageCount: number; 122 | } 123 | 124 | type HistoryResponseFaxItem = Omit & { 125 | faxStatusType: FaxStatusType; 126 | }; 127 | 128 | export enum FaxStatusType { 129 | PENDING = 'PENDING', 130 | SENDING = 'SENDING', 131 | FAILED = 'FAILED', 132 | SENT = 'SENT', 133 | SCHEDULED = 'SCHEDULED', 134 | } 135 | 136 | export interface CallHistoryEntry extends BaseHistoryEntry { 137 | type: HistoryEntryType.CALL; 138 | callId: string; 139 | recordings: Recording[]; 140 | duration: number; 141 | callStatus: CallStatusType; 142 | } 143 | 144 | export interface Recording { 145 | id: string; 146 | url: string; 147 | } 148 | 149 | export enum CallStatusType { 150 | SUCCESS = 'SUCCESS', 151 | FAILURE = 'FAILURE', 152 | REJECTED = 'REJECTED', 153 | REJECTED_DND = 'REJECTED_DND', 154 | VOICEMAIL_NO_MESSAGE = 'VOICEMAIL_NO_MESSAGE', 155 | BUSY_ON_BUSY = 'BUSY_ON_BUSY', 156 | BUSY = 'BUSY', 157 | MISSED = 'MISSED', 158 | } 159 | 160 | export interface SmsHistoryEntry extends BaseHistoryEntry { 161 | type: HistoryEntryType.SMS; 162 | smsContent: string; 163 | scheduled: string; 164 | } 165 | 166 | export interface VoicemailHistoryEntry extends BaseHistoryEntry { 167 | type: HistoryEntryType.VOICEMAIL; 168 | transcription: string; 169 | recordingUrl: string; 170 | duration: number; 171 | } 172 | 173 | export type HistoryEntry = 174 | | CallHistoryEntry 175 | | FaxHistoryEntry 176 | | SmsHistoryEntry 177 | | VoicemailHistoryEntry; 178 | 179 | export type HistoryResponseItem = 180 | | Exclude 181 | | HistoryResponseFaxItem; 182 | 183 | export interface HistoryResponse { 184 | items: HistoryResponseItem[]; 185 | totalCount: number; 186 | } 187 | -------------------------------------------------------------------------------- /lib/webhook/webhook.types.ts: -------------------------------------------------------------------------------- 1 | import { Server } from 'http'; 2 | import { SipgateIOClient, TransferOptions } from '..'; 3 | 4 | export enum EventType { 5 | NEW_CALL = 'newCall', 6 | ANSWER = 'answer', 7 | HANGUP = 'hangup', 8 | DATA = 'dtmf', 9 | } 10 | 11 | export type HandlerCallback = (event: T) => U; 12 | 13 | export type NewCallCallback = HandlerCallback< 14 | NewCallEvent, 15 | ResponseObject | Promise | void 16 | >; 17 | export type AnswerCallback = HandlerCallback; 18 | export type HangUpCallback = HandlerCallback; 19 | export type DataCallback = HandlerCallback< 20 | DataEvent, 21 | ResponseObject | Promise | void 22 | >; 23 | 24 | export interface WebhookHandlers { 25 | [EventType.NEW_CALL]?: NewCallCallback; 26 | [EventType.ANSWER]?: AnswerCallback; 27 | [EventType.HANGUP]?: HangUpCallback; 28 | [EventType.DATA]?: DataCallback; 29 | } 30 | export interface WebhookServer { 31 | onNewCall: (fn: NewCallCallback) => void; 32 | onAnswer: (fn: AnswerCallback) => void; 33 | onHangUp: (fn: HangUpCallback) => void; 34 | onData: (fn: DataCallback) => void; 35 | stop: () => void; 36 | getHttpServer: () => Server; 37 | } 38 | 39 | export interface ServerOptions { 40 | port: number | string; 41 | serverAddress: string; 42 | hostname?: string; 43 | skipSignatureVerification?: boolean; 44 | } 45 | 46 | export interface WebhookModule { 47 | createServer: (serverOptions: ServerOptions) => Promise; 48 | } 49 | 50 | export interface WebhookResponseInterface { 51 | redirectCall: (redirectOptions: RedirectOptions) => RedirectObject; 52 | gatherDTMF: (gatherOptions: GatherOptions) => Promise; 53 | playAudio: (playOptions: PlayOptions) => Promise; 54 | playAudioAndHangUp: ( 55 | playOptions: PlayOptions, 56 | client: SipgateIOClient, 57 | callId: string, 58 | timeout?: number 59 | ) => Promise; 60 | playAudioAndTransfer: ( 61 | playOptions: PlayOptions, 62 | transferOptions: TransferOptions, 63 | client: SipgateIOClient, 64 | callId: string, 65 | timeout?: number 66 | ) => Promise; 67 | rejectCall: (rejectOptions: RejectOptions) => RejectObject; 68 | hangUpCall: () => HangUpObject; 69 | sendToVoicemail: () => VoicemailObject; 70 | } 71 | 72 | export enum RejectReason { 73 | BUSY = 'busy', 74 | REJECTED = 'rejected', 75 | } 76 | 77 | export enum WebhookDirection { 78 | IN = 'in', 79 | OUT = 'out', 80 | } 81 | 82 | export enum HangUpCause { 83 | NORMAL_CLEARING = 'normalClearing', 84 | BUSY = 'busy', 85 | CANCEL = 'cancel', 86 | NO_ANSWER = 'noAnswer', 87 | CONGESTION = 'congestion', 88 | NOT_FOUND = 'notFound', 89 | FORWARDED = 'forwarded', 90 | } 91 | 92 | export interface GenericEvent { 93 | event: EventType; 94 | callId: string; 95 | originalCallId: string; 96 | } 97 | 98 | interface GenericCallEvent extends GenericEvent { 99 | direction: WebhookDirection; 100 | from: string; 101 | to: string; 102 | xcid: string; 103 | } 104 | 105 | export interface NewCallEvent extends GenericCallEvent { 106 | event: EventType.NEW_CALL; 107 | originalCallId: string; 108 | users: string[]; 109 | userIds: string[]; 110 | fullUserIds: string[]; 111 | } 112 | 113 | export interface AnswerEvent extends GenericCallEvent { 114 | event: EventType.ANSWER; 115 | answeringNumber: string; 116 | user?: string; 117 | userId?: string; 118 | fullUserId?: string; 119 | diversion?: string; 120 | } 121 | 122 | export interface DataEvent extends GenericEvent { 123 | event: EventType.DATA; 124 | dtmf: string; // Can begin with zero, so it has to be a string 125 | } 126 | 127 | export interface HangUpEvent extends GenericCallEvent { 128 | event: EventType.HANGUP; 129 | cause: HangUpCause; 130 | answeringNumber: string; 131 | } 132 | 133 | export type CallEvent = NewCallEvent | AnswerEvent | HangUpEvent | DataEvent; 134 | 135 | export type RedirectOptions = { 136 | numbers: string[]; 137 | anonymous?: boolean; 138 | callerId?: string; 139 | }; 140 | 141 | export type GatherOptions = { 142 | announcement?: string; 143 | maxDigits: number; 144 | timeout: number; 145 | }; 146 | 147 | export type PlayOptions = { 148 | announcement: string; 149 | }; 150 | 151 | export type RejectOptions = { 152 | reason: RejectReason; 153 | }; 154 | 155 | export type RedirectObject = { 156 | Dial: { 157 | _attributes: { callerId?: string; anonymous?: string }; 158 | Number: string[]; 159 | }; 160 | }; 161 | 162 | export type GatherObject = { 163 | Gather: { 164 | _attributes: { onData?: string; maxDigits?: string; timeout?: string }; 165 | Play?: { Url: string }; 166 | }; 167 | }; 168 | 169 | export type PlayObject = { 170 | Play: { Url: string }; 171 | }; 172 | 173 | export type RejectObject = { 174 | Reject: { _attributes: { reason?: string } }; 175 | }; 176 | 177 | export type HangUpObject = { 178 | Hangup: {}; 179 | }; 180 | 181 | export type VoicemailObject = { 182 | Dial: { Voicemail: {} }; 183 | }; 184 | 185 | export type ResponseObject = 186 | | RedirectObject 187 | | VoicemailObject 188 | | PlayObject 189 | | GatherObject 190 | | HangUpObject 191 | | RejectObject; 192 | -------------------------------------------------------------------------------- /bundle/sipgate-io.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const fs = require('fs'); 3 | const { TextDecoder, TextEncoder } = require('util'); 4 | 5 | global.TextEncoder = TextEncoder; 6 | global.TextDecoder = TextDecoder; 7 | 8 | describe('sipgate-io module', () => { 9 | let dom; 10 | let bundle; 11 | 12 | beforeAll(() => { 13 | const { JSDOM } = require('jsdom'); 14 | const io = fs.readFileSync('./bundle/sipgate-io.min.js'); 15 | const html = ``; 16 | dom = new JSDOM(html, { runScripts: 'dangerously' }); 17 | bundle = dom.window.require('sipgate-io'); 18 | }); 19 | 20 | it('should have a sipgateIO method', () => { 21 | expect(bundle.sipgateIO).toBeDefined(); 22 | }); 23 | }); 24 | 25 | describe('sipgate-io client', () => { 26 | let dom; 27 | let bundle; 28 | let sipgateClient; 29 | 30 | beforeAll(() => { 31 | const { JSDOM } = require('jsdom'); 32 | const io = fs.readFileSync('./bundle/sipgate-io.min.js'); 33 | const html = ``; 34 | dom = new JSDOM(html, { runScripts: 'dangerously' }); 35 | bundle = dom.window.require('sipgate-io'); 36 | sipgateClient = bundle.sipgateIO({ 37 | username: 'dummy@sipgate.de', 38 | password: '1234', 39 | }); 40 | }); 41 | 42 | it('should contain a call module with an initiate function', () => { 43 | const call = bundle.createCallModule(sipgateClient); 44 | expect(call).toBeDefined(); 45 | expect(call.initiate).toBeDefined(); 46 | expect(typeof call.initiate).toEqual('function'); 47 | }); 48 | 49 | it('should contain a fax module with a send function', () => { 50 | const fax = bundle.createFaxModule(sipgateClient); 51 | expect(fax).toBeDefined(); 52 | expect(fax.send).toBeDefined(); 53 | expect(typeof fax.send).toEqual('function'); 54 | }); 55 | 56 | it('should contain a sms module with a send function', () => { 57 | const sms = bundle.createSMSModule(sipgateClient); 58 | expect(sms).toBeDefined(); 59 | expect(sms.send).toBeDefined(); 60 | expect(typeof sms.send).toEqual('function'); 61 | }); 62 | 63 | it('should contain a history module with a fetchById function', () => { 64 | const history = bundle.createHistoryModule(sipgateClient); 65 | expect(history).toBeDefined(); 66 | expect(history.fetchById).toBeDefined(); 67 | expect(typeof history.fetchById).toEqual('function'); 68 | }); 69 | 70 | it('should contain a settings module with a setIncomingUrl function', () => { 71 | const webhookSettings = bundle.createSettingsModule(sipgateClient); 72 | expect(webhookSettings.setIncomingUrl).toBeDefined(); 73 | expect(typeof webhookSettings.setIncomingUrl).toEqual('function'); 74 | }); 75 | 76 | it('should contain a settings module with a setOutgoingUrl function', () => { 77 | const webhookSettings = bundle.createSettingsModule(sipgateClient); 78 | expect(webhookSettings.setOutgoingUrl).toBeDefined(); 79 | expect(typeof webhookSettings.setOutgoingUrl).toEqual('function'); 80 | }); 81 | 82 | it('should contain a settings module with a setWhitelist function', () => { 83 | const webhookSettings = bundle.createSettingsModule(sipgateClient); 84 | expect(webhookSettings.setWhitelist).toBeDefined(); 85 | expect(typeof webhookSettings.setWhitelist).toEqual('function'); 86 | }); 87 | 88 | it('should contain a settings module with a setLog function', () => { 89 | const webhookSettings = bundle.createSettingsModule(sipgateClient); 90 | expect(webhookSettings.setLog).toBeDefined(); 91 | expect(typeof webhookSettings.setLog).toEqual('function'); 92 | }); 93 | 94 | it('should contain a settings module with a clearIncomingUrl function', () => { 95 | const webhookSettings = bundle.createSettingsModule(sipgateClient); 96 | expect(webhookSettings.clearIncomingUrl).toBeDefined(); 97 | expect(typeof webhookSettings.clearIncomingUrl).toEqual('function'); 98 | }); 99 | 100 | it('should contain a settings module with a clearOutgoingUrl function', () => { 101 | const webhookSettings = bundle.createSettingsModule(sipgateClient); 102 | expect(webhookSettings.clearOutgoingUrl).toBeDefined(); 103 | expect(webhookSettings.clearWhitelist).toBeDefined(); 104 | expect(typeof webhookSettings.clearWhitelist).toEqual('function'); 105 | }); 106 | 107 | it('should contain a settings module with a disableWhitelist function', () => { 108 | const webhookSettings = bundle.createSettingsModule(sipgateClient); 109 | expect(webhookSettings.disableWhitelist).toBeDefined(); 110 | expect(typeof webhookSettings.disableWhitelist).toEqual('function'); 111 | }); 112 | 113 | it('should contain a contacts module with function importFromCsvString', () => { 114 | const contacts = bundle.createContactsModule(sipgateClient); 115 | expect(contacts.importFromCsvString).toBeDefined(); 116 | expect(typeof contacts.importFromCsvString).toEqual('function'); 117 | }); 118 | 119 | it('should contain the exported enums', () => { 120 | expect(bundle.HistoryEntryType).toBeDefined(); 121 | expect(bundle.HistoryEntryType.FAX).toEqual('FAX'); 122 | }); 123 | 124 | it('should not contain a webhook module', () => { 125 | expect(bundle.createWebhookModule).toBeUndefined(); 126 | }); 127 | }); 128 | -------------------------------------------------------------------------------- /lib/history/history.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | HistoryEntry, 3 | HistoryEntryType, 4 | HistoryResponse, 5 | StarredDTO, 6 | } from './history.types'; 7 | import { HistoryErrorMessage } from './errors/handleHistoryError'; 8 | import { SipgateIOClient } from '../core/sipgateIOClient'; 9 | import { createHistoryModule } from './history'; 10 | 11 | describe('History Module', () => { 12 | let mockClient: SipgateIOClient; 13 | 14 | beforeEach(() => { 15 | mockClient = {} as SipgateIOClient; 16 | }); 17 | 18 | it('validates the Extensions and throws an error including the message from the extension-validator', async () => { 19 | const historyModule = createHistoryModule(mockClient); 20 | 21 | await expect( 22 | historyModule.fetchAll({ connectionIds: ['s0', 's1', 'sokx5', 's2'] }) 23 | ).rejects.toThrowError('Invalid extension: sokx5'); 24 | }); 25 | 26 | it('includes the phonenumber filter when using the fetchAll function', async () => { 27 | const historyModule = createHistoryModule(mockClient); 28 | const mockedResponse: HistoryResponse = { 29 | items: [], 30 | totalCount: 0, 31 | }; 32 | mockClient.get = jest 33 | .fn() 34 | .mockImplementationOnce(() => Promise.resolve(mockedResponse)); 35 | 36 | await historyModule.fetchAll({ phonenumber: '0211123456789' }); 37 | 38 | expect(mockClient.get).toBeCalledWith('/history', { 39 | params: { 40 | phonenumber: '0211123456789', 41 | }, 42 | }); 43 | }); 44 | 45 | it('throws an error when the API answers with 404 Not Found', async () => { 46 | mockClient.get = jest.fn().mockImplementationOnce(() => { 47 | return Promise.reject({ 48 | response: { 49 | status: 404, 50 | }, 51 | }); 52 | }); 53 | 54 | const historyModule = createHistoryModule(mockClient); 55 | 56 | await expect( 57 | historyModule.fetchById('someUnknownEntryId') 58 | ).rejects.toThrowError(HistoryErrorMessage.EVENT_NOT_FOUND); 59 | }); 60 | 61 | it('throws an error when the API answers with 400 Bad Request', async () => { 62 | mockClient.get = jest.fn().mockImplementationOnce(() => { 63 | return Promise.reject({ 64 | response: { 65 | status: 400, 66 | }, 67 | }); 68 | }); 69 | 70 | const historyModule = createHistoryModule(mockClient); 71 | 72 | await expect( 73 | historyModule.fetchAll({}, { limit: 100000 }) 74 | ).rejects.toThrowError(HistoryErrorMessage.BAD_REQUEST); 75 | }); 76 | 77 | it('batchUpdates historyUpdates which include no note', async () => { 78 | mockClient.put = jest.fn().mockImplementation(() => { 79 | return Promise.resolve({ 80 | status: 200, 81 | }); 82 | }); 83 | 84 | const historyModule = createHistoryModule(mockClient); 85 | 86 | const events: HistoryEntry[] = [ 87 | { id: '1' } as unknown as HistoryEntry, 88 | { id: '2' } as unknown as HistoryEntry, 89 | { id: '3' } as unknown as HistoryEntry, 90 | { id: '4' } as unknown as HistoryEntry, 91 | { id: '5' } as unknown as HistoryEntry, 92 | ]; 93 | 94 | await expect( 95 | historyModule.batchUpdateEvents(events, (evt) => { 96 | if (evt.id === '3' || evt.id === '4') { 97 | return { note: 'Note Event' }; 98 | } 99 | return { 100 | starred: true, 101 | }; 102 | }) 103 | ).resolves.not.toThrow(); 104 | 105 | expect(mockClient.put).toHaveBeenCalledWith('history', [ 106 | { id: '1', starred: true }, 107 | { id: '2', starred: true }, 108 | { id: '5', starred: true }, 109 | ]); 110 | expect(mockClient.put).toHaveBeenCalledWith('history/3', { 111 | id: undefined, 112 | note: 'Note Event', 113 | }); 114 | expect(mockClient.put).toHaveBeenCalledWith('history/4', { 115 | id: undefined, 116 | note: 'Note Event', 117 | }); 118 | expect(mockClient.put).toHaveBeenCalledTimes(3); 119 | }); 120 | 121 | it('throws an error when a connection id is invalid', async () => { 122 | const historyModule = createHistoryModule(mockClient); 123 | 124 | await expect( 125 | historyModule.exportAsCsvString({ connectionIds: ['s0', 'sokx5', 'e0'] }) 126 | ).rejects.toThrowError('Invalid extension: sokx5'); 127 | }); 128 | 129 | it('passes the filter to the history export endpoint', async () => { 130 | const historyModule = createHistoryModule(mockClient); 131 | mockClient.get = jest 132 | .fn() 133 | .mockImplementationOnce(() => Promise.resolve('example response')); 134 | 135 | await historyModule.exportAsCsvString( 136 | { 137 | archived: true, 138 | types: [HistoryEntryType.SMS], 139 | }, 140 | { offset: 10, limit: 20 } 141 | ); 142 | 143 | expect(mockClient.get).toBeCalledWith('/history/export', { 144 | params: { 145 | archived: true, 146 | types: [HistoryEntryType.SMS], 147 | offset: 10, 148 | limit: 20, 149 | }, 150 | }); 151 | }); 152 | 153 | it('accepts and remaps a starred boolean for the filter to the history export endpoint', async () => { 154 | const historyModule = createHistoryModule(mockClient); 155 | mockClient.get = jest 156 | .fn() 157 | .mockImplementationOnce(() => Promise.resolve('example response')); 158 | 159 | await historyModule.exportAsCsvString({ starred: true }); 160 | 161 | expect(mockClient.get).toBeCalledWith('/history/export', { 162 | params: { 163 | starred: StarredDTO.STARRED, 164 | }, 165 | }); 166 | }); 167 | }); 168 | -------------------------------------------------------------------------------- /lib/sms/sms.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ExtensionType, 3 | validateExtension, 4 | validatePhoneNumber, 5 | } from '../core/validator'; 6 | import { 7 | SMSModule, 8 | ShortMessage, 9 | ShortMessageDTO, 10 | SmsExtension, 11 | SmsSenderId, 12 | } from './sms.types'; 13 | import { SipgateIOClient } from '../core/sipgateIOClient'; 14 | import { SmsErrorMessage, handleSmsError } from './errors/handleSmsError'; 15 | import { validateSendAt } from './validators/validateSendAt'; 16 | 17 | export const createSMSModule = (client: SipgateIOClient): SMSModule => ({ 18 | async send(sms: ShortMessage, sendAt?: Date): Promise { 19 | const smsDTO: ShortMessageDTO = { 20 | smsId: '', 21 | message: sms.message, 22 | recipient: sms.to, 23 | }; 24 | if (sendAt) { 25 | const sendAtValidationResult = validateSendAt(sendAt); 26 | if (!sendAtValidationResult.isValid) { 27 | throw new Error(sendAtValidationResult.cause); 28 | } 29 | smsDTO.sendAt = sendAt.getTime() / 1000; 30 | } 31 | if (('from' in sms ? sms.from : sms.phoneNumber) !== undefined) { 32 | return sendSmsByPhoneNumber(client, sms, smsDTO); 33 | } 34 | return sendSmsBySmsId(sms, smsDTO, client); 35 | }, 36 | getSmsExtensions(webuserId: string): Promise { 37 | return client 38 | .get<{ items: SmsExtension[] }>(`${webuserId}/sms`) 39 | .then((response) => response.items) 40 | .catch((error) => Promise.reject(handleSmsError(error))); 41 | }, 42 | }); 43 | 44 | const sendSms = async ( 45 | client: SipgateIOClient, 46 | smsDTO: ShortMessageDTO 47 | ): Promise => { 48 | await client.post('/sessions/sms', smsDTO).catch((error) => { 49 | throw handleSmsError(error); 50 | }); 51 | }; 52 | 53 | export const getUserSmsExtension = ( 54 | client: SipgateIOClient, 55 | webuserId: string 56 | ): Promise => { 57 | return client 58 | .get<{ items: SmsExtension[] }>(`${webuserId}/sms`) 59 | .then((value) => value.items[0].id) 60 | .catch((error) => Promise.reject(handleSmsError(error))); 61 | }; 62 | 63 | export const getSmsCallerIds = ( 64 | client: SipgateIOClient, 65 | webuserExtension: string, 66 | smsExtension: string 67 | ): Promise => { 68 | return client 69 | .get<{ items: SmsSenderId[] }>( 70 | `${webuserExtension}/sms/${smsExtension}/callerids` 71 | ) 72 | .then((value) => value.items) 73 | .catch((error) => Promise.reject(handleSmsError(error))); 74 | }; 75 | 76 | const setDefaultSenderId = async ( 77 | client: SipgateIOClient, 78 | webuserExtension: string, 79 | smsId: string, 80 | senderId: SmsSenderId 81 | ): Promise => { 82 | await client 83 | .put(`${webuserExtension}/sms/${smsId}/callerids/${senderId.id}`, { 84 | defaultNumber: 'true', 85 | }) 86 | .catch((error) => { 87 | throw handleSmsError(error); 88 | }); 89 | }; 90 | 91 | export const containsPhoneNumber = ( 92 | smsCallerIds: SmsSenderId[], 93 | phoneNumber: string 94 | ): boolean => { 95 | const foundCallerId = smsCallerIds.find( 96 | (smsCallerId) => smsCallerId.phonenumber === phoneNumber 97 | ); 98 | return foundCallerId ? foundCallerId.verified : false; 99 | }; 100 | 101 | async function sendSmsByPhoneNumber( 102 | client: SipgateIOClient, 103 | sms: ShortMessage, 104 | smsDTO: ShortMessageDTO 105 | ): Promise { 106 | const webuserId = await client.getAuthenticatedWebuserId(); 107 | const smsExtension = await getUserSmsExtension(client, webuserId); 108 | const senderIds = await getSmsCallerIds(client, webuserId, smsExtension); 109 | const senderId = senderIds.find( 110 | (value) => 111 | value.phonenumber === ('from' in sms ? sms.from : sms.phoneNumber) 112 | ); 113 | if (senderId === undefined) { 114 | throw new Error(SmsErrorMessage.NUMBER_NOT_REGISTERED); 115 | } 116 | if (!senderId.verified) { 117 | throw new Error(SmsErrorMessage.NUMBER_NOT_VERIFIED); 118 | } 119 | const defaultSmsId = senderIds.find((value) => value.defaultNumber); 120 | if (defaultSmsId === undefined) { 121 | throw new Error(SmsErrorMessage.NO_DEFAULT_SENDER_ID); 122 | } 123 | smsDTO.smsId = smsExtension; 124 | await setDefaultSenderId(client, webuserId, smsExtension, senderId); 125 | return await sendSms(client, smsDTO) 126 | .then( 127 | async () => 128 | await setDefaultSenderId(client, webuserId, smsExtension, defaultSmsId) 129 | ) 130 | .catch((error) => { 131 | return Promise.reject(handleSmsError(error)); 132 | }); 133 | } 134 | 135 | async function sendSmsBySmsId( 136 | sms: ShortMessage, 137 | smsDTO: ShortMessageDTO, 138 | client: SipgateIOClient 139 | ): Promise { 140 | if (sms.smsId === undefined) { 141 | throw new Error('smsId is undefined'); 142 | } 143 | const smsExtensionValidationResult = validateExtension(sms.smsId, [ 144 | ExtensionType.SMS, 145 | ]); 146 | if (!smsExtensionValidationResult.isValid) { 147 | throw new Error(smsExtensionValidationResult.cause); 148 | } 149 | smsDTO.smsId = sms.smsId; 150 | 151 | const phoneNumberValidationResult = validatePhoneNumber(sms.to); 152 | 153 | if (!phoneNumberValidationResult.isValid) { 154 | throw new Error(phoneNumberValidationResult.cause); 155 | } 156 | if (sms.message === '') { 157 | throw new Error(SmsErrorMessage.INVALID_MESSAGE); 158 | } 159 | await sendSms(client, smsDTO).catch((error) => { 160 | throw handleSmsError(error); 161 | }); 162 | } 163 | -------------------------------------------------------------------------------- /lib/contacts/contacts.test.examples.ts: -------------------------------------------------------------------------------- 1 | export const example = 2 | 'BEGIN:VCARD\r\n' + 3 | 'VERSION:4.0\r\n' + 4 | 'N:Doe;John;Mr.;\r\n' + 5 | 'FN:John Doe\r\n' + 6 | 'ORG:Example.com Inc.\r\n' + 7 | 'TITLE:Imaginary test person\r\n' + 8 | 'EMAIL;type=INTERNET;type=WORK;type=pref:johnDoe@example.org\r\n' + 9 | 'TEL;type=HOME:+1 202 555 1212\r\n' + 10 | 'item1.ADR;type=WORK:;;2 Enterprise Avenue;Worktown;NY;01111;USA\r\n' + 11 | 'item1.X-ABADR:us\r\n' + 12 | 'NOTE:John Doe has a long and varied history\\, being documented on more police files that anyone else. Reports of his death are alas numerous.\r\n' + 13 | 'item3.URL;type=pref:http\\://www.example/com/doe\r\n' + 14 | 'item3.X-ABLabel:_$!!$_\r\n' + 15 | 'item4.URL:http\\://www.example.com/Joe/foaf.df\r\n' + 16 | 'item4.X-ABLabel:FOAF\r\n' + 17 | 'item5.X-ABRELATEDNAMES;type=pref:Jane Doe\r\n' + 18 | 'item5.X-ABLabel:_$!!$_\r\n' + 19 | 'CATEGORIES:Work,Test group\r\n' + 20 | 'X-ABUID:5AD380FD-B2DE-4261-BA99-DE1D1DB52FBE\\:ABPerson\r\n' + 21 | 'END:VCARD\r\n'; 22 | 23 | export const exampleWithTwoAdresses = 24 | 'BEGIN:VCARD\r\n' + 25 | 'VERSION:4.0\r\n' + 26 | 'N:Doe;John;Mr.;\r\n' + 27 | 'FN:John Doe\r\n' + 28 | 'ORG:Example.com Inc.\r\n' + 29 | 'TITLE:Imaginary test person\r\n' + 30 | 'EMAIL;type=INTERNET;type=WORK;type=pref:johnDoe@example.org\r\n' + 31 | 'TEL;type=HOME:+1 202 555 1212\r\n' + 32 | 'item1.ADR;type=WORK:;;2 Enterprise Avenue;Worktown;NY;01111;USA\r\n' + 33 | 'item1.X-ABADR:us\r\n' + 34 | 'item2.ADR;type=HOME;type=pref:;;3 Acacia Avenue;Hoemtown;MA;02222;USA\r\n' + 35 | 'item2.X-ABADR:us\r\n' + 36 | 'NOTE:John Doe has a long and varied history\\, being documented on more police files that anyone else. Reports of his death are alas numerous.\r\n' + 37 | 'item3.URL;type=pref:http\\://www.example/com/doe\r\n' + 38 | 'item3.X-ABLabel:_$!!$_\r\n' + 39 | 'item4.URL:http\\://www.example.com/Joe/foaf.df\r\n' + 40 | 'item4.X-ABLabel:FOAF\r\n' + 41 | 'item5.X-ABRELATEDNAMES;type=pref:Jane Doe\r\n' + 42 | 'item5.X-ABLabel:_$!!$_\r\n' + 43 | 'CATEGORIES:Work,Test group\r\n' + 44 | 'X-ABUID:5AD380FD-B2DE-4261-BA99-DE1D1DB52FBE\\:ABPerson\r\n' + 45 | 'END:VCARD\r\n'; 46 | 47 | export const exampleWithAllValues = 48 | 'BEGIN:VCARD\r\n' + 49 | 'VERSION:4.0\r\n' + 50 | 'FN:Vorname Nachname\r\n' + 51 | 'N:Nachname;Vorname\r\n' + 52 | 'TITLE:Titel\r\n' + 53 | 'GENDER:U;\r\n' + 54 | 'BDAY:2000-02-13\r\n' + 55 | 'ORG:Firma\r\n' + 56 | 'TITLE:Rolle\r\n' + 57 | 'ADR;TYPE=HOME:Postfach;Adresszusatz;Straße;ORT;Region;PLZ;Germany\r\n' + 58 | 'TEL;TYPE=HOME:+4915199999999\r\n' + 59 | 'EMAIL;TYPE=HOME,INTERNET:email@example.com\r\n' + 60 | 'END:VCARD'; 61 | 62 | export const exampleWithNotEnoughValues = 63 | 'BEGIN:VCARD\r\n' + 64 | 'VERSION:4.0\r\n' + 65 | 'FN:Vorname Nachname\r\n' + 66 | 'N:Nachname;Vorname\r\n' + 67 | 'TITLE:Titel\r\n' + 68 | 'GENDER:U;\r\n' + 69 | 'BDAY:2000-02-13\r\n' + 70 | 'ORG:Firma\r\n' + 71 | 'TITLE:Rolle\r\n' + 72 | 'ADR;TYPE=HOME:Postfach;Adresszusatz;Straße;ORT;Region;PLZ\r\n' + 73 | 'TEL;TYPE=HOME:+4915199999999\r\n' + 74 | 'EMAIL;TYPE=HOME,INTERNET:email@example.com\r\n' + 75 | 'END:VCARD'; 76 | 77 | export const exampleWithNotEnoughNames = 78 | 'BEGIN:VCARD\r\n' + 79 | 'VERSION:4.0\r\n' + 80 | 'FN:Vorname Nachname\r\n' + 81 | 'N:Nachname\r\n' + 82 | 'TITLE:Titel\r\n' + 83 | 'BDAY:2000-02-13\r\n' + 84 | 'ORG:Firma\r\n' + 85 | 'TITLE:Rolle\r\n' + 86 | 'TEL;TYPE=HOME:+4915199999999\r\n' + 87 | 'EMAIL;TYPE=HOME,INTERNET:email@example.com\r\n' + 88 | 'END:VCARD'; 89 | 90 | export const exampleWithTooManyEmails = 91 | 'BEGIN:VCARD\r\n' + 92 | 'VERSION:4.0\r\n' + 93 | 'FN:Vorname Nachname\r\n' + 94 | 'N:Nachname;Vorname\r\n' + 95 | 'TITLE:Titel\r\n' + 96 | 'BDAY:2000-02-13\r\n' + 97 | 'ORG:Firma\r\n' + 98 | 'TITLE:Rolle\r\n' + 99 | 'TEL;TYPE=HOME:+4915199999999\r\n' + 100 | 'EMAIL;TYPE=HOME,INTERNET:email@example.com\r\n' + 101 | 'EMAIL;TYPE=HOME,INTERNET:email2@example.com\r\n' + 102 | 'END:VCARD'; 103 | 104 | export const exampleWithTwoOrganizations = 105 | 'BEGIN:VCARD\r\n' + 106 | 'VERSION:4.0\r\n' + 107 | 'FN:Vorname Nachname\r\n' + 108 | 'N:Nachname;Vorname\r\n' + 109 | 'TITLE:Titel\r\n' + 110 | 'GENDER:U;\r\n' + 111 | 'BDAY:2000-02-13\r\n' + 112 | 'ORG:Firma\r\n' + 113 | 'ORG:Firma 2\r\n' + 114 | 'TITLE:Rolle\r\n' + 115 | 'ADR;TYPE=HOME:Postfach;Adresszusatz;Straße;ORT;Region;PLZ;Germany\r\n' + 116 | 'TEL;TYPE=HOME:+4915199999999\r\n' + 117 | 'EMAIL;TYPE=HOME,INTERNET:email@example.com\r\n' + 118 | 'END:VCARD'; 119 | 120 | export const exampleWithoutEmail = 121 | 'BEGIN:VCARD\r\n' + 122 | 'VERSION:4.0\r\n' + 123 | 'N:Doe;John;Mr.;\r\n' + 124 | 'FN:John Doe\r\n' + 125 | 'ORG:Example.com Inc.\r\n' + 126 | 'TITLE:Imaginary test person\r\n' + 127 | 'TEL;type=HOME:+1 202 555 1212\r\n' + 128 | 'item1.ADR;type=WORK:;;2 Enterprise Avenue;Worktown;NY;01111;USA\r\n' + 129 | 'item1.X-ABADR:us\r\n' + 130 | 'NOTE:John Doe has a long and varied history\\, being documented on more police files that anyone else. Reports of his death are alas numerous.\r\n' + 131 | 'item3.URL;type=pref:http\\://www.example/com/doe\r\n' + 132 | 'item3.X-ABLabel:_$!!$_\r\n' + 133 | 'item4.URL:http\\://www.example.com/Joe/foaf.df\r\n' + 134 | 'item4.X-ABLabel:FOAF\r\n' + 135 | 'item5.X-ABRELATEDNAMES;type=pref:Jane Doe\r\n' + 136 | 'item5.X-ABLabel:_$!!$_\r\n' + 137 | 'CATEGORIES:Work,Test group\r\n' + 138 | 'X-ABUID:5AD380FD-B2DE-4261-BA99-DE1D1DB52FBE\\:ABPerson\r\n' + 139 | 'END:VCARD\r\n'; 140 | -------------------------------------------------------------------------------- /lib/contacts/helpers/vCardHelper.ts: -------------------------------------------------------------------------------- 1 | import { ContactImport, ContactVCard } from './Address'; 2 | import { ContactsErrorMessage } from '../errors/handleContactsError'; 3 | import vCard from 'vcf'; 4 | 5 | export const parseVCard = (vCardContent: string): ContactVCard => { 6 | let parsedVCard; 7 | try { 8 | parsedVCard = new vCard().parse(vCardContent); 9 | } catch (ex) { 10 | if (ex instanceof SyntaxError) { 11 | if (ex.message.includes('Expected "BEGIN:VCARD"')) { 12 | throw new Error(ContactsErrorMessage.CONTACTS_VCARD_MISSING_BEGIN); 13 | } 14 | if (ex.message.includes('Expected "END:VCARD"')) { 15 | throw new Error(ContactsErrorMessage.CONTACTS_VCARD_MISSING_END); 16 | } 17 | } 18 | throw new Error(ContactsErrorMessage.CONTACTS_VCARD_FAILED_TO_PARSE); 19 | } 20 | 21 | if (parsedVCard.version !== '4.0') { 22 | throw new Error(ContactsErrorMessage.CONTACTS_INVALID_VCARD_VERSION); 23 | } 24 | 25 | const nameAttribute = parsedVCard.get('n'); 26 | const phoneAttribute = parsedVCard.get('tel'); 27 | const emailAttribute = parsedVCard.get('email'); 28 | const addressAttribute = parsedVCard.get('adr'); 29 | const organizationAttribute = parsedVCard.get('org'); 30 | 31 | if (nameAttribute === undefined) { 32 | throw new Error(ContactsErrorMessage.CONTACTS_MISSING_NAME_ATTRIBUTE); 33 | } 34 | 35 | if (phoneAttribute === undefined) { 36 | throw new Error(ContactsErrorMessage.CONTACTS_MISSING_TEL_ATTRIBUTE); 37 | } 38 | 39 | const names = nameAttribute 40 | .toString() 41 | .replace(/(.*)N(.*):/, '') 42 | .split(';'); 43 | 44 | if (isAmountOfNamesInvalid(names)) { 45 | throw new Error(ContactsErrorMessage.CONTACTS_INVALID_AMOUNT_OF_NAMES); 46 | } 47 | 48 | const [lastname, firstname] = names; 49 | 50 | if (isMultipleOf(phoneAttribute)) { 51 | throw new Error( 52 | ContactsErrorMessage.CONTACTS_INVALID_AMOUNT_OF_PHONE_NUMBERS 53 | ); 54 | } 55 | 56 | if (isMultipleOf(addressAttribute)) { 57 | throw new Error(ContactsErrorMessage.CONTACTS_INVALID_AMOUNT_OF_ADDRESSES); 58 | } 59 | 60 | let addressValues; 61 | if (addressAttribute) { 62 | addressValues = addressAttribute 63 | .toString() 64 | .replace(/(.*)ADR(.*):/, '') 65 | .split(';'); 66 | } 67 | 68 | if (addressValues && isAddressAttributeAmountInvalid(addressValues)) { 69 | throw new Error( 70 | ContactsErrorMessage.CONTACTS_INVALID_AMOUNT_OF_ADDRESS_VALUES 71 | ); 72 | } 73 | if (isMultipleOf(emailAttribute)) { 74 | throw new Error(ContactsErrorMessage.CONTACTS_INVALID_AMOUNT_OF_EMAILS); 75 | } 76 | 77 | const organization = 78 | organizationAttribute instanceof Array 79 | ? organizationAttribute 80 | : [organizationAttribute]; 81 | 82 | let result: ContactVCard = { 83 | firstname, 84 | lastname, 85 | phoneNumber: phoneAttribute.valueOf().toString(), 86 | email: emailAttribute ? emailAttribute.valueOf().toString() : undefined, 87 | organization: organization.map((x) => 88 | x 89 | .toString() 90 | .replace(/(.*)ORG(.*):/, '') 91 | .split(';') 92 | ), 93 | }; 94 | 95 | if (addressAttribute) { 96 | result = { 97 | ...result, 98 | address: { 99 | poBox: addressValues ? addressValues[0] : '', 100 | extendedAddress: addressValues ? addressValues[1] : '', 101 | streetAddress: addressValues ? addressValues[2] : '', 102 | locality: addressValues ? addressValues[3] : '', 103 | region: addressValues ? addressValues[4] : '', 104 | postalCode: addressValues ? addressValues[5] : '', 105 | country: addressValues ? addressValues[6] : '', 106 | }, 107 | }; 108 | } 109 | 110 | return result; 111 | }; 112 | 113 | export const createVCards = (contacts: ContactImport[]): string[] => { 114 | const cards: string[] = []; 115 | contacts.map((contact) => { 116 | const card = new vCard(); 117 | card.add('n', `${contact.firstname};${contact.lastname}`); 118 | contact.organizations.forEach((organization) => { 119 | card.add('org', organization.join(';')); 120 | }); 121 | contact.phoneNumbers.forEach((phoneNumber) => { 122 | card.add('tel', phoneNumber.number, { 123 | type: phoneNumber.type, 124 | }); 125 | }); 126 | if (contact.emails !== undefined) { 127 | contact.emails.forEach((mail) => { 128 | card.add('email', mail.email, { 129 | type: mail.type, 130 | }); 131 | }); 132 | } 133 | if (contact.addresses !== undefined) { 134 | const { addresses } = contact; 135 | addresses.forEach((address) => { 136 | card.add( 137 | 'addr', 138 | `${address.poBox ? address.poBox : ''};${ 139 | address.extendedAddress ? address.extendedAddress : '' 140 | };${address.streetAddress ? address.streetAddress : ''};${ 141 | address.locality ? address.locality : '' 142 | };${address.region ? address.region : ''};${ 143 | address.postalCode ? address.postalCode : '' 144 | };${address.country ? address.country : ''}`, 145 | { 146 | type: address.type, 147 | } 148 | ); 149 | }); 150 | } 151 | cards.push(card.toString('4.0')); 152 | }); 153 | return cards; 154 | }; 155 | 156 | const isAddressAttributeAmountInvalid = (addressValues: string[]): boolean => { 157 | return addressValues.length < 7; 158 | }; 159 | 160 | const isMultipleOf = ( 161 | vCardProperty: vCard.Property | vCard.Property[] 162 | ): boolean => { 163 | return vCardProperty && typeof vCardProperty.valueOf() === 'object'; 164 | }; 165 | const isAmountOfNamesInvalid = (names: string[]): boolean => { 166 | return names.length < 2; 167 | }; 168 | -------------------------------------------------------------------------------- /lib/webhook-settings/webhookSettings.test.ts: -------------------------------------------------------------------------------- 1 | import { ErrorMessage } from '../core/errors'; 2 | import { SipgateIOClient } from '../core/sipgateIOClient'; 3 | import { ValidatorMessages } from './validators/ValidatorMessages'; 4 | import { 5 | WebhookSettings, 6 | WebhookSettingsModule, 7 | } from './webhookSettings.types'; 8 | import { createSettingsModule } from './webhookSettings'; 9 | 10 | describe('get settings', () => { 11 | let mockedSettingsModule: WebhookSettingsModule; 12 | let mockClient: SipgateIOClient; 13 | 14 | beforeAll(() => { 15 | mockClient = {} as SipgateIOClient; 16 | mockedSettingsModule = createSettingsModule(mockClient); 17 | }); 18 | 19 | it('should use the endpoint "settings/sipgateio"', async () => { 20 | const settings = { 21 | incomingUrl: 'string', 22 | outgoingUrl: 'string', 23 | log: true, 24 | whitelist: [], 25 | }; 26 | 27 | mockClient.get = jest.fn().mockImplementationOnce(() => { 28 | return Promise.resolve(settings); 29 | }); 30 | mockClient.put = jest.fn().mockImplementationOnce(() => { 31 | return Promise.resolve({}); 32 | }); 33 | 34 | await mockedSettingsModule.setIncomingUrl('https://test.de'); 35 | 36 | expect(mockClient.get).toBeCalledWith('settings/sipgateio'); 37 | expect(mockClient.put).toBeCalledWith('settings/sipgateio', { 38 | ...settings, 39 | incomingUrl: 'https://test.de', 40 | }); 41 | }); 42 | }); 43 | 44 | describe('setIncomingUrl', () => { 45 | let mockClient: SipgateIOClient; 46 | 47 | const TEST_INCOMING_URL = 'http://newIncoming.url'; 48 | 49 | beforeAll(() => { 50 | mockClient = {} as SipgateIOClient; 51 | }); 52 | 53 | it('should set supplied incomingUrl in fetched settings object', async () => { 54 | const settingsModule = createSettingsModule(mockClient); 55 | 56 | const settings: WebhookSettings = { 57 | incomingUrl: '', 58 | log: false, 59 | outgoingUrl: '', 60 | whitelist: [], 61 | }; 62 | 63 | const expectedSettings = { ...settings }; 64 | expectedSettings.incomingUrl = TEST_INCOMING_URL; 65 | 66 | mockClient.get = jest 67 | .fn() 68 | .mockImplementationOnce(() => Promise.resolve(settings)); 69 | 70 | mockClient.put = jest 71 | .fn() 72 | .mockImplementationOnce(() => Promise.resolve({})); 73 | 74 | await settingsModule.setIncomingUrl(TEST_INCOMING_URL); 75 | 76 | expect(mockClient.put).toBeCalledWith(expect.anything(), expectedSettings); 77 | }); 78 | 79 | it('should throw an error when supplied with an invalid url', async () => { 80 | const settingsModule = createSettingsModule(mockClient); 81 | 82 | await expect(settingsModule.setIncomingUrl('newUrl')).rejects.toThrowError( 83 | ValidatorMessages.INVALID_WEBHOOK_URL 84 | ); 85 | }); 86 | }); 87 | 88 | describe('setOutgoingUrl', () => { 89 | let mockClient: SipgateIOClient; 90 | 91 | const TEST_OUTGOING_URL = 'http://newOutgoing.url'; 92 | 93 | beforeAll(() => { 94 | mockClient = {} as SipgateIOClient; 95 | }); 96 | 97 | it('should set supplied incomingUrl in fetched settings object', async () => { 98 | const settingsModule = createSettingsModule(mockClient); 99 | 100 | const settings: WebhookSettings = { 101 | incomingUrl: '', 102 | log: false, 103 | outgoingUrl: '', 104 | whitelist: [], 105 | }; 106 | 107 | const expectedSettings = { ...settings }; 108 | expectedSettings.outgoingUrl = TEST_OUTGOING_URL; 109 | 110 | mockClient.get = jest 111 | .fn() 112 | .mockImplementationOnce(() => Promise.resolve(settings)); 113 | 114 | mockClient.put = jest 115 | .fn() 116 | .mockImplementationOnce(() => Promise.resolve({})); 117 | 118 | await settingsModule.setOutgoingUrl(TEST_OUTGOING_URL); 119 | 120 | expect(mockClient.put).toBeCalledWith(expect.anything(), expectedSettings); 121 | }); 122 | 123 | it('should throw an error when supplied with an invalid url', async () => { 124 | const settingsModule = createSettingsModule(mockClient); 125 | 126 | await expect(settingsModule.setOutgoingUrl('newUrl')).rejects.toThrowError( 127 | ValidatorMessages.INVALID_WEBHOOK_URL 128 | ); 129 | }); 130 | }); 131 | 132 | describe('setWhitelist', () => { 133 | let mockClient: SipgateIOClient; 134 | 135 | const INVALID_WHITELIST = ['g0', 'greatAgain']; 136 | const INVALID_P_EXT_WHITELIST = ['f19']; 137 | const VALID_WHITELIST = ['g0', 'p19']; 138 | 139 | beforeAll(() => { 140 | mockClient = {} as SipgateIOClient; 141 | }); 142 | 143 | it('should throw an error when supplied with an invalid array of extensions', async () => { 144 | const settingsModule = createSettingsModule(mockClient); 145 | 146 | await expect( 147 | settingsModule.setWhitelist(INVALID_WHITELIST) 148 | ).rejects.toThrowError(ErrorMessage.VALIDATOR_INVALID_EXTENSION); 149 | }); 150 | 151 | it('should throw an error when supplied with an invalid array of p-extensions', async () => { 152 | const settingsModule = createSettingsModule(mockClient); 153 | 154 | await expect( 155 | settingsModule.setWhitelist(INVALID_P_EXT_WHITELIST) 156 | ).rejects.toThrowError(ValidatorMessages.INVALID_EXTENSION_FOR_WEBHOOKS); 157 | }); 158 | 159 | it('should succeed when supplied with a valid array of extensions', async () => { 160 | const settingsModule = createSettingsModule(mockClient); 161 | 162 | const settings: WebhookSettings = { 163 | incomingUrl: '', 164 | log: false, 165 | outgoingUrl: '', 166 | whitelist: [], 167 | }; 168 | 169 | const expectedSettings = { ...settings }; 170 | expectedSettings.whitelist = VALID_WHITELIST; 171 | 172 | mockClient.get = jest 173 | .fn() 174 | .mockImplementationOnce(() => Promise.resolve(settings)); 175 | 176 | mockClient.put = jest 177 | .fn() 178 | .mockImplementationOnce(() => Promise.resolve({})); 179 | 180 | await settingsModule.setWhitelist(VALID_WHITELIST); 181 | 182 | expect(mockClient.put).toBeCalledWith(expect.anything(), expectedSettings); 183 | }); 184 | }); 185 | -------------------------------------------------------------------------------- /lib/core/sipgateIOClient/sipgateIOClient.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AuthCredentials, 3 | HttpRequestConfig, 4 | SipgateIOClient, 5 | Webuser, 6 | } from './sipgateIOClient.types'; 7 | 8 | import { UserInfo } from '../core.types'; 9 | import { detect as detectPlatform } from 'detect-browser'; 10 | import { handleCoreError } from '../errors'; 11 | import { toBase64 } from '../../utils'; 12 | import { 13 | validateEmail, 14 | validateOAuthToken, 15 | validatePassword, 16 | validateTokenID, 17 | } from '../validator'; 18 | import { validatePersonalAccessToken } from '../validator/validatePersonalAccessToken'; 19 | import { version } from '../../version.json'; 20 | import axios from 'axios'; 21 | import qs from 'qs'; 22 | 23 | interface RawDeserialized { 24 | [key: string]: RawDeserializedValue; 25 | } 26 | 27 | type RawDeserializedValue = 28 | | null 29 | | string 30 | | number 31 | | boolean 32 | | RawDeserialized 33 | | RawDeserializedValue[]; 34 | 35 | interface DeserializedWithDate { 36 | [key: string]: DeserializedWithDateValue; 37 | } 38 | 39 | type DeserializedWithDateValue = 40 | | null 41 | | string 42 | | number 43 | | boolean 44 | | Date 45 | | DeserializedWithDate 46 | | DeserializedWithDateValue[]; 47 | 48 | const parseRawDeserializedValue = ( 49 | value: RawDeserializedValue 50 | ): DeserializedWithDateValue => { 51 | return value === null 52 | ? null 53 | : value instanceof Array 54 | ? value.map(parseRawDeserializedValue) 55 | : typeof value === 'object' 56 | ? parseDatesInObject(value) 57 | : typeof value === 'string' 58 | ? parseIfDate(value) 59 | : value; 60 | }; 61 | 62 | const parseDatesInObject = (data: RawDeserialized): DeserializedWithDate => { 63 | const newData: DeserializedWithDate = {}; 64 | Object.keys(data).forEach((key) => { 65 | const value = data[key]; 66 | newData[key] = parseRawDeserializedValue(value); 67 | }); 68 | return newData; 69 | }; 70 | 71 | const parseIfDate = (maybeDate: string): Date | string => { 72 | const regexISO = 73 | /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}(?:\.\d+)?)(?:Z|([+-])([\d|:]*))?$/; 74 | if (maybeDate.match(regexISO)) { 75 | return new Date(maybeDate); 76 | } 77 | return maybeDate; 78 | }; 79 | 80 | export const sipgateIO = (credentials: AuthCredentials): SipgateIOClient => { 81 | const authorizationHeader = getAuthHeader(credentials); 82 | 83 | const platformInfo = detectPlatform(); 84 | const client = axios.create({ 85 | baseURL: 'https://api.sipgate.com/v2', 86 | headers: { 87 | Authorization: authorizationHeader, 88 | Accept: 'application/json', 89 | 'Content-Type': 'application/json', 90 | 'X-Sipgate-Client': JSON.stringify(platformInfo), 91 | 'X-Sipgate-Version': version, 92 | }, 93 | paramsSerializer: (params) => 94 | qs.stringify(params, { arrayFormat: 'repeat' }), 95 | }); 96 | 97 | client.interceptors.response.use((response) => { 98 | response.data = parseRawDeserializedValue(response.data); 99 | return response; 100 | }); 101 | 102 | return { 103 | delete(url: string, config?: HttpRequestConfig): Promise { 104 | return client.delete(url, config).then((response) => response.data); 105 | }, 106 | 107 | get(url: string, config?: HttpRequestConfig): Promise { 108 | return client.get(url, config).then((response) => response.data); 109 | }, 110 | 111 | patch( 112 | url: string, 113 | data?: unknown, 114 | config?: HttpRequestConfig 115 | ): Promise { 116 | return client 117 | .patch(url, data, config) 118 | .then((response) => response.data); 119 | }, 120 | 121 | post( 122 | url: string, 123 | data?: unknown, 124 | config?: HttpRequestConfig 125 | ): Promise { 126 | return client 127 | .post(url, data, config) 128 | .then((response) => response.data); 129 | }, 130 | 131 | put( 132 | url: string, 133 | data?: unknown, 134 | config?: HttpRequestConfig 135 | ): Promise { 136 | return client.put(url, data, config).then((response) => response.data); 137 | }, 138 | getAuthenticatedWebuserId(): Promise { 139 | return client 140 | .get('authorization/userinfo') 141 | .then((response) => response.data.sub) 142 | .catch((error) => Promise.reject(handleCoreError(error))); 143 | }, 144 | getWebUsers(): Promise { 145 | return client 146 | .get<{ items: Webuser[] }>('users') 147 | .then((response) => response.data.items) 148 | .catch((error) => Promise.reject(handleCoreError(error))); 149 | }, 150 | }; 151 | }; 152 | 153 | const getAuthHeader = (credentials: AuthCredentials): string => { 154 | if ('tokenId' in credentials) { 155 | const tokenIDValidationResult = validateTokenID(credentials.tokenId); 156 | if (!tokenIDValidationResult.isValid) { 157 | throw new Error(tokenIDValidationResult.cause); 158 | } 159 | 160 | const tokenValidationResult = validatePersonalAccessToken( 161 | credentials.token 162 | ); 163 | 164 | if (!tokenValidationResult.isValid) { 165 | throw new Error(tokenValidationResult.cause); 166 | } 167 | 168 | return `Basic ${toBase64(`${credentials.tokenId}:${credentials.token}`)}`; 169 | } 170 | 171 | if ('token' in credentials) { 172 | const tokenValidationResult = validateOAuthToken(credentials.token); 173 | 174 | if (!tokenValidationResult.isValid) { 175 | throw new Error(tokenValidationResult.cause); 176 | } 177 | return `Bearer ${credentials.token}`; 178 | } 179 | 180 | const emailValidationResult = validateEmail(credentials.username); 181 | 182 | if (!emailValidationResult.isValid) { 183 | throw new Error(emailValidationResult.cause); 184 | } 185 | 186 | const passwordValidationResult = validatePassword(credentials.password); 187 | 188 | if (!passwordValidationResult.isValid) { 189 | throw new Error(passwordValidationResult.cause); 190 | } 191 | 192 | return `Basic ${toBase64(`${credentials.username}:${credentials.password}`)}`; 193 | }; 194 | -------------------------------------------------------------------------------- /lib/call/validators/validateCallData.test.ts: -------------------------------------------------------------------------------- 1 | import { ErrorMessage } from '../../core'; 2 | import { ValidationErrors, validateCallData } from './validateCallData'; 3 | 4 | describe('callData validation', () => { 5 | test.each` 6 | input | expected 7 | ${{ to: '+4915177777777', from: '+49++', callerId: null, deviceId: null }} | ${{ isValid: false, cause: ValidationErrors.INVALID_CALLER }} 8 | ${{ to: '+4915177777777', from: '+49++', callerId: null, deviceId: 'g5' }} | ${{ isValid: false, cause: ValidationErrors.INVALID_CALLER }} 9 | ${{ to: '+4915177777777', from: '+49++', callerId: null, deviceId: 'p0' }} | ${{ isValid: false, cause: ValidationErrors.INVALID_CALLER }} 10 | ${{ to: '+4915177777777', from: '+49++', callerId: '+49++', deviceId: null }} | ${{ isValid: false, cause: ValidationErrors.INVALID_CALLER }} 11 | ${{ to: '+4915177777777', from: '+49++', callerId: '+49++', deviceId: 'g5' }} | ${{ isValid: false, cause: ValidationErrors.INVALID_CALLER }} 12 | ${{ to: '+4915177777777', from: '+49++', callerId: '+49++', deviceId: 'p0' }} | ${{ isValid: false, cause: ValidationErrors.INVALID_CALLER }} 13 | ${{ to: '+4915177777777', from: '+49++', callerId: '+4915177777777', deviceId: null }} | ${{ isValid: false, cause: ValidationErrors.INVALID_CALLER }} 14 | ${{ to: '+4915177777777', from: '+49++', callerId: '+4915177777777', deviceId: 'g5' }} | ${{ isValid: false, cause: ValidationErrors.INVALID_CALLER }} 15 | ${{ to: '+4915177777777', from: '+49++', callerId: '+4915177777777', deviceId: 'p0' }} | ${{ isValid: false, cause: ValidationErrors.INVALID_CALLER }} 16 | ${{ to: '+4915177777777', from: '+4915177777777', callerId: null, deviceId: null }} | ${{ isValid: false, cause: ValidationErrors.INVALID_DEVICE_ID }} 17 | ${{ to: '+4915177777777', from: '+4915177777777', callerId: null, deviceId: 'g5' }} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_EXTENSION }} 18 | ${{ to: '+4915177777777', from: '+4915177777777', callerId: null, deviceId: 'p0' }} | ${{ isValid: true }} 19 | ${{ to: '+4915177777777', from: '+4915177777777', callerId: '+49++', deviceId: null }} | ${{ isValid: false, cause: ValidationErrors.INVALID_DEVICE_ID }} 20 | ${{ to: '+4915177777777', from: '+4915177777777', callerId: '+49++', deviceId: 'g5' }} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_EXTENSION }} 21 | ${{ to: '+4915177777777', from: '+4915177777777', callerId: '+49++', deviceId: 'p0' }} | ${{ isValid: false, cause: ValidationErrors.INVALID_CALLER_ID }} 22 | ${{ to: '+4915177777777', from: '+4915177777777', callerId: '+4915177777777', deviceId: null }} | ${{ isValid: false, cause: ValidationErrors.INVALID_DEVICE_ID }} 23 | ${{ to: '+4915177777777', from: '+4915177777777', callerId: '+4915177777777', deviceId: 'g5' }} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_EXTENSION }} 24 | ${{ to: '+4915177777777', from: '+4915177777777', callerId: '+4915177777777', deviceId: 'p0' }} | ${{ isValid: true }} 25 | ${{ to: '+4915177777777', from: 'e25', callerId: null, deviceId: null }} | ${{ isValid: true }} 26 | ${{ to: '+4915177777777', from: 'e25', callerId: null, deviceId: 'g5' }} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_EXTENSION }} 27 | ${{ to: '+4915177777777', from: 'e25', callerId: null, deviceId: 'p0' }} | ${{ isValid: true }} 28 | ${{ to: '+4915177777777', from: 'e25', callerId: '+49++', deviceId: null }} | ${{ isValid: false, cause: ValidationErrors.INVALID_CALLER_ID }} 29 | ${{ to: '+4915177777777', from: 'e25', callerId: '+49++', deviceId: 'g5' }} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_EXTENSION }} 30 | ${{ to: '+4915177777777', from: 'e25', callerId: '+49++', deviceId: 'p0' }} | ${{ isValid: false, cause: ValidationErrors.INVALID_CALLER_ID }} 31 | ${{ to: '+4915177777777', from: 'e25', callerId: '+4915177777777', deviceId: null }} | ${{ isValid: true }} 32 | ${{ to: '+4915177777777', from: 'e25', callerId: '+4915177777777', deviceId: 'g5' }} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_EXTENSION }} 33 | ${{ to: '+4915177777777', from: 'e25', callerId: '+4915177777777', deviceId: 'p0' }} | ${{ isValid: true }} 34 | ${{ to: '+49++', from: '+49++', callerId: null, deviceId: null }} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_PHONE_NUMBER }} 35 | ${{ to: '+49++', from: '+49++', callerId: null, deviceId: 'g5' }} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_PHONE_NUMBER }} 36 | ${{ to: '+49++', from: '+49++', callerId: null, deviceId: 'p0' }} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_PHONE_NUMBER }} 37 | ${{ to: '+49++', from: '+49++', callerId: '+49++', deviceId: null }} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_PHONE_NUMBER }} 38 | ${{ to: '+49++', from: '+49++', callerId: '+49++', deviceId: 'g5' }} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_PHONE_NUMBER }} 39 | ${{ to: '+49++', from: '+49++', callerId: '+49++', deviceId: 'p0' }} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_PHONE_NUMBER }} 40 | ${{ to: '+49++', from: '+49++', callerId: '+4915177777777', deviceId: null }} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_PHONE_NUMBER }} 41 | ${{ to: '+49++', from: '+49++', callerId: '+4915177777777', deviceId: 'g5' }} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_PHONE_NUMBER }} 42 | ${{ to: '+49++', from: '+49++', callerId: '+4915177777777', deviceId: 'p0' }} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_PHONE_NUMBER }} 43 | ${{ to: '+49++', from: '+4915177777777', callerId: null, deviceId: null }} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_PHONE_NUMBER }} 44 | ${{ to: '+49++', from: '+4915177777777', callerId: null, deviceId: 'g5' }} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_PHONE_NUMBER }} 45 | ${{ to: '+49++', from: '+4915177777777', callerId: null, deviceId: 'p0' }} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_PHONE_NUMBER }} 46 | ${{ to: '+49++', from: '+4915177777777', callerId: '+49++', deviceId: null }} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_PHONE_NUMBER }} 47 | ${{ to: '+49++', from: '+4915177777777', callerId: '+49++', deviceId: 'g5' }} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_PHONE_NUMBER }} 48 | ${{ to: '+49++', from: '+4915177777777', callerId: '+49++', deviceId: 'p0' }} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_PHONE_NUMBER }} 49 | ${{ to: '+49++', from: '+4915177777777', callerId: '+4915177777777', deviceId: null }} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_PHONE_NUMBER }} 50 | ${{ to: '+49++', from: '+4915177777777', callerId: '+4915177777777', deviceId: 'g5' }} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_PHONE_NUMBER }} 51 | ${{ to: '+49++', from: '+4915177777777', callerId: '+4915177777777', deviceId: 'p0' }} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_PHONE_NUMBER }} 52 | ${{ to: '+49++', from: 'e25', callerId: null, deviceId: null }} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_PHONE_NUMBER }} 53 | ${{ to: '+49++', from: 'e25', callerId: null, deviceId: 'g5' }} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_PHONE_NUMBER }} 54 | ${{ to: '+49++', from: 'e25', callerId: null, deviceId: 'p0' }} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_PHONE_NUMBER }} 55 | ${{ to: '+49++', from: 'e25', callerId: '+49++', deviceId: null }} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_PHONE_NUMBER }} 56 | ${{ to: '+49++', from: 'e25', callerId: '+49++', deviceId: 'g5' }} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_PHONE_NUMBER }} 57 | ${{ to: '+49++', from: 'e25', callerId: '+49++', deviceId: 'p0' }} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_PHONE_NUMBER }} 58 | ${{ to: '+49++', from: 'e25', callerId: '+4915177777777', deviceId: null }} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_PHONE_NUMBER }} 59 | ${{ to: '+49++', from: 'e25', callerId: '+4915177777777', deviceId: 'g5' }} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_PHONE_NUMBER }} 60 | ${{ to: '+49++', from: 'e25', callerId: '+4915177777777', deviceId: 'p0' }} | ${{ isValid: false, cause: ErrorMessage.VALIDATOR_INVALID_PHONE_NUMBER }} 61 | `( 62 | 'validator returns $expected when $input is validated', 63 | ({ input, expected }) => { 64 | const output = validateCallData(input); 65 | expect(output.isValid).toEqual(expected.isValid); 66 | 67 | if (output.isValid === false) { 68 | expect(output.cause).toContain(expected.cause); 69 | } 70 | } 71 | ); 72 | }); 73 | -------------------------------------------------------------------------------- /lib/sms/sms.test.ts: -------------------------------------------------------------------------------- 1 | import { ErrorMessage } from '../core/errors'; 2 | import { ShortMessage, SmsExtension, SmsSenderId } from './sms.types'; 3 | import { SipgateIOClient } from '../core/sipgateIOClient'; 4 | import { SmsErrorMessage } from './errors/handleSmsError'; 5 | import { UserInfo } from '../core/core.types'; 6 | import { 7 | containsPhoneNumber, 8 | createSMSModule, 9 | getSmsCallerIds, 10 | getUserSmsExtension, 11 | } from './sms'; 12 | 13 | describe('SMS Module', () => { 14 | let mockClient: SipgateIOClient; 15 | 16 | beforeEach(() => { 17 | mockClient = { 18 | async getAuthenticatedWebuserId(): Promise { 19 | return 'w999'; 20 | }, 21 | } as SipgateIOClient; 22 | }); 23 | 24 | it('sends a sms by using a validated phone number', async () => { 25 | const smsModule = createSMSModule(mockClient); 26 | 27 | mockClient.get = jest.fn().mockImplementation((args) => { 28 | if (args === 'authorization/userinfo') { 29 | return Promise.resolve({ 30 | sub: 'w999', 31 | }); 32 | } 33 | if (args === 'w999/sms') { 34 | return Promise.resolve({ 35 | items: [ 36 | { 37 | id: 's999', 38 | alias: 'SMS von Douglas Engelbart', 39 | callerId: '+4915739777777', 40 | }, 41 | ], 42 | }); 43 | } 44 | if (args === 'w999/sms/s999/callerids') { 45 | return Promise.resolve({ 46 | items: [ 47 | { 48 | id: 0, 49 | phonenumber: 'sipgate', 50 | verified: true, 51 | defaultNumber: true, 52 | }, 53 | { 54 | id: 123456, 55 | phonenumber: '+4915739777777', 56 | verified: true, 57 | defaultNumber: false, 58 | }, 59 | ], 60 | }); 61 | } 62 | return Promise.reject({ 63 | response: { 64 | status: 500, 65 | uri: args, 66 | }, 67 | }); 68 | }); 69 | 70 | mockClient.post = jest 71 | .fn() 72 | .mockImplementationOnce(() => Promise.resolve({})); 73 | 74 | mockClient.put = jest.fn().mockImplementation((args) => { 75 | if ( 76 | args === 'w999/sms/s999/callerids/123456' || 77 | args === 'w999/sms/s999/callerids/0' 78 | ) { 79 | return Promise.resolve({}); 80 | } 81 | return Promise.reject({ 82 | response: { 83 | status: 500, 84 | uri: args, 85 | }, 86 | }); 87 | }); 88 | 89 | await expect( 90 | smsModule.send({ 91 | message: 'Lorem Ipsum Dolor', 92 | to: '+4915739777777', 93 | from: '+4915739777777', 94 | }) 95 | ).resolves.not.toThrow(); 96 | }); 97 | 98 | it('sends a SMS successfully', async () => { 99 | const smsModule = createSMSModule(mockClient); 100 | 101 | mockClient.post = jest 102 | .fn() 103 | .mockImplementationOnce(() => Promise.resolve({})); 104 | 105 | const message: ShortMessage = { 106 | message: 'ValidMessage', 107 | to: '+4915739777777', 108 | smsId: 's0', 109 | }; 110 | 111 | await expect(smsModule.send(message)).resolves.not.toThrow(); 112 | }); 113 | 114 | test('It sends an invalid SMS with smsId which does not exist', async () => { 115 | const smsModule = createSMSModule(mockClient); 116 | 117 | mockClient.post = jest 118 | .fn() 119 | .mockImplementationOnce(() => 120 | Promise.reject({ response: { status: 403, data: {} } }) 121 | ); 122 | 123 | const message: ShortMessage = { 124 | message: 'ValidMessage', 125 | to: '+4915739777777', 126 | smsId: 's999', 127 | }; 128 | 129 | await expect(smsModule.send(message)).rejects.toThrowError( 130 | SmsErrorMessage.INVALID_EXTENSION 131 | ); 132 | }); 133 | 134 | test('It throws when sending an SMS with an invalid smsId', async () => { 135 | const smsModule = createSMSModule(mockClient); 136 | 137 | const message: ShortMessage = { 138 | message: 'ValidMessage', 139 | to: '015739777777', 140 | smsId: 'xyz123', 141 | }; 142 | 143 | await expect(smsModule.send(message)).rejects.toThrowError( 144 | `${ErrorMessage.VALIDATOR_INVALID_EXTENSION}: ${message.smsId}` 145 | ); 146 | }); 147 | test('It sends SMS with no to', async () => { 148 | const smsModule = createSMSModule(mockClient); 149 | 150 | const message: ShortMessage = { 151 | message: 'ValidMessage', 152 | to: '', 153 | smsId: 's0', 154 | }; 155 | 156 | await expect(smsModule.send(message)).rejects.toThrowError( 157 | ErrorMessage.VALIDATOR_INVALID_PHONE_NUMBER 158 | ); 159 | }); 160 | 161 | test('It sends SMS with empty message', async () => { 162 | const smsModule = createSMSModule(mockClient); 163 | 164 | const message: ShortMessage = { 165 | message: '', 166 | to: '+4915739777777', 167 | smsId: 's0', 168 | }; 169 | 170 | await expect(smsModule.send(message)).rejects.toThrowError( 171 | SmsErrorMessage.INVALID_MESSAGE 172 | ); 173 | }); 174 | }); 175 | 176 | describe('schedule sms', () => { 177 | let mockClient: SipgateIOClient; 178 | beforeAll(() => { 179 | mockClient = {} as SipgateIOClient; 180 | }); 181 | 182 | test('should use sendAt', async () => { 183 | const smsModule = createSMSModule(mockClient); 184 | 185 | const message: ShortMessage = { 186 | message: 'ValidMessage', 187 | to: '+4915739777777', 188 | smsId: 's0', 189 | }; 190 | 191 | const date: Date = new Date( 192 | new Date().setSeconds(new Date().getSeconds() + 60) 193 | ); 194 | 195 | mockClient.post = jest.fn().mockImplementationOnce(() => { 196 | return Promise.resolve({}); 197 | }); 198 | await smsModule.send(message, date); 199 | 200 | expect(mockClient.post).toBeCalledWith('/sessions/sms', { 201 | message: 'ValidMessage', 202 | recipient: '+4915739777777', 203 | smsId: 's0', 204 | sendAt: date.getTime() / 1000, 205 | }); 206 | }); 207 | 208 | test('should throw an "SMS_TIME_MUST_BE_IN_FUTURE" error when using current date ', async () => { 209 | const smsModule = createSMSModule(mockClient); 210 | 211 | const message: ShortMessage = { 212 | message: 'ValidMessage', 213 | to: '+4915739777777', 214 | smsId: 's0', 215 | }; 216 | 217 | const date: Date = new Date( 218 | new Date().setSeconds(new Date().getSeconds() - 5) 219 | ); 220 | 221 | await expect(smsModule.send(message, date)).rejects.toThrowError( 222 | SmsErrorMessage.TIME_MUST_BE_IN_FUTURE 223 | ); 224 | }); 225 | 226 | test('should return an "SMS_TIME_TOO_FAR_IN_FUTURE" when providing a sendAt greater than 30 days in advance', async () => { 227 | const smsModule = createSMSModule(mockClient); 228 | 229 | const message: ShortMessage = { 230 | message: 'ValidMessage', 231 | to: '+4915739777777', 232 | smsId: 's0', 233 | }; 234 | 235 | const date: Date = new Date( 236 | new Date().setSeconds(new Date().getSeconds() + 60 * 60 * 24 * 31) 237 | ); 238 | 239 | await expect(smsModule.send(message, date)).rejects.toThrowError( 240 | SmsErrorMessage.TIME_TOO_FAR_IN_FUTURE 241 | ); 242 | }); 243 | 244 | test('should return an invalid date format error', async () => { 245 | const smsModule = createSMSModule(mockClient); 246 | 247 | const message: ShortMessage = { 248 | message: 'ValidMessage', 249 | to: '+4915739777777', 250 | smsId: 's0', 251 | }; 252 | 253 | const date: Date = new Date('08 bar 2015'); 254 | 255 | await expect(smsModule.send(message, date)).rejects.toThrowError( 256 | SmsErrorMessage.TIME_INVALID 257 | ); 258 | }); 259 | }); 260 | 261 | describe('The SMS module', () => { 262 | test("should correctly identify a webuser's smsExtension", async () => { 263 | const expectedId = 's0'; 264 | const mockData = { 265 | items: [ 266 | { 267 | alias: '"Alexander Bain\'s phone"', 268 | callerId: '+491517777777', 269 | id: expectedId, 270 | }, 271 | ], 272 | status: 200, 273 | }; 274 | 275 | const mockedClient = {} as SipgateIOClient; 276 | 277 | mockedClient.get = jest 278 | .fn() 279 | .mockImplementation(() => Promise.resolve(mockData)); 280 | 281 | expect( 282 | getUserSmsExtension(mockedClient, 'some webuserId') 283 | ).resolves.toEqual(expectedId); 284 | }); 285 | 286 | it('extracts the `items` from the API response', async () => { 287 | const mockuserId = 'w0'; 288 | const testSmsExtensions: SmsExtension[] = [ 289 | { 290 | id: 's0', 291 | alias: 'Sams SMS', 292 | callerId: 'test', 293 | }, 294 | { 295 | id: 's1', 296 | alias: 'Daniels SMS', 297 | callerId: 'test2', 298 | }, 299 | ]; 300 | const mockClient = {} as SipgateIOClient; 301 | 302 | mockClient.get = jest 303 | .fn() 304 | .mockImplementationOnce(() => 305 | Promise.resolve({ items: testSmsExtensions }) 306 | ); 307 | 308 | const smsModule = createSMSModule(mockClient); 309 | 310 | const smsExtensions = await smsModule.getSmsExtensions(mockuserId); 311 | expect(smsExtensions).toEqual(testSmsExtensions); 312 | 313 | expect(mockClient.get).toHaveBeenCalledWith(`${mockuserId}/sms`); 314 | }); 315 | }); 316 | 317 | describe('CallerIds for SMS Extension', () => { 318 | test('should get callerIds for sms extension', async () => { 319 | const mockData = { 320 | items: [ 321 | { 322 | defaultNumber: true, 323 | id: 0, 324 | phonenumber: '+4912345678', 325 | verified: true, 326 | }, 327 | { 328 | defaultNumber: false, 329 | id: 1, 330 | phonenumber: '+4987654321', 331 | verified: false, 332 | }, 333 | ], 334 | }; 335 | 336 | const userInfo: UserInfo = { 337 | domain: '', 338 | locale: '', 339 | masterSipId: '', 340 | sub: '', 341 | }; 342 | 343 | const smsExtension: SmsExtension = { 344 | alias: 'SMS Extension', 345 | callerId: '+4912345678', 346 | id: 's0', 347 | }; 348 | 349 | const mockedClient = {} as SipgateIOClient; 350 | 351 | mockedClient.get = jest 352 | .fn() 353 | .mockImplementation(() => Promise.resolve(mockData)); 354 | 355 | const callerIds = await getSmsCallerIds( 356 | mockedClient, 357 | userInfo.sub, 358 | smsExtension.id 359 | ); 360 | expect(callerIds).toEqual(mockData.items); 361 | }); 362 | }); 363 | 364 | describe('Numbers Verification', () => { 365 | test('should verify phone number correctly', async () => { 366 | const smsCallerIds: SmsSenderId[] = [ 367 | { 368 | defaultNumber: true, 369 | id: 0, 370 | phonenumber: '+4912345678', 371 | verified: true, 372 | }, 373 | { 374 | defaultNumber: false, 375 | id: 1, 376 | phonenumber: '+4987654321', 377 | verified: false, 378 | }, 379 | ]; 380 | 381 | const verificationStatus = containsPhoneNumber(smsCallerIds, '+4912345678'); 382 | 383 | expect(verificationStatus).toBeTruthy(); 384 | }); 385 | 386 | test('should not verify phone number', async () => { 387 | const smsCallerIds: SmsSenderId[] = [ 388 | { 389 | defaultNumber: true, 390 | id: 0, 391 | phonenumber: '+4912345678', 392 | verified: true, 393 | }, 394 | { 395 | defaultNumber: false, 396 | id: 1, 397 | phonenumber: '+4987654321', 398 | verified: false, 399 | }, 400 | ]; 401 | 402 | const verificationStatus = containsPhoneNumber(smsCallerIds, '+4987654321'); 403 | 404 | expect(verificationStatus).toBeFalsy(); 405 | }); 406 | 407 | test('should not verify phone unknown number', async () => { 408 | const smsCallerIds: SmsSenderId[] = [ 409 | { 410 | defaultNumber: true, 411 | id: 0, 412 | phonenumber: '+4912345678', 413 | verified: true, 414 | }, 415 | { 416 | defaultNumber: false, 417 | id: 1, 418 | phonenumber: '+4987654321', 419 | verified: false, 420 | }, 421 | ]; 422 | 423 | const verificationStatus = containsPhoneNumber(smsCallerIds, '12345678'); 424 | 425 | expect(verificationStatus).toBeFalsy(); 426 | }); 427 | }); 428 | -------------------------------------------------------------------------------- /lib/webhook/webhook.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CallEvent, 3 | EventType, 4 | GatherObject, 5 | GatherOptions, 6 | GenericEvent, 7 | HandlerCallback, 8 | HangUpObject, 9 | PlayObject, 10 | PlayOptions, 11 | RedirectObject, 12 | RedirectOptions, 13 | RejectObject, 14 | RejectOptions, 15 | ResponseObject, 16 | ServerOptions, 17 | VoicemailObject, 18 | WebhookHandlers, 19 | WebhookModule, 20 | WebhookResponseInterface, 21 | WebhookServer, 22 | } from './webhook.types'; 23 | import { IncomingMessage, OutgoingMessage, createServer } from 'http'; 24 | import { SipgateIOClient, TransferOptions, createRTCMModule } from '..'; 25 | import { WebhookErrorMessage } from './webhook.errors'; 26 | import { isSipgateSignature } from './signatureVerifier'; 27 | import { js2xml } from 'xml-js'; 28 | import { parse } from 'qs'; 29 | import { validateAnnouncementAudio } from './audioUtils'; 30 | 31 | interface WebhookApiResponse { 32 | _declaration: { 33 | _attributes: { 34 | version: string; 35 | encoding: string; 36 | }; 37 | }; 38 | Response: 39 | | ({ _attributes: Record } & ResponseObject) 40 | | { _attributes: Record }; 41 | } 42 | 43 | export const createWebhookModule = (): WebhookModule => ({ 44 | createServer: createWebhookServer, 45 | }); 46 | 47 | const SIPGATE_WEBHOOK_IP_ADDRESSES: string[] = [ 48 | '217.116.118.254', 49 | '212.9.46.32', 50 | ]; 51 | 52 | const createWebhookServer = async ( 53 | serverOptions: ServerOptions 54 | ): Promise => { 55 | const handlers: WebhookHandlers = { 56 | [EventType.NEW_CALL]: () => { 57 | return; 58 | }, 59 | }; 60 | 61 | return new Promise((resolve, reject) => { 62 | const requestHandler = async ( 63 | req: IncomingMessage, 64 | res: OutgoingMessage 65 | ): Promise => { 66 | const requestBody = await collectRequestData(req); 67 | if (!serverAddressesMatch(req, serverOptions)) { 68 | console.error(WebhookErrorMessage.SERVERADDRESS_DOES_NOT_MATCH); 69 | } 70 | if (!serverOptions.skipSignatureVerification) { 71 | if (!isSipgateOrigin(req, SIPGATE_WEBHOOK_IP_ADDRESSES)) { 72 | console.error(WebhookErrorMessage.INVALID_ORIGIN); 73 | res.end( 74 | `` 75 | ); 76 | return; 77 | } 78 | if ( 79 | !isSipgateSignature( 80 | req.headers['x-sipgate-signature'] as string, 81 | requestBody 82 | ) 83 | ) { 84 | console.error( 85 | WebhookErrorMessage.SIPGATE_SIGNATURE_VERIFICATION_FAILED 86 | ); 87 | res.end( 88 | `` 89 | ); 90 | return; 91 | } 92 | } 93 | res.setHeader('Content-Type', 'application/xml'); 94 | 95 | const requestBodyJSON = parseRequestBodyJSON(requestBody); 96 | const requestCallback = handlers[ 97 | requestBodyJSON.event 98 | ] as HandlerCallback; 99 | 100 | if (requestCallback === undefined) { 101 | res.end( 102 | `` 103 | ); 104 | return; 105 | } 106 | 107 | const callbackResult = requestCallback(requestBodyJSON) || undefined; 108 | 109 | const responseObject = createResponseObject( 110 | callbackResult instanceof Promise 111 | ? await callbackResult 112 | : callbackResult, 113 | serverOptions.serverAddress 114 | ); 115 | 116 | if (handlers[EventType.ANSWER]) { 117 | responseObject.Response['_attributes'].onAnswer = 118 | serverOptions.serverAddress; 119 | } 120 | 121 | if (handlers[EventType.HANGUP]) { 122 | responseObject.Response['_attributes'].onHangup = 123 | serverOptions.serverAddress; 124 | } 125 | 126 | const xmlResponse = createXmlResponse(responseObject); 127 | res.end(xmlResponse); 128 | }; 129 | 130 | const server = createServer(requestHandler).on('error', reject); 131 | 132 | server.listen( 133 | { 134 | port: serverOptions.port, 135 | hostname: serverOptions.hostname || 'localhost', 136 | }, 137 | () => { 138 | resolve({ 139 | onNewCall: (handler) => { 140 | handlers[EventType.NEW_CALL] = handler; 141 | }, 142 | onAnswer: (handler) => { 143 | if (!serverOptions.serverAddress) 144 | throw new Error( 145 | WebhookErrorMessage.SERVERADDRESS_MISSING_FOR_FOLLOWUPS 146 | ); 147 | handlers[EventType.ANSWER] = handler; 148 | }, 149 | onHangUp: (handler) => { 150 | if (!serverOptions.serverAddress) 151 | throw new Error( 152 | WebhookErrorMessage.SERVERADDRESS_MISSING_FOR_FOLLOWUPS 153 | ); 154 | handlers[EventType.HANGUP] = handler; 155 | }, 156 | onData: (handler) => { 157 | if (!serverOptions.serverAddress) 158 | throw new Error( 159 | WebhookErrorMessage.SERVERADDRESS_MISSING_FOR_FOLLOWUPS 160 | ); 161 | handlers[EventType.DATA] = handler; 162 | }, 163 | stop: () => { 164 | if (server) { 165 | server.close(); 166 | } 167 | }, 168 | getHttpServer: () => server, 169 | }); 170 | } 171 | ); 172 | }); 173 | }; 174 | 175 | const parseRequestBodyJSON = (body: string): CallEvent => { 176 | body = body 177 | .replace(/user%5B%5D/g, 'users%5B%5D') 178 | .replace(/userId%5B%5D/g, 'userIds%5B%5D') 179 | .replace(/fullUserId%5B%5D/g, 'fullUserIds%5B%5D') 180 | .replace(/origCallId/g, 'originalCallId'); 181 | 182 | const parsedBody = parse(body) as unknown as CallEvent; 183 | if ('from' in parsedBody && parsedBody.from !== 'anonymous') { 184 | parsedBody.from = `+${parsedBody.from}`; 185 | } 186 | if ('to' in parsedBody && parsedBody.to !== 'anonymous') { 187 | parsedBody.to = `+${parsedBody.to}`; 188 | } 189 | if ('diversion' in parsedBody && parsedBody.diversion !== 'anonymous') { 190 | parsedBody.diversion = `+${parsedBody.diversion}`; 191 | } 192 | if ( 193 | 'answeringNumber' in parsedBody && 194 | parsedBody.answeringNumber !== 'anonymous' 195 | ) { 196 | parsedBody.answeringNumber = `+${parsedBody.answeringNumber}`; 197 | } 198 | 199 | return parsedBody; 200 | }; 201 | 202 | const collectRequestData = (request: IncomingMessage): Promise => { 203 | return new Promise((resolve, reject) => { 204 | if ( 205 | request.headers['content-type'] && 206 | !request.headers['content-type'].includes( 207 | 'application/x-www-form-urlencoded' 208 | ) 209 | ) { 210 | reject(); 211 | } 212 | 213 | let body = ''; 214 | request.on('data', (chunk) => { 215 | body += chunk.toString(); 216 | }); 217 | request.on('end', () => { 218 | resolve(body); 219 | }); 220 | }); 221 | }; 222 | 223 | const createResponseObject = ( 224 | responseObject: ResponseObject | undefined, 225 | serverAddress: string 226 | ): WebhookApiResponse => { 227 | if (responseObject && isGatherObject(responseObject)) { 228 | responseObject.Gather._attributes['onData'] = serverAddress; 229 | } 230 | return { 231 | _declaration: { _attributes: { version: '1.0', encoding: 'utf-8' } }, 232 | Response: { 233 | _attributes: {}, 234 | ...responseObject, 235 | }, 236 | }; 237 | }; 238 | 239 | const createXmlResponse = (responseObject: WebhookApiResponse): string => { 240 | const options = { 241 | compact: true, 242 | ignoreComment: true, 243 | spaces: 4, 244 | }; 245 | return js2xml(responseObject, options); 246 | }; 247 | 248 | const isGatherObject = ( 249 | gatherCandidate: ResponseObject 250 | ): gatherCandidate is GatherObject => { 251 | return (gatherCandidate as GatherObject)?.Gather !== undefined; 252 | }; 253 | 254 | const isSipgateOrigin = ( 255 | req: IncomingMessage, 256 | validOrigins: string[] 257 | ): boolean => { 258 | const requestHeaders = req.headers['x-forwarded-for']; 259 | 260 | if (requestHeaders === undefined) { 261 | return false; 262 | } 263 | if (typeof requestHeaders === 'string') { 264 | return validOrigins.includes(requestHeaders); 265 | } 266 | 267 | return requestHeaders.some((requestHeader) => 268 | validOrigins.includes(requestHeader) 269 | ); 270 | }; 271 | 272 | export const serverAddressesMatch = ( 273 | { headers: { host }, url }: { headers: { host?: string }; url?: string }, 274 | { serverAddress }: { serverAddress: string } 275 | ): boolean => { 276 | const actual = new URL(`http://${host}${url}`); 277 | const expected = new URL(serverAddress); 278 | 279 | function paramsToObject(entries: IterableIterator<[string, string]>) { 280 | type KeyValueSet = { [shot: string]: string }; 281 | const result: KeyValueSet = {}; 282 | 283 | for (const [key, value] of entries) { 284 | result[key] = value; 285 | } 286 | return result; 287 | } 288 | 289 | return [ 290 | actual.hostname === expected.hostname, 291 | actual.pathname === expected.pathname, 292 | JSON.stringify(paramsToObject(actual.searchParams.entries())) === 293 | JSON.stringify(paramsToObject(expected.searchParams.entries())), 294 | ].every((filter) => filter === true); 295 | }; 296 | 297 | export const WebhookResponse: WebhookResponseInterface = { 298 | gatherDTMF: async (gatherOptions: GatherOptions): Promise => { 299 | if (gatherOptions.maxDigits < 1) { 300 | throw new Error( 301 | `\n\n${WebhookErrorMessage.INVALID_DTMF_MAX_DIGITS}\nYour maxDigits was: ${gatherOptions.maxDigits}\n` 302 | ); 303 | } 304 | if (gatherOptions.timeout < 0) { 305 | throw new Error( 306 | `\n\n${WebhookErrorMessage.INVALID_DTMF_TIMEOUT}\nYour timeout was: ${gatherOptions.timeout}\n` 307 | ); 308 | } 309 | const gatherObject: GatherObject = { 310 | Gather: { 311 | _attributes: { 312 | maxDigits: String(gatherOptions.maxDigits), 313 | timeout: String(gatherOptions.timeout), 314 | }, 315 | }, 316 | }; 317 | if (gatherOptions.announcement) { 318 | const validationResult = await validateAnnouncementAudio( 319 | gatherOptions.announcement 320 | ); 321 | 322 | if (!validationResult.isValid) { 323 | throw new Error( 324 | `\n\n${ 325 | WebhookErrorMessage.AUDIO_FORMAT_ERROR 326 | }\nYour format was: ${JSON.stringify(validationResult.metadata)}\n` 327 | ); 328 | } 329 | 330 | gatherObject.Gather['Play'] = { 331 | Url: gatherOptions.announcement, 332 | }; 333 | } 334 | return gatherObject; 335 | }, 336 | hangUpCall: (): HangUpObject => { 337 | return { Hangup: {} }; 338 | }, 339 | playAudio: async (playOptions: PlayOptions): Promise => { 340 | const validationResult = await validateAnnouncementAudio( 341 | playOptions.announcement 342 | ); 343 | 344 | if (!validationResult.isValid) { 345 | throw new Error( 346 | `\n\n${ 347 | WebhookErrorMessage.AUDIO_FORMAT_ERROR 348 | }\nYour format was: ${JSON.stringify(validationResult.metadata)}\n` 349 | ); 350 | } 351 | 352 | return { Play: { Url: playOptions.announcement } }; 353 | }, 354 | 355 | playAudioAndHangUp: async ( 356 | playOptions: PlayOptions, 357 | client: SipgateIOClient, 358 | callId: string, 359 | timeout?: number 360 | ): Promise => { 361 | const validationResult = await validateAnnouncementAudio( 362 | playOptions.announcement 363 | ); 364 | 365 | if (!validationResult.isValid) { 366 | throw new Error( 367 | `\n\n${ 368 | WebhookErrorMessage.AUDIO_FORMAT_ERROR 369 | }\nYour format was: ${JSON.stringify(validationResult.metadata)}\n` 370 | ); 371 | } 372 | 373 | let duration = validationResult.metadata.duration 374 | ? validationResult.metadata.duration * 1000 375 | : 0; 376 | 377 | duration += timeout ? timeout : 0; 378 | 379 | setTimeout(() => { 380 | const rtcm = createRTCMModule(client); 381 | // ignore errors, which were happening when the callee already hung up the phone before the announcement had ended 382 | rtcm.hangUp({ callId }).catch(() => {}); 383 | }, duration); 384 | 385 | return { Play: { Url: playOptions.announcement } }; 386 | }, 387 | playAudioAndTransfer: async ( 388 | playOptions: PlayOptions, 389 | transferOptions: TransferOptions, 390 | client: SipgateIOClient, 391 | callId: string, 392 | timeout?: number 393 | ): Promise => { 394 | const validationResult = await validateAnnouncementAudio( 395 | playOptions.announcement 396 | ); 397 | 398 | if (!validationResult.isValid) { 399 | throw new Error( 400 | `\n\n${ 401 | WebhookErrorMessage.AUDIO_FORMAT_ERROR 402 | }\nYour format was: ${JSON.stringify(validationResult.metadata)}\n` 403 | ); 404 | } 405 | 406 | let duration = validationResult.metadata.duration 407 | ? validationResult.metadata.duration * 1000 408 | : 0; 409 | 410 | duration += timeout ? timeout : 0; 411 | 412 | setTimeout(() => { 413 | const rtcm = createRTCMModule(client); 414 | // ignore errors, which were happening when the callee already hung up the phone before the announcement had ended 415 | rtcm.transfer({ callId }, transferOptions).catch(() => {}); 416 | }, duration); 417 | 418 | return { Play: { Url: playOptions.announcement } }; 419 | }, 420 | redirectCall: (redirectOptions: RedirectOptions): RedirectObject => { 421 | return { 422 | Dial: { 423 | _attributes: { 424 | callerId: redirectOptions.callerId, 425 | anonymous: String(redirectOptions.anonymous), 426 | }, 427 | Number: redirectOptions.numbers, 428 | }, 429 | }; 430 | }, 431 | rejectCall: (rejectOptions: RejectOptions): RejectObject => { 432 | return { Reject: { _attributes: { reason: rejectOptions.reason } } }; 433 | }, 434 | 435 | sendToVoicemail: (): VoicemailObject => { 436 | return { Dial: { Voicemail: {} } }; 437 | }, 438 | }; 439 | -------------------------------------------------------------------------------- /lib/contacts/contacts.ts: -------------------------------------------------------------------------------- 1 | import { ContactImport } from './helpers/Address'; 2 | import { 3 | ContactResponse, 4 | ContactUpdate, 5 | ContactsDTO, 6 | ContactsListResponse, 7 | ContactsModule, 8 | ImportCSVRequestDTO, 9 | } from './contacts.types'; 10 | import { 11 | ContactsErrorMessage, 12 | handleContactsError, 13 | } from './errors/handleContactsError'; 14 | import { PagedResponse } from '../core'; 15 | import { Parser } from 'json2csv'; 16 | import { SipgateIOClient } from '../core/sipgateIOClient'; 17 | import { createVCards, parseVCard } from './helpers/vCardHelper'; 18 | import { toBase64 } from '../utils'; 19 | 20 | export const createContactsModule = ( 21 | client: SipgateIOClient 22 | ): ContactsModule => ({ 23 | async importFromCsvString(csvContent: string): Promise { 24 | const projectedCsv = projectCsvString(csvContent); 25 | const base64EncodedCsv = toBase64(projectedCsv); 26 | const contactsDTO: ImportCSVRequestDTO = { 27 | base64Content: base64EncodedCsv, 28 | }; 29 | 30 | await client 31 | .post('/contacts/import/csv', contactsDTO) 32 | .catch((error) => Promise.reject(handleContactsError(error))); 33 | }, 34 | 35 | async create(contact, scope): Promise { 36 | const { 37 | firstname, 38 | lastname, 39 | organization, 40 | address, 41 | email, 42 | phone, 43 | picture, 44 | } = contact; 45 | 46 | if (firstname === '' && lastname === '') { 47 | throw new Error(ContactsErrorMessage.CONTACTS_MISSING_NAME_ATTRIBUTE); 48 | } 49 | const contactsDTO: ContactsDTO = { 50 | name: `${firstname} ${lastname}`, 51 | family: lastname, 52 | given: firstname, 53 | organization: organization ? organization : [], 54 | picture: picture ? picture : null, 55 | scope, 56 | addresses: address ? [address] : [], 57 | emails: email ? [email] : [], 58 | numbers: phone ? [phone] : [], 59 | }; 60 | await client 61 | .post('/contacts', contactsDTO) 62 | .catch((error) => Promise.reject(handleContactsError(error))); 63 | }, 64 | 65 | async update(contact: ContactUpdate): Promise { 66 | await client 67 | .put(`/contacts/${contact.id}`, contact) 68 | .catch((error) => Promise.reject(handleContactsError(error))); 69 | }, 70 | 71 | async delete(id: string): Promise { 72 | await client 73 | .delete(`/contacts/${id}`) 74 | .catch((error) => Promise.reject(handleContactsError(error))); 75 | }, 76 | 77 | async deleteAllPrivate(): Promise { 78 | await client 79 | .delete('/contacts') 80 | .catch((error) => Promise.reject(handleContactsError(error))); 81 | }, 82 | async deleteAllShared(): Promise { 83 | const contactsResponse = await client.get(`contacts`); 84 | contactsResponse.items = contactsResponse.items.filter( 85 | (contact) => contact.scope === 'SHARED' 86 | ); 87 | 88 | Promise.all( 89 | contactsResponse.items.map(async (contact) => { 90 | await client 91 | .delete(`/contacts/${contact.id}`) 92 | .catch((error) => Promise.reject(handleContactsError(error))); 93 | }) 94 | ); 95 | }, 96 | async importVCardString(vCardContent: string, scope): Promise { 97 | const parsedVCard = parseVCard(vCardContent); 98 | 99 | const addresses = []; 100 | if (parsedVCard.address) { 101 | addresses.push(parsedVCard.address); 102 | } 103 | const emails = []; 104 | if (parsedVCard.email) { 105 | emails.push({ 106 | email: parsedVCard.email, 107 | type: [], 108 | }); 109 | } 110 | 111 | const contactsDTO: ContactsDTO = { 112 | name: `${parsedVCard.firstname} ${parsedVCard.lastname}`, 113 | family: parsedVCard.lastname, 114 | given: parsedVCard.firstname, 115 | organization: parsedVCard.organization, 116 | picture: null, 117 | scope, 118 | addresses, 119 | emails, 120 | numbers: [ 121 | { 122 | number: parsedVCard.phoneNumber, 123 | type: [], 124 | }, 125 | ], 126 | }; 127 | await client 128 | .post('/contacts', contactsDTO) 129 | .catch((error) => Promise.reject(handleContactsError(error))); 130 | }, 131 | 132 | // The api indicates that there is more data to be fetched with the 133 | // `hasMore` flag. If this flag is observed, you can make a paginated 134 | // request with an offset. 135 | async paginatedExportAsCsv( 136 | scope, 137 | delimiter = ',', 138 | pagination, 139 | filter 140 | ): Promise> { 141 | const contactsResponse = await client.get( 142 | `contacts`, 143 | { 144 | params: { 145 | ...pagination, 146 | ...filter, 147 | }, 148 | } 149 | ); 150 | 151 | const limit = pagination?.limit ?? 5000; 152 | const offset = pagination?.offset ?? 0; 153 | const hasMore = contactsResponse.totalCount > limit + offset; 154 | 155 | contactsResponse.items = contactsResponse.items.filter( 156 | (contact) => contact.scope === scope || scope === 'ALL' 157 | ); 158 | 159 | const fields = [ 160 | 'id', 161 | 'name', 162 | 'emails', 163 | 'numbers', 164 | 'addresses', 165 | 'organizations', 166 | ]; 167 | 168 | const opts = { fields, delimiter }; 169 | const elements = contactsResponse.items.map((contact) => { 170 | return { 171 | id: contact.id, 172 | name: contact.name, 173 | emails: contact.emails.map((email) => email.email), 174 | numbers: contact.numbers.map((number) => number.number), 175 | addresses: contact.addresses, 176 | organizations: contact.organization, 177 | }; 178 | }); 179 | try { 180 | const parser = new Parser(opts); 181 | 182 | return { 183 | response: parser.parse(elements), 184 | hasMore, 185 | }; 186 | } catch (err) { 187 | throw Error(`${err}`); 188 | } 189 | }, 190 | 191 | async exportAsCsv( 192 | scope, 193 | delimiter = ',', 194 | pagination, 195 | filter 196 | ): Promise { 197 | const contactsResponse = await client.get( 198 | `contacts`, 199 | { 200 | params: { 201 | ...pagination, 202 | ...filter, 203 | }, 204 | } 205 | ); 206 | 207 | contactsResponse.items = contactsResponse.items.filter( 208 | (contact) => contact.scope === scope || scope === 'ALL' 209 | ); 210 | 211 | const fields = [ 212 | 'id', 213 | 'name', 214 | 'emails', 215 | 'numbers', 216 | 'addresses', 217 | 'organizations', 218 | ]; 219 | 220 | const opts = { fields, delimiter }; 221 | const elements = contactsResponse.items.map((contact) => { 222 | return { 223 | id: contact.id, 224 | name: contact.name, 225 | emails: contact.emails.map((email) => email.email), 226 | numbers: contact.numbers.map((number) => number.number), 227 | addresses: contact.addresses, 228 | organizations: contact.organization, 229 | }; 230 | }); 231 | try { 232 | const parser = new Parser(opts); 233 | return parser.parse(elements); 234 | } catch (err) { 235 | throw Error(`${err}`); 236 | } 237 | }, 238 | 239 | async paginatedExportAsJSON( 240 | scope, 241 | pagination, 242 | filter 243 | ): Promise> { 244 | const contactsResponse = await client.get( 245 | `contacts`, 246 | { 247 | params: { 248 | ...pagination, 249 | ...filter, 250 | }, 251 | } 252 | ); 253 | 254 | const limit = pagination?.limit ?? 5000; 255 | const offset = pagination?.offset ?? 0; 256 | const hasMore = contactsResponse.totalCount > limit + offset; 257 | 258 | contactsResponse.items = contactsResponse.items.filter( 259 | (contact) => contact.scope === scope || scope === 'ALL' 260 | ); 261 | 262 | const elements = contactsResponse.items.map((contact) => { 263 | return { 264 | id: contact.id, 265 | name: contact.name, 266 | emails: contact.emails.map((email) => email.email), 267 | numbers: contact.numbers.map((number) => number.number), 268 | addresses: contact.addresses, 269 | organizations: contact.organization, 270 | scope: contact.scope, 271 | }; 272 | }); 273 | try { 274 | const jsonResponse = JSON.stringify({ 275 | contacts: elements, 276 | totalCount: contactsResponse.totalCount, 277 | }); 278 | return { 279 | response: jsonResponse, 280 | hasMore, 281 | }; 282 | } catch (err) { 283 | throw Error(`${err}`); 284 | } 285 | }, 286 | async exportAsJSON(scope, pagination, filter): Promise { 287 | const contactsResponse = await client.get( 288 | `contacts`, 289 | { 290 | params: { 291 | ...pagination, 292 | ...filter, 293 | }, 294 | } 295 | ); 296 | 297 | contactsResponse.items = contactsResponse.items.filter( 298 | (contact) => contact.scope === scope || scope === 'ALL' 299 | ); 300 | const elements = contactsResponse.items.map((contact) => { 301 | return { 302 | id: contact.id, 303 | name: contact.name, 304 | emails: contact.emails.map((email) => email.email), 305 | numbers: contact.numbers.map((number) => number.number), 306 | addresses: contact.addresses, 307 | organizations: contact.organization, 308 | scope: contact.scope, 309 | }; 310 | }); 311 | try { 312 | return JSON.stringify({ 313 | contacts: elements, 314 | totalCount: contactsResponse.totalCount, 315 | }); 316 | } catch (err) { 317 | throw Error(`${err}`); 318 | } 319 | }, 320 | async paginatedGet( 321 | scope, 322 | pagination, 323 | filter 324 | ): Promise> { 325 | const contactsResponse = await client.get( 326 | `contacts`, 327 | { 328 | params: { 329 | ...pagination, 330 | ...filter, 331 | }, 332 | } 333 | ); 334 | 335 | const limit = pagination?.limit ?? 5000; 336 | const offset = pagination?.offset ?? 0; 337 | const hasMore = contactsResponse.totalCount > limit + offset; 338 | 339 | contactsResponse.items = contactsResponse.items.filter( 340 | (contact) => contact.scope === scope || scope === 'ALL' 341 | ); 342 | return { 343 | response: contactsResponse.items, 344 | hasMore, 345 | }; 346 | }, 347 | 348 | async get(scope, pagination, filter): Promise { 349 | const contactsResponse = await client.get( 350 | `contacts`, 351 | { 352 | params: { 353 | ...pagination, 354 | ...filter, 355 | }, 356 | } 357 | ); 358 | contactsResponse.items = contactsResponse.items.filter( 359 | (contact) => contact.scope === scope || scope === 'ALL' 360 | ); 361 | return contactsResponse.items; 362 | }, 363 | 364 | async paginatedExportAsSingleVCard( 365 | scope, 366 | pagination, 367 | filter 368 | ): Promise> { 369 | const vCards = await this.paginatedExportAsVCards( 370 | scope, 371 | pagination, 372 | filter 373 | ); 374 | return { 375 | response: vCards.response.join('\r\n'), 376 | hasMore: vCards.hasMore, 377 | }; 378 | }, 379 | 380 | async exportAsSingleVCard(scope, pagination, filter): Promise { 381 | const vCards = await this.exportAsVCards(scope, pagination, filter); 382 | return vCards.join('\r\n'); 383 | }, 384 | 385 | async paginatedExportAsVCards( 386 | scope, 387 | pagination, 388 | filter 389 | ): Promise> { 390 | const contactsResponse = await client.get( 391 | `contacts`, 392 | { 393 | params: { 394 | ...pagination, 395 | ...filter, 396 | }, 397 | } 398 | ); 399 | 400 | const limit = pagination?.limit ?? 5000; 401 | const offset = pagination?.offset ?? 0; 402 | const hasMore = contactsResponse.totalCount > limit + offset; 403 | 404 | contactsResponse.items = contactsResponse.items.filter( 405 | (contact) => contact.scope === scope || scope === 'ALL' 406 | ); 407 | 408 | const contacts = contactsResponse.items.map((contact) => { 409 | return { 410 | firstname: contact.name, 411 | lastname: '', 412 | organizations: contact.organization, 413 | phoneNumbers: contact.numbers, 414 | emails: contact.emails, 415 | addresses: contact.addresses.map((address) => { 416 | return { 417 | ...address, 418 | type: ['home'], 419 | }; 420 | }), 421 | }; 422 | }); 423 | return { 424 | response: createVCards(contacts), 425 | hasMore, 426 | }; 427 | }, 428 | 429 | async exportAsVCards(scope, pagination, filter): Promise { 430 | const contactsResponse = await client.get( 431 | `contacts`, 432 | { 433 | params: { 434 | ...pagination, 435 | ...filter, 436 | }, 437 | } 438 | ); 439 | 440 | contactsResponse.items = contactsResponse.items.filter( 441 | (contact) => contact.scope === scope || scope === 'ALL' 442 | ); 443 | 444 | const contacts = contactsResponse.items.map((contact) => { 445 | return { 446 | firstname: contact.name, 447 | lastname: '', 448 | organizations: contact.organization, 449 | phoneNumbers: contact.numbers, 450 | emails: contact.emails, 451 | addresses: contact.addresses.map((address) => { 452 | return { 453 | ...address, 454 | type: ['home'], 455 | }; 456 | }), 457 | }; 458 | }); 459 | 460 | return createVCards(contacts); 461 | }, 462 | }); 463 | 464 | const findColumnIndex = (array: string[], needle: string): number => { 465 | const index = array.indexOf(needle); 466 | if (index < 0) { 467 | throw new Error( 468 | `${ContactsErrorMessage.CONTACTS_MISSING_HEADER_FIELD}: ${needle}` 469 | ); 470 | } 471 | return index; 472 | }; 473 | 474 | const projectCsvString = (csvString: string): string => { 475 | const csvLines: string[] = csvString 476 | .split(/\n|\r\n/) 477 | .filter((line) => line !== ''); 478 | 479 | if (csvLines.length < 1) { 480 | throw new Error(ContactsErrorMessage.CONTACTS_INVALID_CSV); 481 | } 482 | 483 | if (csvLines.length < 2) { 484 | console.log('WARNING: no lines to import'); 485 | } 486 | 487 | const csvHeader: string[] = csvLines[0] 488 | .split(',') 489 | .map((header) => header.toLowerCase()); 490 | const columnIndices = { 491 | firstname: findColumnIndex(csvHeader, 'firstname'), 492 | lastname: findColumnIndex(csvHeader, 'lastname'), 493 | number: findColumnIndex(csvHeader, 'number'), 494 | }; 495 | 496 | csvLines.shift(); 497 | 498 | const lines = csvLines 499 | .map((lines) => lines.split(',')) 500 | .map((columns, index) => { 501 | if (columns.length !== csvHeader.length) { 502 | throw Error(ContactsErrorMessage.CONTACTS_MISSING_VALUES); 503 | } 504 | 505 | const firstname = columns[columnIndices.firstname]; 506 | const lastname = columns[columnIndices.lastname]; 507 | const number = columns[columnIndices.number]; 508 | 509 | if (!(firstname && lastname && number)) { 510 | console.log(`WARNING: record at position ${index + 1} is empty`); 511 | return ''; 512 | } 513 | 514 | return [firstname, lastname, number].join(','); 515 | }); 516 | 517 | return ['firstname,lastname,number', ...lines].join('\n'); 518 | }; 519 | --------------------------------------------------------------------------------