Subversion Repositories SmartDukaan

Rev

Rev 37651 | Details | Compare with Previous | Last modification | View Log | RSS feed

Rev Author Line No. Line
37651 vikas 1
package com.spice.profitmandi.web.controller;
2
 
3
import com.fasterxml.jackson.databind.JsonNode;
4
import com.fasterxml.jackson.databind.ObjectMapper;
5
import com.spice.profitmandi.common.web.util.ResponseSender;
6
import com.spice.profitmandi.dao.entity.auth.AuthUser;
7
import com.spice.profitmandi.dao.entity.user.Lead;
8
import com.spice.profitmandi.dao.entity.user.LeadCall;
9
import com.spice.profitmandi.dao.entity.user.LeadDnd;
10
import com.spice.profitmandi.dao.entity.auth.VonageAgent;
11
import com.spice.profitmandi.dao.repository.auth.AuthRepository;
12
import com.spice.profitmandi.dao.repository.auth.VonageAgentRepository;
13
import com.spice.profitmandi.dao.repository.dtr.LeadCallRepository;
14
import com.spice.profitmandi.dao.repository.dtr.LeadDndRepository;
15
import com.spice.profitmandi.dao.repository.dtr.LeadRepository;
16
import com.spice.profitmandi.service.integrations.vonage.VonageVoiceService;
37749 vikas 17
import com.spice.profitmandi.service.lms.LmsCallService;
18
import com.spice.profitmandi.service.lms.LmsOutcome;
37651 vikas 19
import com.spice.profitmandi.service.lms.LmsDialerProvider;
20
import com.spice.profitmandi.web.model.LoginDetails;
21
import com.spice.profitmandi.web.util.CookiesProcessor;
22
import org.apache.logging.log4j.LogManager;
23
import org.apache.logging.log4j.Logger;
24
import org.springframework.beans.factory.annotation.Autowired;
25
import org.springframework.beans.factory.annotation.Value;
26
import org.springframework.http.HttpStatus;
27
import org.springframework.http.MediaType;
28
import org.springframework.http.ResponseEntity;
29
import org.springframework.stereotype.Controller;
30
import org.springframework.transaction.annotation.Transactional;
31
import org.springframework.web.bind.annotation.RequestBody;
32
import org.springframework.web.bind.annotation.RequestHeader;
33
import org.springframework.web.bind.annotation.RequestMapping;
34
import org.springframework.web.bind.annotation.RequestMethod;
35
import org.springframework.web.bind.annotation.RequestParam;
36
import org.springframework.web.bind.annotation.ResponseBody;
37
 
38
import javax.crypto.Mac;
39
import javax.crypto.spec.SecretKeySpec;
40
import javax.servlet.http.HttpServletRequest;
41
import java.nio.charset.StandardCharsets;
42
import java.security.MessageDigest;
43
import java.time.LocalDateTime;
44
import java.time.LocalTime;
45
import java.util.Base64;
46
import java.util.HashMap;
47
import java.util.Map;
48
 
49
/**
50
 * The LMS web dialer: the endpoints the browser softphone talks to, plus the three Vonage callbacks.
51
 *
52
 * <p><b>Two audiences, two security models.</b> {@code /lms/dialer/*} is normal authenticated app
53
 * traffic behind the session cookie and the role gate. {@code /vonage/voice/*} is Vonage calling us,
54
 * so those paths are excluded from all three interceptors in {@code WebConfig} and are protected
55
 * instead by the signed-webhook JWT — which means they must never trust anything in the payload that
56
 * they can look up themselves. In particular the number we dial comes from the lead record, never
57
 * from the webhook.
58
 */
