File size: 5,890 Bytes
4327358
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
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);
    }
  }
}