Contact
CoCalc Logo Icon
StoreFeaturesDocsShareSupport News AboutSign UpSign In
| Download
Views: 39539
1
# Handling support tickets for users -- currently a Zendesk wrapper.
2
# (c) 2016, SageMath, Inc.
3
# License: GPLv3
4
5
###
6
Support Tickets, built on top of Zendesk's Core API
7
8
Docs:
9
10
https://developer.zendesk.com/rest_api/docs/core/introduction
11
https://github.com/blakmatrix/node-zendesk
12
###
13
14
# if true, no real tickets are created
15
DEBUG = process.env.SMC_TEST_ZENDESK ? false
16
17
async = require('async')
18
fs = require('fs')
19
path = require('path')
20
misc = require('smc-util/misc')
21
theme = require('smc-util/theme')
22
_ = require('underscore')
23
{defaults, required} = misc
24
25
winston = require 'winston'
26
winston.remove(winston.transports.Console)
27
28
SMC_TEST = process.env.SMC_TEST
29
if not SMC_TEST
30
winston.add(winston.transports.Console, {level: 'debug', timestamp:true, colorize:true})
31
32
zendesk_password_filename = ->
33
return (process.env.SMC_ROOT ? '.') + '/data/secrets/zendesk'
34
35
fixSessions = (body) ->
36
# takes the body of the ticket, searches for http[s]://<theme.DNS>/ URLs and either replaces ?session=* by ?session=support or adds it
37
body = body.replace(/\?session=([^\s]*)/g, '?session=support')
38
39
urlPattern = new RegExp("(http[s]?://[^\\s]*#{theme.DNS}[^\\s]+)", "g")
40
reSession = /session=([^\s]*)/g
41
42
ret = ''
43
offset = 0
44
45
while m = urlPattern.exec(body)
46
url = m[0]
47
i = m.index
48
j = i + url.length
49
#console.log(i, j)
50
#console.log(x[i..j])
51
52
ret += body[offset...i]
53
54
q = url.indexOf('?session')
55
if q >= 0
56
url = url[0...q]
57
q = url.indexOf('?')
58
if q >= 0
59
url += '&session=support'
60
else
61
url += '?session=support'
62
ret += url
63
offset = j
64
ret += body[offset...body.length]
65
return ret
66
67
support = undefined
68
exports.init_support = (cb) ->
69
support = new Support cb: (err, s) =>
70
support = s
71
cb(err)
72
73
exports.get_support = ->
74
return support
75
76
class Support
77
constructor: (opts={}) ->
78
opts = defaults opts,
79
cb : undefined
80
81
@dbg = (f) =>
82
return (m) -> winston.debug("Zendesk.#{f}: #{m}")
83
84
dbg = @dbg("constructor")
85
@_zd = null
86
87
async.waterfall([
88
(cb) =>
89
dbg("loading zendesk password from disk")
90
password_file = zendesk_password_filename()
91
fs.exists password_file, (exists) =>
92
if exists
93
fs.readFile password_file, (err, data) =>
94
if err
95
cb(err)
96
else
97
dbg("read zendesk password from '#{password_file}'")
98
creds = data.toString().trim().split(':')
99
cb(null, creds[0], creds[1])
100
else
101
dbg("no password file found at #{password_file}")
102
cb(null, null, null)
103
104
(username, password, cb) =>
105
if username? and password?
106
zendesk = require('node-zendesk')
107
# username already has /token postfix, otherwise set "token" instead of "password"
108
zd = zendesk.createClient
109
username : username,
110
password : password,
111
remoteUri : 'https://sagemathcloud.zendesk.com/api/v2'
112
cb(null, zd)
113
else
114
cb(null, null)
115
116
], (err, zendesk_client) =>
117
if err
118
dbg("error initializing zendesk -- #{misc.to_json(err)}")
119
else
120
dbg("successfully initialized zendesk")
121
@_zd = zendesk_client
122
opts.cb?(err, @)
123
)
124
125
126
###
127
# Start of high-level SMC API for support tickets
128
###
129
130
# List recent tickets (basically a test if the API client works)
131
# https://developer.zendesk.com/rest_api/docs/core/tickets#list-tickets
132
recent_tickets: (cb) ->
133
@_zd?.tickets.listRecent (err, statusList, body, responseList, resultList) =>
134
if (err)
135
console.log(err)
136
return
137
dbg = @dbg("recent_tickets")
138
dbg(JSON.stringify(body, null, 2, true))
139
cb?(body)
140
141
get_support_tickets: (account_id, cb) ->
142
dbg = @dbg("get_support_tickets")
143
dbg("args: #{account_id}")
144
if not @_zd?
145
err = "Support ticket backend is not available."
146
dbg(err)
147
cb?(err)
148
return
149
150
query_zendesk = (account_id, cb) =>
151
# zendesk query, looking for tickets tagged with the account_id
152
# https://support.zendesk.com/hc/en-us/articles/203663226
153
q = "type:ticket fieldvalue:#{account_id}"
154
dbg("query = #{q}")
155
@_zd.search.query q, (err, req, result) =>
156
if err
157
cb(err); return
158
cb(null, result)
159
160
process_result = (raw, cb) =>
161
# post-processing zendesk list
162
# dbg("raw = #{JSON.stringify(raw, null, 2, true)}")
163
tickets = []
164
for r in raw
165
t = _.pick(r, 'id', 'subject', 'description', 'created_at', 'updated_at', 'status')
166
t.url = misc.ticket_id_to_ticket_url(t.id)
167
tickets.push(t)
168
cb(null, tickets)
169
170
async.waterfall([
171
async.apply(query_zendesk, account_id)
172
process_result
173
], (err, tickets) =>
174
if err
175
cb?(err)
176
else
177
cb?(null, tickets)
178
)
179
180
# mapping of incoming data from SMC to the API of Zendesk
181
# https://developer.zendesk.com/rest_api/docs/core/tickets#create-ticket
182
create_ticket: (opts, cb) ->
183
opts = defaults opts,
184
email_address : required # if there is no email_address in the account, there can't be a ticket!
185
username : undefined
186
subject : required # like an email subject
187
body : required # html or md formatted text
188
tags : undefined
189
account_id : undefined
190
location : undefined # URL
191
info : {} # additional data dict, like browser/OS
192
193
dbg = @dbg("create_ticket")
194
# dbg("opts = #{misc.to_json(opts)}")
195
196
if not @_zd?
197
err = "Support ticket backend is not available."
198
dbg(err)
199
cb?(err)
200
return
201
202
# data assembly, we need a special formatted user and ticket object
203
# name: must be at least one character, even " " is causing errors
204
# https://developer.zendesk.com/rest_api/docs/core/users
205
user =
206
user:
207
name : if opts.username?.trim?().length > 0 then opts.username else opts.email_address
208
email : opts.email_address
209
external_id : opts.account_id ? null
210
# manage custom_fields here: https://sagemathcloud.zendesk.com/agent/admin/user_fields
211
#custom_fields:
212
# subscription : null
213
# type : null
214
215
tags = opts.tags ? []
216
217
# https://sagemathcloud.zendesk.com/agent/admin/ticket_fields
218
# Also, you have to read the API info (way more complex than you might think!)
219
# https://developer.zendesk.com/rest_api/docs/core/tickets#setting-custom-field-values
220
cus_fld_id =
221
account_id: 31614628
222
project_id: 30301277
223
location : 30301287
224
browser : 31647548
225
mobile : 31647578
226
internet : 31665978
227
hostname : 31665988
228
course : 31764067
229
quotas : 31758818
230
info : 31647558
231
232
custom_fields = [
233
{id: cus_fld_id.account_id, value: opts.account_id ? ''}
234
{id: cus_fld_id.project_id, value: opts.info.project_id ? ''}
235
{id: cus_fld_id.location , value: opts.location ? ''}
236
{id: cus_fld_id.browser , value: opts.info.browser ? 'unknown'}
237
{id: cus_fld_id.mobile , value: opts.info.mobile ? 'unknown'}
238
{id: cus_fld_id.internet , value: opts.info.internet ? 'unknown'}
239
{id: cus_fld_id.hostname , value: opts.info.hostname ? 'unknown'}
240
{id: cus_fld_id.course , value: opts.info.course ? 'unknown'}
241
{id: cus_fld_id.quotas , value: opts.info.quotas ? 'unknown'}
242
]
243
244
# getting rid of those fields, which we have picked above -- keeps extra fields.
245
remaining_info = _.omit(opts.info, _.keys(cus_fld_id))
246
custom_fields.push(id: cus_fld_id.info, value: JSON.stringify(remaining_info))
247
248
# fix any copy/pasted links from inside the body of the message to replace an optional session
249
body = fixSessions(opts.body)
250
251
# below the body message, add a link to the location
252
# TODO fix hardcoded URL
253
if opts.location?
254
url = "https://" + path.join(theme.DNS, opts.location)
255
body = body + "\n\n#{url}?session=support"
256
else
257
body = body + "\n\nNo location provided."
258
259
if misc.is_valid_uuid_string(opts.info.course)
260
body += "\n\nCourse: #{theme.DOMAIN_NAME}/projects/#{opts.info.course}?session=support"
261
262
# https://developer.zendesk.com/rest_api/docs/core/tickets#request-parameters
263
ticket =
264
ticket:
265
subject: opts.subject
266
comment:
267
body : body
268
tags : tags
269
type : "problem"
270
custom_fields: custom_fields
271
272
# data assembly finished → creating or updating existing zendesk user, then sending ticket creation
273
274
async.waterfall([
275
# 1. get or create user ID in zendesk-land
276
(cb) =>
277
if DEBUG
278
cb(null, 1234567890)
279
else
280
# workaround, until https://github.com/blakmatrix/node-zendesk/pull/131/files is in
281
@_zd.users.request 'POST', ['users', 'create_or_update'], user, (err, req, result) =>
282
if err
283
dbg("create_or_update user error: #{misc.to_json(err)}")
284
try
285
# we HAVE had uncaught exceptions here in production
286
# logged in the central_error_log!
287
err = "#{misc.to_json(misc.from_json(err.result))}"
288
catch
289
# evidently err.result is not valid json so can't do better than to string it
290
err = "#{err.result}"
291
#if err.result?.type == "Buffer"
292
# err = err.result.data.map((c) -> String.fromCharCode(c)).join('')
293
# dbg("create_or_update zendesk message: #{err}")
294
cb(err); return
295
# result = { "id": int, "url": "https://…json", "name": …, "email": "…", "created_at": "…", "updated_at": "…", … }
296
# dbg(JSON.stringify(result, null, 2, true))
297
cb(null, result.id)
298
299
# 2. creating ticket with known zendesk user ID (an integer number)
300
(requester_id, cb) =>
301
dbg("create ticket #{misc.to_json(ticket)} with requester_id: #{requester_id}")
302
ticket.ticket.requester_id = requester_id
303
if DEBUG
304
cb(null, Math.floor(Math.random() * 1e6 + 999e7))
305
else
306
@_zd.tickets.create ticket, (err, req, result) =>
307
if (err)
308
cb(err); return
309
# dbg(JSON.stringify(result, null, 2, true))
310
cb(null, result.id)
311
312
# 3. store ticket data, timestamp, and zendesk ticket number in our own DB
313
(ticket_id, cb) =>
314
# TODO: NYI
315
cb(null, ticket_id)
316
317
], (err, ticket_id) =>
318
# dbg("done! ticket_id: #{ticket_id}, err: #{err}, and callback: #{@cb?}")
319
if err
320
cb?(err)
321
else
322
url = misc.ticket_id_to_ticket_url(ticket_id)
323
cb?(null, url)
324
)
325
326
327
if SMC_TEST
328
exports.fixSessions = fixSessions
329
330