import { SECOND } from '@waha/structures/enums.dto'; import { RetryPolicy, WebhookConfig, } from '@waha/structures/webhooks.config.dto'; import { LoggerBuilder } from '@waha/utils/logging'; import { VERSION } from '@waha/version'; import axios, { AxiosInstance } from 'axios'; import axiosRetry, { retryAfter } from 'axios-retry'; import * as crypto from 'crypto'; import { Logger } from 'pino'; import { ulid } from 'ulid'; // eslint-disable-next-line @typescript-eslint/no-var-requires const HttpAgent = require('agentkeepalive'); // eslint-disable-next-line @typescript-eslint/no-var-requires const HttpsAgent = require('agentkeepalive').HttpsAgent; const DEFAULT_RETRY_DELAY_SECONDS = 2; const DEFAULT_RETRY_ATTEMPTS = 15; const DEFAULT_HMAC_ALGORITHM = 'sha512'; function noDelay(_retryNumber = 0, error: any) { return Math.max(0, retryAfter(error)); } function constantDelay(delayFactor: number) { return (_retryNumber = 0, error = undefined) => { return Math.max(delayFactor, retryAfter(error)); }; } export function exponentialDelay(delayFactor: number) { return (retryNumber = 0, error = undefined) => { const calculatedDelay = 2 ** retryNumber * delayFactor; const delay = Math.max(calculatedDelay, retryAfter(error)); const randomSum = delay * 0.2 * Math.random(); // 0-20% of the delay return delay + randomSum; }; } export class WebhookSender { protected static AGENTS = { http: new HttpAgent({}), https: new HttpsAgent({}), }; protected url: string; protected logger: Logger; protected readonly config: WebhookConfig; protected axios: AxiosInstance; constructor( loggerBuilder: LoggerBuilder, protected webhookConfig: WebhookConfig, ) { this.url = webhookConfig.url; this.logger = loggerBuilder.child({ name: WebhookSender.name }); this.config = webhookConfig; this.axios = this.buildAxiosInstance(); } send(json: any) { const body = JSON.stringify(json); const headers = { 'content-type': 'application/json', }; Object.assign(headers, this.getWebhookHeader(json)); Object.assign(headers, this.getHMACHeaders(body)); const ctx = { id: headers['X-Webhook-Request-Id'], ['event.id']: json.id, url: this.url, }; this.logger.info(ctx, `Sending POST...`); this.logger.debug(ctx, `POST DATA`); this.axios .post(this.url, body, { headers: headers }) .then((response) => { this.logger.info( ctx, `POST request was sent with status code: ${response.status}`, ); this.logger.debug( { ...ctx, body: response.data, }, `Response`, ); }) .catch((error) => { this.logger.error( { ...ctx, error: error.message, data: error.response?.data, }, `POST request failed: ${error.message}`, ); }); } protected buildAxiosInstance(): AxiosInstance { // configure headers const customHeaders = this.config.customHeaders || []; const headers = { 'content-type': 'application/json', 'User-Agent': `WAHA/${VERSION.version}`, }; customHeaders.forEach((header) => { headers[header.name] = header.value; }); // configure retry const attempts = this.config.retries?.attempts ?? DEFAULT_RETRY_ATTEMPTS; const delaySeconds = this.config.retries?.delaySeconds ?? DEFAULT_RETRY_DELAY_SECONDS; const delayMs = delaySeconds * SECOND; const policy = this.config.retries?.policy; const retryDelay = this.buildRetryDelay(policy, delayMs); const instance = axios.create({ headers: headers, httpAgent: WebhookSender.AGENTS.http, httpsAgent: WebhookSender.AGENTS.https, }); axiosRetry(instance, { retries: attempts, retryDelay: retryDelay, retryCondition: (error) => true, onRetry: (retryCount, error, requestConfig) => { this.logger.warn( { id: requestConfig.headers['X-Webhook-Request-Id'] }, `Error sending POST request: '${error.message}'. Retrying ${retryCount}/${attempts}...`, ); }, }); return instance; } protected getHMACHeaders(body: string) { // HMAC const hmac = this.calculateHmac(body, DEFAULT_HMAC_ALGORITHM); if (!hmac) { return {}; } return { 'X-Webhook-Hmac': hmac, 'X-Webhook-Hmac-Algorithm': DEFAULT_HMAC_ALGORITHM, }; } protected getWebhookHeader(json: any) { const timestamp = json.timestamp?.toString() || Date.now().toString(); return { // UUID, no '-' in it 'X-Webhook-Request-Id': ulid(), // unix timestamp with ms 'X-Webhook-Timestamp': timestamp, }; } private calculateHmac(body, algorithm) { if (!this.config.hmac || !this.config.hmac.key) { return undefined; } return crypto .createHmac(algorithm, this.config.hmac.key) .update(body) .digest('hex'); } private buildRetryDelay( policy: RetryPolicy | null, ms: number, ): (retryNumber: number, error: any) => number { if (!ms) { this.logger.debug(`Using no delay, because delaySeconds set to 0`); return noDelay; } switch (policy) { case RetryPolicy.CONSTANT: this.logger.debug(`Using constant delay with '${ms}' ms factor`); return constantDelay(ms); case RetryPolicy.LINEAR: this.logger.debug(`Using linear delay with '${ms}' ms factor`); return axiosRetry.linearDelay(ms); case RetryPolicy.EXPONENTIAL: this.logger.debug(`Using exponential delay with '${ms}' ms factor`); return exponentialDelay(ms); default: this.logger.debug('No delay policy specified, using constant delay'); return constantDelay(ms); } } }