forked from bots-go-framework/bots-fw
/
driver.go
316 lines (283 loc) · 11.7 KB
/
driver.go
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
package bots
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"runtime/debug"
"strings"
"time"
"context"
"github.com/julienschmidt/httprouter"
"github.com/pkg/errors"
"github.com/strongo/app"
"github.com/strongo/gamp"
"github.com/strongo/log"
)
// ErrorIcon is used to report errors to user
var ErrorIcon = "🚨"
// WebhookDriver is doing initial request & final response processing.
// That includes logging, creating input messages in a general format, sending response.
type WebhookDriver interface {
RegisterWebhookHandlers(httpRouter *httprouter.Router, pathPrefix string, webhookHandlers ...WebhookHandler)
HandleWebhook(w http.ResponseWriter, r *http.Request, webhookHandler WebhookHandler)
}
// BotDriver keeps information about bots and map requests to appropriate handlers
type BotDriver struct {
Analytics AnalyticsSettings
botHost BotHost
appContext BotAppContext
//router *WebhooksRouter
panicTextFooter string
}
var _ WebhookDriver = (*BotDriver)(nil) // Ensure BotDriver is implementing interface WebhookDriver
// AnalyticsSettings keeps data for Google Analytics
type AnalyticsSettings struct {
GaTrackingID string // TODO: Refactor to list of analytics providers
Enabled func(r *http.Request) bool
}
// NewBotDriver registers new bot driver (TODO: describe why we need it)
func NewBotDriver(gaSettings AnalyticsSettings, appContext BotAppContext, host BotHost, panicTextFooter string) WebhookDriver {
if appContext.AppUserEntityKind() == "" {
panic("appContext.AppUserEntityKind() is empty")
}
if host == nil {
panic("BotHost == nil")
}
return BotDriver{
Analytics: gaSettings,
appContext: appContext,
botHost: host,
//router: router,
panicTextFooter: panicTextFooter,
}
}
// RegisterWebhookHandlers adds handlers to a bot driver
func (d BotDriver) RegisterWebhookHandlers(httpRouter *httprouter.Router, pathPrefix string, webhookHandlers ...WebhookHandler) {
for _, webhookHandler := range webhookHandlers {
webhookHandler.RegisterHttpHandlers(d, d.botHost, httpRouter, pathPrefix)
}
}
// HandleWebhook takes and HTTP request and process it
func (d BotDriver) HandleWebhook(w http.ResponseWriter, r *http.Request, webhookHandler WebhookHandler) {
started := time.Now()
c := d.botHost.Context(r)
//log.Debugf(c, "BotDriver.HandleWebhook()")
if w == nil {
panic("Parameter 'w http.ResponseWriter' is nil")
}
if r == nil {
panic("Parameter 'r *http.Request' is nil")
}
if webhookHandler == nil {
panic("Parameter 'webhookHandler WebhookHandler' is nil")
}
botContext, entriesWithInputs, err := webhookHandler.GetBotContextAndInputs(c, r)
if d.invalidContextOrInputs(c, w, r, botContext, entriesWithInputs, err) {
return
}
log.Debugf(c, "BotDriver.HandleWebhook() => botCode=%v, len(entriesWithInputs): %d", botContext.BotSettings.Code, len(entriesWithInputs))
var (
whc WebhookContext // TODO: How do deal with Facebook multiple entries per request?
measurementSender *gamp.BufferedClient
)
var sendStats bool
{ // Initiate Google Analytics Measurement API client
if d.Analytics.Enabled == nil {
sendStats = botContext.BotSettings.Env == strongo.EnvProduction
} else {
if sendStats = d.Analytics.Enabled(r); !sendStats {
}
//log.Debugf(c, "d.AnalyticsSettings.Enabled != nil, sendStats: %v", sendStats)
}
if sendStats {
botHost := botContext.BotHost
measurementSender = gamp.NewBufferedClient("", botHost.GetHTTPClient(c), func(err error) {
log.Errorf(c, "Failed to log to GA: %v", err)
})
} else {
envName, ok := strongo.EnvironmentNames[botContext.BotSettings.Env]
if !ok {
envName = "UNKNOWN"
}
log.Debugf(c, "d.Analytics.Enabled=%v, botContext.BotSettings.Env=%v:%v, sendStats=%v",
d.Analytics.Enabled, botContext.BotSettings.Env, envName, sendStats)
}
}
defer func() {
log.Debugf(c, "driver.deferred(recover) - checking for panic & flush GA")
if sendStats {
if d.Analytics.GaTrackingID == "" {
log.Warningf(c, "driver.Analytics.GaTrackingID is not set")
} else {
timing := gamp.NewTiming(time.Now().Sub(started))
timing.TrackingID = d.Analytics.GaTrackingID // TODO: What to do if different FB bots have different Tacking IDs? Can FB handler get messages for different bots? If not (what probably is the case) can we get ID from bot settings instead of driver?
measurementSender.Queue(timing)
}
}
reportError := func(recovered interface{}) {
messageText := fmt.Sprintf("Server error (panic): %v\n\n%v", recovered, d.panicTextFooter)
log.Criticalf(c, "Panic recovered: %s\n%s", messageText, debug.Stack())
if sendStats { // Zero if GA is disabled
d.reportErrorToGA(whc, measurementSender, messageText)
}
if whc != nil {
if chatID, err := whc.BotChatID(); err == nil && chatID != "" {
if responder := whc.Responder(); responder != nil {
if _, err := responder.SendMessage(c, whc.NewMessage(ErrorIcon+" "+messageText), BotAPISendMessageOverResponse); err != nil {
log.Errorf(c, errors.WithMessage(err, "failed to report error to user").Error())
}
}
}
}
}
if recovered := recover(); recovered != nil {
reportError(recovered)
} else if sendStats {
log.Debugf(c, "Flushing GA...")
if err = measurementSender.Flush(); err != nil {
log.Warningf(c, "Failed to flush to GA: %v", err)
} else {
log.Debugf(c, "Sent to GA: %v items", measurementSender.QueueDepth())
}
} else {
log.Debugf(c, "GA: sendStats=false")
}
}()
if err != nil {
log.Errorf(c, "Failed to create new WebhookContext: %v", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
botCoreStores := webhookHandler.CreateBotCoreStores(d.appContext, r)
defer func() {
if whc != nil { // TODO: How do deal with Facebook multiple entries per request?
//log.Debugf(c, "Closing BotChatStore...")
//chatEntity := whc.ChatEntity()
//if chatEntity != nil && chatEntity.GetPreferredLanguage() == "" {
// chatEntity.SetPreferredLanguage(whc.Locale().Code5)
//}
if err := botCoreStores.BotChatStore.Close(c); err != nil {
log.Errorf(c, "Failed to close BotChatStore: %v", err)
var m MessageFromBot
m.Text = ErrorIcon + " ERROR: Service is temporary unavailable. Probably a global outage, status at https://status.cloud.google.com/"
if _, err := whc.Responder().SendMessage(c, m, BotAPISendMessageOverHTTPS); err != nil {
log.Errorf(c, "Failed to report outage: %v", err)
}
}
}
}()
for _, entryWithInputs := range entriesWithInputs {
for i, input := range entryWithInputs.Inputs {
if input == nil {
panic(fmt.Sprintf("entryWithInputs.Inputs[%d] == nil", i))
}
d.logInput(c, i, input)
whc = webhookHandler.CreateWebhookContext(d.appContext, r, *botContext, input, botCoreStores, measurementSender)
responder := webhookHandler.GetResponder(w, whc) // TODO: Move inside webhookHandler.CreateWebhookContext()?
botContext.BotSettings.Router.Dispatch(webhookHandler, responder, whc)
}
}
}
func (BotDriver) invalidContextOrInputs(c context.Context, w http.ResponseWriter, r *http.Request, botContext *BotContext, entriesWithInputs []EntryInputs, err error) bool {
if err != nil {
if _, ok := err.(ErrAuthFailed); ok {
log.Warningf(c, "Auth failed: %v", err)
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
} else if errors.Cause(err) == ErrNotImplemented {
log.Debugf(c, err.Error())
w.WriteHeader(http.StatusNoContent)
//http.Error(w, "", http.StatusOK) // TODO: Decide how to handle it properly, return http.StatusNotImplemented?
} else if _, ok := err.(*json.SyntaxError); ok {
log.Debugf(c, errors.Wrap(err, "Request body is not valid JSON").Error())
http.Error(w, err.Error(), http.StatusBadRequest)
} else {
log.Errorf(c, "Failed to call webhookHandler.GetBotContextAndInputs(router): %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return true
} else if botContext == nil {
if entriesWithInputs == nil {
log.Warningf(c, "botContext == nil, entriesWithInputs == nil")
} else if len(entriesWithInputs) == 0 {
log.Warningf(c, "botContext == nil, len(entriesWithInputs) == 0")
} else {
log.Errorf(c, "botContext == nil, len(entriesWithInputs) == %v", len(entriesWithInputs))
}
return true
} else if entriesWithInputs == nil {
log.Errorf(c, "entriesWithInputs == nil")
return true
}
switch botContext.BotSettings.Env {
case strongo.EnvLocal:
if r.Host != "localhost" && !strings.HasSuffix(r.Host, ".ngrok.io") {
log.Warningf(c, "whc.GetBotSettings().Mode == Local, host: %v", r.Host)
w.WriteHeader(http.StatusBadRequest)
return true
}
case strongo.EnvProduction:
if r.Host == "localhost" || strings.HasSuffix(r.Host, ".ngrok.io") {
log.Warningf(c, "whc.GetBotSettings().Mode == Production, host: %v", r.Host)
w.WriteHeader(http.StatusBadRequest)
return true
}
}
return false
}
func (BotDriver) reportErrorToGA(whc WebhookContext, measurementSender *gamp.BufferedClient, messageText string) {
ga := whc.GA()
gaMessage := gamp.NewException(messageText, true)
if whc != nil { // TODO: How do deal with Facebook multiple entries per request?
gaMessage.Common = ga.GaCommon()
} else {
gaMessage.Common.ClientID = "c7ea15eb-3333-4d47-a002-9d1a14996371" // TODO: move hardcoded value
gaMessage.Common.DataSource = "bot-" + whc.BotPlatform().ID()
}
c := whc.Context()
if err := ga.Queue(gaMessage); err != nil {
log.Errorf(c, "Failed to queue exception message for GA: %v", err)
} else {
log.Debugf(c, "Exception message queued for GA.")
}
if err := measurementSender.Flush(); err != nil {
log.Errorf(c, "Failed to flush GA buffer after exception: %v", err)
} else {
log.Debugf(c, "GA buffer flushed after exception")
}
}
func (BotDriver) logInput(c context.Context, i int, input WebhookInput) {
switch input.(type) {
case WebhookTextMessage:
sender := input.GetSender()
log.Debugf(c, "BotUser#%v(%v %v) => text: %v", sender.GetID(), sender.GetFirstName(), sender.GetLastName(), input.(WebhookTextMessage).Text())
case WebhookNewChatMembersMessage:
newMembers := input.(WebhookNewChatMembersMessage).NewChatMembers()
var b bytes.Buffer
b.WriteString(fmt.Sprintf("NewChatMembers: %d", len(newMembers)))
for i, member := range newMembers {
b.WriteString(fmt.Sprintf("\t%d: (%v) - %v %v", i+1, member.GetUserName(), member.GetFirstName(), member.GetLastName()))
}
log.Debugf(c, b.String())
case WebhookContactMessage:
sender := input.GetSender()
contactMessage := input.(WebhookContactMessage)
log.Debugf(c, "BotUser#%v(%v %v) => Contact(name: %v|%v, phone number: %v)", sender.GetID(), sender.GetFirstName(), sender.GetLastName(), contactMessage.FirstName(), contactMessage.LastName(), contactMessage.PhoneNumber())
case WebhookCallbackQuery:
callbackQuery := input.(WebhookCallbackQuery)
callbackData := callbackQuery.GetData()
sender := input.GetSender()
log.Debugf(c, "BotUser#%v(%v %v) => callback: %v", sender.GetID(), sender.GetFirstName(), sender.GetLastName(), callbackData)
case WebhookInlineQuery:
sender := input.GetSender()
log.Debugf(c, "BotUser#%v(%v %v) => inline query: %v", sender.GetID(), sender.GetFirstName(), sender.GetLastName(), input.(WebhookInlineQuery).GetQuery())
case WebhookChosenInlineResult:
sender := input.GetSender()
log.Debugf(c, "BotUser#%v(%v %v) => chosen InlineMessageID: %v", sender.GetID(), sender.GetFirstName(), sender.GetLastName(), input.(WebhookChosenInlineResult).GetInlineMessageID())
case WebhookReferralMessage:
sender := input.GetSender()
log.Debugf(c, "BotUser#%v(%v %v) => text: %v", sender.GetID(), sender.GetFirstName(), sender.GetLastName(), input.(WebhookTextMessage).Text())
default:
log.Warningf(c, "Unhandled input[%v] type: %T", i, input)
}
}