-
-
Notifications
You must be signed in to change notification settings - Fork 350
/
Bot.java
330 lines (297 loc) · 12.8 KB
/
Bot.java
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
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
package me.ramswaroop.jbot.core.facebook;
import me.ramswaroop.jbot.core.common.BaseBot;
import me.ramswaroop.jbot.core.common.Controller;
import me.ramswaroop.jbot.core.common.EventType;
import me.ramswaroop.jbot.core.facebook.models.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestTemplate;
import javax.annotation.PostConstruct;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Queue;
import java.util.regex.Matcher;
/**
* @author ramswaroop
* @version 11/09/2016
*/
public abstract class Bot extends BaseBot {
private static final Logger logger = LoggerFactory.getLogger(Bot.class);
private String fbSendUrl;
private String fbMessengerProfileUrl;
@Autowired
protected RestTemplate restTemplate;
@Autowired
protected FbApiEndpoints fbApiEndpoints;
@PostConstruct
private void constructFbSendUrl() {
fbSendUrl = fbApiEndpoints.getFbSendUrl().replace("{PAGE_ACCESS_TOKEN}", getPageAccessToken());
fbMessengerProfileUrl = fbApiEndpoints.getFbMessengerProfileUrl().replace("{PAGE_ACCESS_TOKEN}",
getPageAccessToken());
}
/**
* Class extending this must implement this as it's
* required for Send API.
*
* @return facebook page access token
*/
public abstract String getPageAccessToken();
/**
* @param mode
* @param verifyToken
* @param challenge
* @return if verify token is valid then 200 OK with challenge in the body or else forbidden error
*/
@GetMapping("/webhook")
public final ResponseEntity setupWebhookVerification(@RequestParam("hub.mode") String mode,
@RequestParam("hub.verify_token") String verifyToken,
@RequestParam("hub.challenge") String challenge) {
if (EventType.SUBSCRIBE.name().equalsIgnoreCase(mode) && getToken().equals(verifyToken)) {
return ResponseEntity.ok(challenge);
} else {
return new ResponseEntity<>(HttpStatus.FORBIDDEN);
}
}
/**
* Add webhook endpoint
*
* @param callback
* @return 200 OK response
*/
@ResponseBody
@PostMapping("/webhook")
public final ResponseEntity setupWebhookEndpoint(@RequestBody Callback callback) {
try {
// Checks this is an event from a page subscription
if (!callback.getObject().equals("page")) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
logger.debug("Callback from fb: {}", callback);
for (Entry entry : callback.getEntry()) {
if (entry.getMessaging() != null) {
for (Event event : entry.getMessaging()) {
if (event.getMessage() != null) {
if (event.getMessage().isEcho() != null &&
event.getMessage().isEcho()) {
event.setType(EventType.MESSAGE_ECHO);
} else if (event.getMessage().getQuickReply() != null) {
event.setType(EventType.QUICK_REPLY);
} else {
event.setType(EventType.MESSAGE);
// send typing on indicator to create a conversational experience
sendTypingOnIndicator(event.getSender());
}
} else if (event.getDelivery() != null) {
event.setType(EventType.MESSAGE_DELIVERED);
} else if (event.getRead() != null) {
event.setType(EventType.MESSAGE_READ);
} else if (event.getPostback() != null) {
event.setType(EventType.POSTBACK);
} else if (event.getOptin() != null) {
event.setType(EventType.OPT_IN);
} else if (event.getReferral() != null) {
event.setType(EventType.REFERRAL);
} else if (event.getAccountLinking() != null) {
event.setType(EventType.ACCOUNT_LINKING);
} else {
logger.debug("Callback/Event type not supported: {}", event);
return ResponseEntity.ok("Callback not supported yet!");
}
if (isConversationOn(event)) {
invokeChainedMethod(event);
} else {
invokeMethods(event);
}
}
}
}
} catch (Exception e) {
logger.error("Error in fb webhook: Callback: {} \nException: ", callback.toString(), e);
}
// fb advises to send a 200 response within 20 secs
return ResponseEntity.ok("EVENT_RECEIVED");
}
private void sendTypingOnIndicator(User recipient) {
restTemplate.postForEntity(fbSendUrl,
new Event().setRecipient(recipient).setSenderAction("typing_on"), Response.class);
}
private void sendTypingOffIndicator(User recipient) {
restTemplate.postForEntity(fbSendUrl,
new Event().setRecipient(recipient).setSenderAction("typing_off"), Response.class);
}
protected final ResponseEntity<String> reply(Event event) {
sendTypingOffIndicator(event.getRecipient());
logger.debug("Send message: {}", event.toString());
try {
return restTemplate.postForEntity(fbSendUrl, event, String.class);
} catch (HttpClientErrorException e) {
logger.error("Send message error: Response body: {} \nException: ", e.getResponseBodyAsString(), e);
return new ResponseEntity<>(e.getResponseBodyAsString(), e.getStatusCode());
}
}
protected ResponseEntity<String> reply(Event event, String text) {
Event response = new Event()
.setMessagingType("RESPONSE")
.setRecipient(event.getSender())
.setMessage(new Message().setText(text));
return reply(response);
}
protected ResponseEntity<String> reply(Event event, Message message) {
Event response = new Event()
.setMessagingType("RESPONSE")
.setRecipient(event.getSender())
.setMessage(message);
return reply(response);
}
/**
* Call this method with a {@code payload} to set the "Get Started" button. A user sees this button
* when it first starts a conversation with the bot.
* <p>
* See https://developers.facebook.com/docs/messenger-platform/discovery/welcome-screen for more.
*
* @param payload for "Get Started" button
* @return response from facebook
*/
protected final ResponseEntity<Response> setGetStartedButton(String payload) {
Event event = new Event().setGetStarted(new Postback().setPayload(payload));
return restTemplate.postForEntity(fbMessengerProfileUrl, event, Response.class);
}
/**
* Call this method to set the "Greeting Text". A user sees this when it opens up the chat window for the
* first time. You can specify different messages for different locales. Therefore, this method receives an
* array of {@code greeting}.
* <p>
* See https://developers.facebook.com/docs/messenger-platform/discovery/welcome-screen for more.
*
* @param greeting an array of Payload consisting of text and locale
* @return response from facebook
*/
protected final ResponseEntity<Response> setGreetingText(Payload[] greeting) {
Event event = new Event().setGreeting(greeting);
return restTemplate.postForEntity(fbMessengerProfileUrl, event, Response.class);
}
/**
* Invoke this method to make the bot subscribe to a page after which
* your users can interact with your page or in other words, the bot.
* <p>
* NOTE: It seems Fb now allows the bot to subscribe to a page via the
* ui. See https://developers.facebook.com/docs/messenger-platform/getting-started/app-setup
*/
@PostMapping("/subscribe")
public final void subscribeAppToPage() {
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.set("access_token", getPageAccessToken());
restTemplate.postForEntity(fbApiEndpoints.getSubscribeUrl(), params, String.class);
}
/**
* Call this method to start a conversation.
*
* @param event received from facebook
*/
protected final void startConversation(Event event, String methodName) {
startConversation(event.getSender().getId(), methodName);
}
/**
* Call this method to jump to the next method in a conversation.
*
* @param event received from facebook
*/
protected final void nextConversation(Event event) {
nextConversation(event.getSender().getId());
}
/**
* Call this method to stop the end the conversation.
*
* @param event received from facebook
*/
protected final void stopConversation(Event event) {
stopConversation(event.getSender().getId());
}
/**
* Check whether a conversation is up in a particular slack channel.
*
* @param event received from facebook
* @return true if a conversation is on, false otherwise.
*/
protected final boolean isConversationOn(Event event) {
return isConversationOn(event.getSender().getId());
}
/**
* Invoke the methods with matching {@link Controller#events()}
* and {@link Controller#pattern()} in events received from Slack/Facebook.
*
* @param event received from facebook
*/
private void invokeMethods(Event event) {
try {
List<MethodWrapper> methodWrappers = eventToMethodsMap.get(event.getType().name().toUpperCase());
if (methodWrappers == null) return;
methodWrappers = new ArrayList<>(methodWrappers);
MethodWrapper matchedMethod =
getMethodWithMatchingPatternAndFilterUnmatchedMethods(getPatternFromEventType(event), methodWrappers);
if (matchedMethod != null) {
methodWrappers = new ArrayList<>();
methodWrappers.add(matchedMethod);
}
for (MethodWrapper methodWrapper : methodWrappers) {
Method method = methodWrapper.getMethod();
if (Arrays.asList(method.getParameterTypes()).contains(Matcher.class)) {
method.invoke(this, event, methodWrapper.getMatcher());
} else {
method.invoke(this, event);
}
}
} catch (Exception e) {
logger.error("Error invoking controller: ", e);
}
}
/**
* Invoke the appropriate method in a conversation.
*
* @param event received from facebook
*/
private void invokeChainedMethod(Event event) {
Queue<MethodWrapper> queue = conversationQueueMap.get(event.getSender().getId());
if (queue != null && !queue.isEmpty()) {
MethodWrapper methodWrapper = queue.peek();
try {
EventType[] eventTypes = methodWrapper.getMethod().getAnnotation(Controller.class).events();
for (EventType eventType : eventTypes) {
if (eventType.name().equalsIgnoreCase(event.getType().name())) {
methodWrapper.getMethod().invoke(this, event);
return;
}
}
} catch (Exception e) {
logger.error("Error invoking chained method: ", e);
}
}
}
/**
* Match the pattern with different attributes based on the event type.
*
* @param event received from facebook
* @return the pattern string
*/
private String getPatternFromEventType(Event event) {
switch (event.getType()) {
case MESSAGE:
return event.getMessage().getText();
case QUICK_REPLY:
return event.getMessage().getQuickReply().getPayload();
case POSTBACK:
return event.getPostback().getPayload();
default:
return event.getMessage().getText();
}
}
}