59
@Controller
60
@Transactional(rollbackFor = Throwable.class)
61
public class LmsDialerController {
62
 
63
    private static final Logger LOGGER = LogManager.getLogger(LmsDialerController.class);
64
 
65
    private final ObjectMapper objectMapper = new ObjectMapper();
66
 
67
    @Autowired
68
    private LeadRepository leadRepository;
69
 
70
    @Autowired
71
    private LeadCallRepository leadCallRepository;
72
 
73
    @Autowired
74
    private LeadDndRepository leadDndRepository;
75
 
76
    @Autowired
77
    private VonageVoiceService vonageVoiceService;
78
 
79
    @Autowired
80
    private LmsDialerProvider dialerProvider;
81
 
82
    /**
37749 vikas 83
     * Placing, tracking and ending a call — shared with the field app's endpoints in
84
     * profitmandi-web, which differ from these only in how they identify the agent.
37651 vikas 85
     */
37749 vikas 86
    @Autowired
87
    private LmsCallService lmsCallService;
37651 vikas 88
 
89
    @Autowired
90
    private AuthRepository authRepository;
91
 
92
    @Autowired
93
    private VonageAgentRepository vonageAgentRepository;
94
 
95
    @Autowired
96
    private CookiesProcessor cookiesProcessor;
97
 
98
    @Autowired
99
    private ResponseSender<?> responseSender;
100
 
101
    /**
102
     * Vonage account signature secret. Verification is skipped while this is blank so a new
103
     * environment can be brought up before the secret is in place — same opt-in shape as
104
     * {@code PinelabsWebhookController}. Set it in every environment that faces the internet.
105
     */
106
    @Value("${vonage.webhook.signature.secret:}")
107
    private String webhookSignatureSecret;
108
 
109
    // ---- Browser-facing -----------------------------------------------------------------------
110
 
111
    /**
112
     * Mint this agent's dialer credential. Short-lived by design — the page asks again rather than
113
     * holding a long-lived token in memory.
114
     */
115
    @RequestMapping(value = "/lms/dialer/token", method = RequestMethod.GET)
116
    @ResponseBody
117
    public ResponseEntity<?> token(HttpServletRequest request) {
118
        AuthUser me = currentUser(request);
119
        LmsDialerProvider.Handshake handshake = dialerProvider.prepare(me);
120
        Map<String, Object> out = new HashMap<>();
121
        out.put("ok", handshake.ok);
122
        if (!handshake.ok) {
123
            out.put("reason", handshake.reason);
124
            return responseSender.ok(out);
125
        }
126
        out.put("mode", handshake.mode.name());
127
        out.put("token", handshake.token);
128
        out.put("applicationId", handshake.applicationId);
129
        return responseSender.ok(out);
130
    }
131
 
132
    /**
37749 vikas 133
     * Gate a dial before it happens: DND and calling hours.
134
     *
135
     * <p>A refusal is a 200 carrying {@code {ok:false, code, reason}} — "this retailer is on the
136
     * DND register" is a normal answer to "may I dial?", not a client error, and the browser needs
137
     * the code to decide what to show.
37651 vikas 138
     */
139
    @RequestMapping(value = "/lms/dialer/precheck", method = RequestMethod.POST)
140
    @ResponseBody
141
    public ResponseEntity<?> precheck(HttpServletRequest request,
142
                                      @RequestParam(name = "leadId") int leadId) {
37749 vikas 143
        LmsOutcome outcome = lmsCallService.precheck(leadId);
144
        if (outcome.status == LmsOutcome.Status.REFUSED) {
145
            return responseSender.ok(outcome.asRefusalBody());
37651 vikas 146
        }
37749 vikas 147
        return render(outcome);
37651 vikas 148
    }
149
 
150
    /**
151
     * The call row for a lead, so the browser can learn its {@code leadCallId} after the answer
152
     * webhook has created it. The browser cannot know this id itself — see {@code VonageVoiceService}.
153
     */
154
    @RequestMapping(value = "/lms/dialer/call", method = RequestMethod.GET)
155
    @ResponseBody
156
    public ResponseEntity<?> currentCall(HttpServletRequest request,
157
                                         @RequestParam(name = "leadId") int leadId) {
158
        AuthUser me = currentUser(request);
159
        if (me == null) {
160
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
161
        }
162
        LeadCall call = leadCallRepository.selectLatestByLeadIdAndAuthId(leadId, me.getId());
163
        Map<String, Object> out = new HashMap<>();
164
        if (call == null) {
165
            out.put("found", false);
166
            return responseSender.ok(out);
167
        }
168
        out.put("found", true);
169
        out.put("leadCallId", call.getId());
170
        out.put("status", call.getStatus() != null ? call.getStatus().name() : null);
171
        out.put("durationSeconds", call.getDurationSeconds());
172
        out.put("hasRecording", call.getRecordingDocumentId() != null);
173
        return responseSender.ok(out);
174
    }
175
 
37749 vikas 176
    /** Place a call server-side (click2dial). */
37651 vikas 177
    @RequestMapping(value = "/lms/dialer/dial", method = RequestMethod.POST)
178
    @ResponseBody
179
    public ResponseEntity<?> dial(HttpServletRequest request,
180
                                  @RequestParam(name = "leadId") int leadId) {
37749 vikas 181
        return render(lmsCallService.dial(currentUser(request), leadId));
37651 vikas 182
    }
183
 
37749 vikas 184
    /** Poll one call's live state — click2dial has no event webhook, so the browser has to ask. */
37651 vikas 185
    @RequestMapping(value = "/lms/dialer/state", method = RequestMethod.GET)
186
    @ResponseBody
187
    public ResponseEntity<?> state(HttpServletRequest request,
188
                                   @RequestParam(name = "leadCallId") int leadCallId) {
37749 vikas 189
        return render(lmsCallService.state(currentUser(request), leadCallId));
37651 vikas 190
    }
191
 
192
    /** Hang up a server-placed call. */
193
    @RequestMapping(value = "/lms/dialer/hangup", method = RequestMethod.POST)
194
    @ResponseBody
195
    public ResponseEntity<?> hangup(HttpServletRequest request,
196
                                    @RequestParam(name = "leadCallId") int leadCallId) {
37749 vikas 197
        return render(lmsCallService.hangup(currentUser(request), leadCallId));
37651 vikas 198
    }
199
 
37749 vikas 200
    /** Map a service outcome onto this module's response envelope. */
201
    private ResponseEntity<?> render(LmsOutcome outcome) {
202
        switch (outcome.status) {
203
            case OK:
204
                return responseSender.ok(outcome.data);
205
            case NOT_FOUND:
206
                return responseSender.notFound(outcome.message);
207
            case UNAUTHORIZED:
208
                return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
209
            case FORBIDDEN:
210
                return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
211
            default:
212
                return responseSender.badRequest(outcome.message);
213
        }
37651 vikas 214
    }
215
 
216
    // ---- Vonage callbacks ---------------------------------------------------------------------
217
 
218
    /**
219
     * Answer webhook — returns the NCCO. Accepts GET and POST because the method is an Application
220
     * setting rather than something we control per call.
221
     *
222
     * <p>Always returns valid NCCO JSON, never an error status: a 5xx here makes Vonage retry a call
223
     * that is already ringing. An unidentifiable lead yields an empty NCCO, which hangs up cleanly.
224
     */
225
    @RequestMapping(value = "/vonage/voice/answer", method = {RequestMethod.GET, RequestMethod.POST},
226
            produces = MediaType.APPLICATION_JSON_VALUE)
227
    @ResponseBody
228
    public String answer(HttpServletRequest request,
229
                         @RequestBody(required = false) String rawBody) {
230
        try {
231
            JsonNode body = (rawBody != null && !rawBody.trim().isEmpty())
232
                    ? objectMapper.readTree(rawBody) : null;
233
 
234
            String conversationUuid = firstNonNull(
235
                    body != null ? asText(body.get("conversation_uuid")) : null,
236
                    request.getParameter("conversation_uuid"));
237
            String callUuid = firstNonNull(
238
                    body != null ? asText(body.get("uuid")) : null,
239
                    request.getParameter("uuid"));
240
 
241
            // custom_data is what serverCall({leadId}) put on the wire.
242
            int leadId = 0;
243
            if (body != null && body.get("custom_data") != null) {
244
                leadId = asInt(body.get("custom_data").get("leadId"));
245
            }
246
            if (leadId <= 0) {
247
                leadId = parseInt(request.getParameter("leadId"));
248
            }
249
 
250
            // The agent is resolved from `from_user` — the JWT `sub` Vonage authenticated, echoed back
251
            // to us. Deliberately NOT taken from custom_data: this webhook is unauthenticated as far
252
            // as the app is concerned, and an auth id supplied by the caller would let anyone
253
            // attribute a call, and its recording, to another agent. The same lookup yields the
254
            // agent's own caller ID, which is why it must be trustworthy.
255
            VonageAgent agent = resolveAgent(firstNonNull(
256
                    body != null ? asText(body.get("from_user")) : null,
257
                    request.getParameter("from_user")));
258
            int authId = agent != null ? agent.getAuthId() : 0;
259
            String agentFromNumber = agent != null ? agent.getFromNumber() : null;
260
 
261
            Lead lead = leadId > 0 ? leadRepository.selectById(leadId) : null;
262
            if (lead == null) {
263
                LOGGER.error("Vonage answer webhook could not resolve a lead (conversation {})", conversationUuid);
264
                return "[]";
265
            }
266
            // Defence in depth: the number is taken from the lead, and DND is re-checked here rather
267
            // than trusting that the pre-check ran or that nothing changed since.
268
            if (leadDndRepository.selectByMobile(lead.getLeadMobile()) != null) {
269
                LOGGER.warn("Refusing to connect lead {} — number is on the DND register", leadId);
270
                return "[]";
271
            }
272
 
273
            return vonageVoiceService.onAnswer(leadId, authId, agentFromNumber, lead.getLeadMobile(),
274
                    conversationUuid, callUuid);
275
        } catch (Exception e) {
276
            LOGGER.error("Vonage answer webhook failed", e);
277
            return "[]";
278
        }
279
    }
280
 
281
    /** Event webhook — call state transitions. Always ACKs; Vonage retries anything non-2xx. */
282
    @RequestMapping(value = "/vonage/voice/event", method = RequestMethod.POST)
283
    @ResponseBody
284
    public ResponseEntity<?> event(HttpServletRequest request,
285
                                   @RequestHeader(name = "Authorization", required = false) String authorization,
286
                                   @RequestBody(required = false) String rawBody) {
287
        if (!verifySignature(authorization, rawBody)) {
288
            LOGGER.warn("Rejected a Vonage event webhook with a bad signature");
289
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
290
        }
291
        try {
292
            vonageVoiceService.onEvent(objectMapper.readTree(rawBody));
293
        } catch (Exception e) {
294
            LOGGER.error("Vonage event webhook failed for body {}", rawBody, e);
295
        }
296
        return ResponseEntity.ok().build();
297
    }
298
 
299
    /** Recording webhook — attaches the vendor handle; archival happens on its own schedule. */
300
    @RequestMapping(value = "/vonage/voice/recording", method = RequestMethod.POST)
301
    @ResponseBody
302
    public ResponseEntity<?> recording(HttpServletRequest request,
303
                                       @RequestHeader(name = "Authorization", required = false) String authorization,
304
                                       @RequestBody(required = false) String rawBody) {
305
        if (!verifySignature(authorization, rawBody)) {
306
            LOGGER.warn("Rejected a Vonage recording webhook with a bad signature");
307
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
308
        }
309
        try {
310
            vonageVoiceService.onRecording(objectMapper.readTree(rawBody));
311
        } catch (Exception e) {
312
            LOGGER.error("Vonage recording webhook failed for body {}", rawBody, e);
313
        }
314
        return ResponseEntity.ok().build();
315
    }
316
 
317
    /**
318
     * Vonage signed webhooks carry an HS256 JWT in {@code Authorization: Bearer}, signed with the
319
     * account signature secret, whose {@code payload_hash} claim is the SHA-256 of the request body.
320
     * Verifying both the signature and the hash is what stops a replayed body.
321
     *
322
     * <p>Returns true when no secret is configured — deliberate, so a new environment is not bricked
323
     * before the secret lands, and the same opt-in shape the Pinelabs webhook already uses.
324
     */
325
    private boolean verifySignature(String authorization, String rawBody) {
326
        if (webhookSignatureSecret == null || webhookSignatureSecret.trim().isEmpty()) {
327
            return true;
328
        }
329
        if (authorization == null || !authorization.trim().toLowerCase().startsWith("bearer ")) {
330
            return false;
331
        }
332
        try {
333
            String jwt = authorization.trim().substring(7).trim();
334
            String[] parts = jwt.split("\\.");
335
            if (parts.length != 3) {
336
                return false;
337
            }
338
            String signingInput = parts[0] + "." + parts[1];
339
            Mac mac = Mac.getInstance("HmacSHA256");
340
            mac.init(new SecretKeySpec(webhookSignatureSecret.trim().getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
341
            byte[] expected = mac.doFinal(signingInput.getBytes(StandardCharsets.UTF_8));
342
            byte[] actual = Base64.getUrlDecoder().decode(parts[2]);
343
            if (!MessageDigest.isEqual(expected, actual)) {
344
                return false;
345
            }
346
 
347
            JsonNode claims = objectMapper.readTree(Base64.getUrlDecoder().decode(parts[1]));
348
            String payloadHash = asText(claims.get("payload_hash"));
349
            if (payloadHash == null) {
350
                // Signature alone is valid; a hash-less token cannot bind to this body.
351
                return true;
352
            }
353
            byte[] digest = MessageDigest.getInstance("SHA-256")
354
                    .digest((rawBody == null ? "" : rawBody).getBytes(StandardCharsets.UTF_8));
355
            return MessageDigest.isEqual(hex(digest).getBytes(StandardCharsets.UTF_8),
356
                    payloadHash.toLowerCase().getBytes(StandardCharsets.UTF_8));
357
        } catch (Exception e) {
358
            LOGGER.warn("Could not verify a Vonage webhook signature", e);
359
            return false;
360
        }
361
    }
362
 
363
    private String hex(byte[] bytes) {
364
        StringBuilder sb = new StringBuilder(bytes.length * 2);
365
        for (byte b : bytes) {
366
            sb.append(Character.forDigit((b >> 4) & 0xF, 16));
367
            sb.append(Character.forDigit(b & 0xF, 16));
368
        }
369
        return sb.toString();
370
    }
371
 
372
    // ---- helpers ------------------------------------------------------------------------------
373
 
374
    private String asText(JsonNode node) {
375
        if (node == null || node.isNull()) {
376
            return null;
377
        }
378
        String s = node.asText();
379
        return (s == null || s.trim().isEmpty()) ? null : s.trim();
380
    }
381
 
382
    private int asInt(JsonNode node) {
383
        return (node == null || node.isNull()) ? 0 : node.asInt(0);
384
    }
385
 
386
    private int parseInt(String s) {
387
        try {
388
            return (s == null || s.trim().isEmpty()) ? 0 : Integer.parseInt(s.trim());
389
        } catch (NumberFormatException e) {
390
            return 0;
391
        }
392
    }
393
 
394
    private String firstNonNull(String a, String b) {
395
        return a != null ? a : b;
396
    }
397
 
398
    /**
399
     * Vonage user name -> the agent mapping, which carries both our auth id and that agent's own
400
     * caller ID. Returns null when unmapped, leaving the call unattributed and falling back to the
401
     * default caller ID rather than attributing it to the wrong person — an orphan call is
402
     * recoverable, a misattributed recording is not.
403
     */
404
    private VonageAgent resolveAgent(String vonageUserName) {
405
        if (vonageUserName == null || vonageUserName.trim().isEmpty()) {
406
            LOGGER.warn("Vonage answer webhook carried no from_user — call will be unattributed");
407
            return null;
408
        }
409
        VonageAgent agent = vonageAgentRepository.selectByVonageUserName(vonageUserName);
410
        if (agent == null) {
411
            LOGGER.warn("No vonage_agent mapping for Vonage user '{}'", vonageUserName);
412
        }
413
        return agent;
414
    }
415
 
416
 
417
 
418
    private AuthUser currentUser(HttpServletRequest request) {
419
        try {
420
            LoginDetails loginDetails = cookiesProcessor.getCookiesObject(request);
421
            if (loginDetails != null && loginDetails.getEmailId() != null) {
422
                return authRepository.selectByEmailOrMobile(loginDetails.getEmailId());
423
            }
424
        } catch (Exception e) {
425
            LOGGER.warn("Could not resolve the user for a dialer request", e);
426
        }
427
        return null;
428
    }
429
}