-
Notifications
You must be signed in to change notification settings - Fork 1
/
webhook.go
344 lines (284 loc) · 10 KB
/
webhook.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
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
package client
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
)
const (
defaultLeeway = 5 * time.Minute
signatureHeader = "X-GradientLabs-Signature"
)
var (
// ErrInvalidWebhookSignature is returned when the authenticity of the webhook
// could not be verified using its signature. You should respond with an HTTP
// 401 status code.
ErrInvalidWebhookSignature = fmt.Errorf("%s header is invalid", signatureHeader)
// ErrUnknownWebhookType is returned when the webhook contained an event of an
// unknwon type. You should generally log these and return an HTTP 200 status
// code.
ErrUnknownWebhookType = errors.New("unknown webhook type")
)
// WebhookType indicates the type of webhook event.
type WebhookType string
const (
// WebhookTypeAgentMessage indicates the agent wants to send the customer a
// message.
WebhookTypeAgentMessage WebhookType = "agent.message"
// WebhookTypeConversationHandOff indicates the agent is escalating or handing
// the conversation off to a human agent.
WebhookTypeConversationHandOff WebhookType = "conversation.hand_off"
// WebhookTypeConversationFinished indicates the agent has concluded the
// conversation with the customer and you should close any corresponding
// ticket, etc.
WebhookTypeConversationFinished WebhookType = "conversation.finished"
// WebhookTypeAction indicates that the agent needs an action to
// be executed (e.g., while following a procedure)
WebhookTypeActionExecute WebhookType = "action.execute"
// WebhookTypeResourcePull indicates that the agent wants to pull a resource.
WebhookTypeResourcePull WebhookType = "resource.pull"
)
// Webhook is an event delivered to your webhook endpoint.
type Webhook struct {
// ID uniquely identifies this event.
ID string `json:"id"`
// Type indicates the type of event.
Type WebhookType `json:"type"`
// SequenceNumber can be used to establish an order of webhook events.
// For more information, see: https://api-docs.gradient-labs.ai/#sequence-numbers
SequenceNumber int `json:"sequence_number"`
// Timestamp is the time at which this event was generated.
Timestamp time.Time `json:"timestamp"`
// Data contains the event data. Use the helper methods (e.g.
// Webhook.AgentMessage) to access it.
Data any `json:"-"`
}
// AgentMessage returns the data for an `agent.message` event.
func (w Webhook) AgentMessage() (*AgentMessageEvent, bool) {
e, ok := w.Data.(*AgentMessageEvent)
return e, ok
}
// ConversationHandOff returns the data for an `conversation.hand_off` event.
func (w Webhook) ConversationHandOff() (*ConversationHandOffEvent, bool) {
e, ok := w.Data.(*ConversationHandOffEvent)
return e, ok
}
// ConversationFinished returns the data for an `conversation.finished` event.
func (w Webhook) ConversationFinished() (*ConversationFinishedEvent, bool) {
e, ok := w.Data.(*ConversationFinishedEvent)
return e, ok
}
// ActionExecute returns the data for an `action.execute` event.
func (w Webhook) ActionExecute() (*ActionExecuteEvent, bool) {
e, ok := w.Data.(*ActionExecuteEvent)
return e, ok
}
// ResourcePull returns the data for an `resource.pull` event.
func (w Webhook) ResourcePull() (*ResourcePullEvent, bool) {
e, ok := w.Data.(*ResourcePullEvent)
return e, ok
}
// AgentMessageEvent contains the data for an `agent.message` webhook event.
type AgentMessageEvent struct {
// Conversation contains the details of the conversation the event relates to.
Conversation WebhookConversation `json:"conversation"`
// Body contains the text of the message the agent wants to send.
Body string `json:"body"`
}
// ConversationHandOffEvent contains the data for a `conversation.hand_off` event.
type ConversationHandOffEvent struct {
// Conversation contains the details of the conversation the event relates to.
Conversation WebhookConversation `json:"conversation"`
// Target defines where the agent wants to hand this conversation to.
Target string `json:"target,omitempty"`
// Reason is the code that describes why the agent wants to hand off this
// conversation.
Reason string `json:"reason_code"`
// Description is a human-legible description of the Reason code.
Description string `json:"reason"`
// Intent is the most recent intent that was classified from the customer's
// conversation, if any.
Intent string `json:"intent,omitempty"`
}
// ConversationFinishedEvent contains the data for a `conversation.finished` event.
type ConversationFinishedEvent struct {
// Conversation contains the details of the conversation the event relates to.
Conversation WebhookConversation `json:"conversation"`
// Reason is the code that describes why the agent wants to finish this
// conversation.
Reason string `json:"reason_code,omitempty"`
// Intent is the most recent intent that was classified from the customer's
// conversation, if any.
Intent string `json:"intent,omitempty"`
}
// ActionExecuteEvent contains the data for a `action.execute` event.
type ActionExecuteEvent struct {
// Action is the name of the action to execute
Action string `json:"action"`
// Params are the arguments to execute the action with.
Params json.RawMessage `json:"params"`
// Conversation contains the details of the conversation the event relates to.
Conversation WebhookConversation `json:"conversation"`
}
// ResourcePullEvent contains the data for a `resource.pull` event.
type ResourcePullEvent struct {
// ResourceType is the name of the resource type the agent wants to pull.
ResourceType string `json:"resource_type"`
// Conversation contains the details of the conversation the event relates to.
Conversation WebhookConversation `json:"conversation"`
}
// WebhookConversation contains the details of the conversation the webhook
// relates to.
type WebhookConversation struct {
// ID is chosen unique identifier for this conversation.
ID string `json:"id"`
// CustomerID is your chosen identifier for the customer.
CustomerID string `json:"customer_id"`
// Metadata you attached to the conversation with Client.StartConversation.
Metadata any `json:"metadata"`
}
// ParseWebhook parses the request, verifies its signature, and returns the
// webhook data.
func (c *Client) ParseWebhook(req *http.Request) (*Webhook, error) {
if err := c.VerifyWebhookRequest(req); err != nil {
return nil, err
}
var payload struct {
Webhook
Data json.RawMessage `json:"data"`
}
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
return nil, err
}
switch payload.Type {
case WebhookTypeAgentMessage:
var am AgentMessageEvent
if err := json.Unmarshal(payload.Data, &am); err != nil {
return nil, err
}
payload.Webhook.Data = &am
case WebhookTypeConversationHandOff:
var ho ConversationHandOffEvent
if err := json.Unmarshal(payload.Data, &ho); err != nil {
return nil, err
}
payload.Webhook.Data = &ho
case WebhookTypeConversationFinished:
var fin ConversationFinishedEvent
if err := json.Unmarshal(payload.Data, &fin); err != nil {
return nil, err
}
payload.Webhook.Data = &fin
case WebhookTypeActionExecute:
var act ActionExecuteEvent
if err := json.Unmarshal(payload.Data, &act); err != nil {
return nil, err
}
payload.Webhook.Data = &act
case WebhookTypeResourcePull:
var pull ResourcePullEvent
if err := json.Unmarshal(payload.Data, &pull); err != nil {
return nil, err
}
payload.Webhook.Data = &pull
default:
return nil, fmt.Errorf("unknown webhook event type received: %q", payload.Type)
}
return &payload.Webhook, nil
}
// VerifyWebhookRequest verifies the authenticity of the given request using
// its signature header. You do not need to call it if you're already using
// Client.ParseWebhook.
func (c *Client) VerifyWebhookRequest(req *http.Request) error {
return c.webhookVerifier.VerifyRequest(req)
}
// WebhookVerifier verifies the authenticity of requests to your webhook
// endpoint using the X-GradientLabs-Signature header.
type WebhookVerifier struct {
secret []byte
leeway time.Duration
}
// VerifyRequest verifies the authenticity of the given request using its
// signature header.
func (v WebhookVerifier) VerifyRequest(req *http.Request) error {
body, err := io.ReadAll(req.Body)
if err != nil {
return err
}
if err := req.Body.Close(); err != nil {
return err
}
req.Body = io.NopCloser(bytes.NewReader(body))
return v.VerifySignature(body, req.Header.Get(signatureHeader))
}
// VerifySignature is a lower level variant of VerifyRequest
func (v WebhookVerifier) VerifySignature(body []byte, sig string) error {
ts, sigs, err := v.parseHeader(sig)
if err != nil {
return ErrInvalidWebhookSignature
}
if time.Since(ts).Abs() > v.leeway {
return ErrInvalidWebhookSignature
}
expected, err := v.computeSignature(ts, body)
if err != nil {
return err
}
for _, sig := range sigs {
if hmac.Equal(expected, sig) {
return nil
}
}
return ErrInvalidWebhookSignature
}
func (v WebhookVerifier) computeSignature(ts time.Time, body []byte) ([]byte, error) {
mac := hmac.New(sha256.New, v.secret)
if _, err := io.WriteString(mac, strconv.Itoa(int(ts.Unix()))); err != nil {
return nil, err
}
if _, err := io.WriteString(mac, "."); err != nil {
return nil, err
}
if _, err := mac.Write(body); err != nil {
return nil, err
}
return mac.Sum(nil), nil
}
func (v WebhookVerifier) parseHeader(header string) (time.Time, [][]byte, error) {
var (
ts time.Time
sigs [][]byte
)
for _, pair := range strings.Split(header, ",") {
parts := strings.SplitN(pair, "=", 2)
if len(parts) != 2 {
return ts, nil, fmt.Errorf("invalid %s header", signatureHeader)
}
switch parts[0] {
case "t":
unix, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return ts, nil, fmt.Errorf("invalid timestamp component %s", parts[1])
}
ts = time.Unix(unix, 0)
case "v1":
sig, err := hex.DecodeString(parts[1])
if err != nil {
return ts, nil, errors.New("invalid signature")
}
sigs = append(sigs, sig)
}
}
if ts.IsZero() {
return ts, nil, fmt.Errorf("%s header contains no timestamp component", signatureHeader)
}
return ts, sigs, nil
}