File size: 6,820 Bytes
46252cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import { randomUUID, createHash } from 'crypto';

/**
 * Safely convert an unknown value to a string for use in idempotency keys
 */
function toStr(value: unknown, fallback = 'unknown'): string {
  if (value === null || value === undefined) return fallback;
  if (typeof value === 'string') return value;
  if (typeof value === 'number' || typeof value === 'boolean') return String(value);
  return fallback;
}

/**
 * Generate a short hash from data for use in idempotency keys.
 * Used when no unique identifier is available in the payload.
 */
function hashData(data: Record<string, unknown>): string {
  const str = JSON.stringify(data, Object.keys(data).sort());
  return createHash('sha256').update(str).digest('hex').substring(0, 12);
}

/**
 * Generate an idempotency key based on event type and data.
 * Same event with same data will produce the same key (deterministic).
 *
 * @remarks
 * Stable message keys are content-based (keyed on the unique message id), so two deliveries of the
 * same logical message dedupe. Recurring events (message edits/reactions and session lifecycle
 * transitions) can repeat with identical content, so they are salted with `occurredAt` (captured ONCE
 * per dispatch and reused across retries): distinct occurrences get distinct keys while retries of
 * the same occurrence stay stable.
 *
 * @param occurredAt - ISO timestamp captured once per dispatch; salts recurring occurrence keys.
 */
export function generateIdempotencyKey(event: string, data: Record<string, unknown>, occurredAt?: string): string {
  // Only recurring occurrence keys consume this salt; stable message and QR keys ignore it.
  const occurrence = occurredAt ? `_${occurredAt}` : '';
  switch (event) {
    case 'message.received':
    case 'message.sent':
      // Dispatched payload is an IncomingMessage, which carries `id`; fall back to a legacy `messageId`.
      // Resolve the value before toStr() β€” toStr() returns a truthy 'unknown' fallback, so chaining with
      // `||` would short-circuit before reaching the second field.
      // Scope by sessionId: waMessageIds are unique per account, not globally, so two
      // sessions could otherwise collide on the same key and wrongly dedupe each other's events.
      return `msg_${toStr(data.sessionId)}_${toStr(data.id ?? data.messageId)}`;

    case 'message.ack':
      // Message ID + delivery status together are unique. Key on the neutral `status`; fall back to
      // the legacy `ack` integer for backward compatibility with older payloads.
      return `ack_${toStr(data.sessionId)}_${toStr(data.id ?? data.messageId)}_${toStr(data.status ?? data.ack, '0')}`;

    case 'message.failed':
      return `failed_${toStr(data.sessionId)}_${toStr(data.id ?? data.messageId)}_${toStr(data.status ?? data.ack, '0')}`;

    case 'message.revoked':
      return `rev_${toStr(data.sessionId)}_${toStr(data.id ?? data.messageId)}`;

    case 'message.edited':
      // Editing the same message multiple times should produce different idempotency keys.
      // Salt with occurredAt (captured once per dispatch, reused across retries) so distinct occurrences
      // get distinct keys, while retries of the same delivery stay stable.
      return `edit_${toStr(data.sessionId)}_${toStr(data.messageId)}${occurrence}`;

    case 'message.reaction':
      // A reaction carries no unique id and is a read-modify-write of the message's reactions map; the
      // same sender can re-apply the same emoji over time (πŸ‘ β†’ remove β†’ πŸ‘). Keying on
      // (sender, target message) alone would collapse a genuine re-reaction onto the earlier one, so salt
      // with occurredAt (captured once per dispatch, reused across retries): distinct occurrences get
      // distinct keys while retries of the same delivery stay stable.
      return `react_${toStr(data.sessionId)}_${toStr(data.messageId)}_${toStr(data.senderId)}${occurrence}`;

    case 'session.status':
      // Salted so repeated transitions to the same status (e.g. across disconnect/reconnect cycles)
      // stay distinct instead of collapsing onto one key.
      return `sess_${toStr(data.sessionId)}_${toStr(data.status)}${occurrence}`;

    case 'session.qr':
      // QR changes each time, use the QR data hash for uniqueness
      return `qr_${toStr(data.sessionId)}_${hashData({ qr: data.qr })}`;

    case 'session.authenticated':
      // Salted so each (re)authentication is a distinct event β€” phone/pushName repeat across reconnects.
      return `auth_${toStr(data.sessionId)}_${hashData(data)}${occurrence}`;

    case 'session.disconnected':
      // Salted so repeat disconnects stay distinct β€” `reason` alone can be a constant (Baileys
      // always sends 'logged out'), which would otherwise collapse every disconnect onto one key.
      return `disc_${toStr(data.sessionId)}_${hashData({ reason: data.reason })}${occurrence}`;

    case 'group.join':
      // A membership change carries no unique id and repeats with identical content (the same user
      // leaves and rejoins the same group). Key on the affected participants and salt with
      // occurredAt (captured once per dispatch, reused across retries) so a genuine re-join is not
      // deduped onto the earlier delivery.
      return `grp_${toStr(data.groupId)}_${hashData({ participants: data.participantIds })}_join${occurrence}`;

    case 'group.leave':
      // Same recurring-occurrence treatment as group.join.
      return `grp_${toStr(data.groupId)}_${hashData({ participants: data.participantIds })}_leave${occurrence}`;

    case 'group.update':
      // Key on WHAT changed (a subject set to "X" twice is one logical change; announce toggling
      // back and forth is several), salted per occurrence so identical repeat updates stay distinct.
      // `changes` is pre-stringified: hashData's allowlist replacer only keeps TOP-LEVEL keys, so a
      // nested object would serialize as {} and hash every distinct change identically.
      return `grp_${toStr(data.groupId)}_update_${hashData({ changes: JSON.stringify(data.changes ?? null) })}${occurrence}`;

    case 'call.received':
      // A call id is unique per call, so keying on (session, callId) gives each occurrence a
      // distinct key with no occurredAt salt, while retries of the same dispatch regenerate the
      // same key (same stability contract as the message.* keys). Scoped by sessionId like `msg_`.
      return `call_${toStr(data.sessionId)}_${toStr(data.callId)}`;

    default:
      // Fallback: hash entire payload for determinism
      return `evt_${event.replace(/\./g, '_')}_${hashData(data)}`;
  }
}

/**
 * Generate a unique delivery ID for each webhook delivery (stable across retry attempts)
 */
export function generateDeliveryId(): string {
  return `dlv_${randomUUID()}`;
}