smc-hub
Version:
CoCalc: Backend webserver component
447 lines (420 loc) • 14.8 kB
JavaScript
// Generated by CoffeeScript 2.5.1
(function() {
//########################################################################
// This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
// License: AGPLv3 s.t. "Commons Clause" – see LICENSE.md for details
//########################################################################
// Handling support tickets for users -- currently a Zendesk wrapper.
// (c) 2016, SageMath, Inc.
// License: GPLv3
var DEBUG, SMC_TEST, Support, _, async, defaults, fixSessions, fs, misc, path, ref, required, support, theme, winston, zendesk_password_filename;
/*
Support Tickets, built on top of Zendesk's Core API
Docs:
https://developer.zendesk.com/rest_api/docs/core/introduction
https://github.com/blakmatrix/node-zendesk
*/
// if true, no real tickets are created
DEBUG = (ref = process.env.SMC_TEST_ZENDESK) != null ? ref : false;
SMC_TEST = process.env.SMC_TEST;
async = require('async');
fs = require('fs');
path = require('path');
misc = require('smc-util/misc');
theme = require('smc-util/theme');
_ = require('underscore');
({defaults, required} = misc);
winston = require('./logger').getLogger('support');
zendesk_password_filename = function() {
var ref1;
return ((ref1 = process.env.SMC_ROOT) != null ? ref1 : '.') + '/data/secrets/zendesk';
};
fixSessions = function(body) {
var i, j, m, offset, q, reSession, ret, url, urlPattern;
// takes the body of the ticket, searches for http[s]://<theme.DNS>/ URLs and either replaces ?session=* by ?session= or adds it
body = body.replace(/\?session=([^\s]*)/g, '?session=');
urlPattern = new RegExp(`(http[s]?://[^\\s]*${theme.DNS}[^\\s]+)`, "g");
reSession = /session=([^\s]*)/g;
ret = '';
offset = 0;
while (m = urlPattern.exec(body)) {
url = m[0];
i = m.index;
j = i + url.length;
//console.log(i, j)
//console.log(x[i..j])
ret += body.slice(offset, i);
q = url.indexOf('?session');
if (q >= 0) {
url = url.slice(0, q);
}
q = url.indexOf('?');
if (q >= 0) {
url += '&session=';
} else {
url += '?session=';
}
ret += url;
offset = j;
}
ret += body.slice(offset, body.length);
return ret;
};
support = void 0;
exports.init_support = function(cb) {
return support = new Support({
cb: (err, s) => {
support = s;
return cb(err);
}
});
};
exports.get_support = function() {
return support;
};
Support = class Support {
constructor(opts = {}) {
var dbg;
opts = defaults(opts, {
cb: void 0
});
this.dbg = (f) => {
return function(m) {
return winston.debug(`Zendesk.${f}: ${m}`);
};
};
dbg = this.dbg("constructor");
this._zd = null;
async.waterfall([
(cb) => {
var password_file;
dbg("loading zendesk password from disk");
password_file = zendesk_password_filename();
return fs.exists(password_file,
(exists) => {
if (exists) {
return fs.readFile(password_file,
(err,
data) => {
var creds;
if (err) {
return cb(err);
} else {
dbg(`read zendesk password from '${password_file}'`);
creds = data.toString().trim().split(':');
return cb(null,
creds[0],
creds[1]);
}
});
} else {
dbg(`no password file found at ${password_file}`);
return cb(null,
null,
null);
}
});
},
(username,
password,
cb) => {
var zd,
zendesk;
if ((username != null) && (password != null)) {
zendesk = require('node-zendesk');
// username already has /token postfix, otherwise set "token" instead of "password"
zd = zendesk.createClient({
username: username,
password: password,
remoteUri: 'https://sagemathcloud.zendesk.com/api/v2'
});
return cb(null,
zd);
} else {
return cb(null,
null);
}
}
], (err, zendesk_client) => {
if (err) {
dbg(`error initializing zendesk -- ${misc.to_json(err)}`);
} else {
dbg("successfully initialized zendesk");
this._zd = zendesk_client;
}
return typeof opts.cb === "function" ? opts.cb(err, this) : void 0;
});
}
/*
* Start of high-level SMC API for support tickets
*/
// List recent tickets (basically a test if the API client works)
// https://developer.zendesk.com/rest_api/docs/core/tickets#list-tickets
recent_tickets(cb) {
var ref1;
return (ref1 = this._zd) != null ? ref1.tickets.listRecent((err, statusList, body, responseList, resultList) => {
var dbg;
if (err) {
console.log(err);
return;
}
dbg = this.dbg("recent_tickets");
dbg(JSON.stringify(body, null, 2, true));
return typeof cb === "function" ? cb(body) : void 0;
}) : void 0;
}
get_support_tickets(account_id, cb) {
var dbg, err, process_result, query_zendesk;
dbg = this.dbg("get_support_tickets");
dbg(`args: ${account_id}`);
if (this._zd == null) {
err = "Support ticket backend is not available.";
dbg(err);
if (typeof cb === "function") {
cb(err);
}
return;
}
query_zendesk = (account_id, cb) => {
var q;
// zendesk query, looking for tickets tagged with the account_id
// https://support.zendesk.com/hc/en-us/articles/203663226
q = `type:ticket fieldvalue:${account_id}`;
dbg(`query = ${q}`);
return this._zd.search.query(q, (err, req, result) => {
if (err) {
cb(err);
return;
}
return cb(null, result);
});
};
process_result = (raw, cb) => {
var k, len, r, t, tickets;
// post-processing zendesk list
// dbg("raw = #{JSON.stringify(raw, null, 2, true)}")
tickets = [];
for (k = 0, len = raw.length; k < len; k++) {
r = raw[k];
t = _.pick(r, 'id', 'subject', 'description', 'created_at', 'updated_at', 'status');
t.url = misc.ticket_id_to_ticket_url(t.id);
tickets.push(t);
}
return cb(null, tickets);
};
return async.waterfall([async.apply(query_zendesk, account_id), process_result], (err, tickets) => {
if (err) {
return typeof cb === "function" ? cb(err) : void 0;
} else {
return typeof cb === "function" ? cb(null, tickets) : void 0;
}
});
}
// mapping of incoming data from SMC to the API of Zendesk
// https://developer.zendesk.com/rest_api/docs/core/tickets#create-ticket
create_ticket(opts, cb) {
var body, cus_fld_id, custom_fields, dbg, err, ref1, ref10, ref11, ref12, ref2, ref3, ref4, ref5, ref6, ref7, ref8, ref9, remaining_info, tags, ticket, url, user;
opts = defaults(opts, {
email_address: required, // if there is no email_address in the account, there can't be a ticket!
username: void 0,
subject: required, // like an email subject
body: required, // html or md formatted text
tags: void 0,
account_id: void 0,
location: void 0, // URL
info: {} // additional data dict, like browser/OS
});
dbg = this.dbg("create_ticket");
// dbg("opts = #{misc.to_json(opts)}")
if (this._zd == null) {
err = "Support ticket backend is not available.";
dbg(err);
if (typeof cb === "function") {
cb(err);
}
return;
}
// data assembly, we need a special formatted user and ticket object
// name: must be at least one character, even " " is causing errors
// https://developer.zendesk.com/rest_api/docs/core/users
user = {
user: {
name: ((ref1 = opts.username) != null ? typeof ref1.trim === "function" ? ref1.trim().length : void 0 : void 0) > 0 ? opts.username : opts.email_address,
email: opts.email_address,
external_id: (ref2 = opts.account_id) != null ? ref2 : null
}
};
// manage custom_fields here: https://sagemathcloud.zendesk.com/agent/admin/user_fields
//custom_fields:
// subscription : null
// type : null
tags = (ref3 = opts.tags) != null ? ref3 : [];
// https://sagemathcloud.zendesk.com/agent/admin/ticket_fields
// Also, you have to read the API info (way more complex than you might think!)
// https://developer.zendesk.com/rest_api/docs/core/tickets#setting-custom-field-values
cus_fld_id = {
account_id: 31614628,
project_id: 30301277,
location: 30301287,
browser: 31647548,
mobile: 31647578,
internet: 31665978,
hostname: 31665988,
course: 31764067,
quotas: 31758818,
info: 31647558
};
custom_fields = [
{
id: cus_fld_id.account_id,
value: (ref4 = opts.account_id) != null ? ref4 : ''
},
{
id: cus_fld_id.project_id,
value: (ref5 = opts.info.project_id) != null ? ref5 : ''
},
{
id: cus_fld_id.location,
value: (ref6 = opts.location) != null ? ref6 : ''
},
{
id: cus_fld_id.browser,
value: (ref7 = opts.info.browser) != null ? ref7 : 'unknown'
},
{
id: cus_fld_id.mobile,
value: (ref8 = opts.info.mobile) != null ? ref8 : 'unknown'
},
{
id: cus_fld_id.internet,
value: (ref9 = opts.info.internet) != null ? ref9 : 'unknown'
},
{
id: cus_fld_id.hostname,
value: (ref10 = opts.info.hostname) != null ? ref10 : 'unknown'
},
{
id: cus_fld_id.course,
value: (ref11 = opts.info.course) != null ? ref11 : 'unknown'
},
{
id: cus_fld_id.quotas,
value: (ref12 = opts.info.quotas) != null ? ref12 : 'unknown'
}
];
// getting rid of those fields, which we have picked above -- keeps extra fields.
remaining_info = _.omit(opts.info, _.keys(cus_fld_id));
custom_fields.push({
id: cus_fld_id.info,
value: JSON.stringify(remaining_info)
});
// fix any copy/pasted links from inside the body of the message to replace an optional session
body = fixSessions(opts.body);
// below the body message, add a link to the location
// TODO fix hardcoded URL
if (opts.location != null) {
url = "https://" + path.join(theme.DNS, opts.location);
body = body + `\n\n${url}?session=`;
} else {
body = body + "\n\nNo location provided.";
}
if (misc.is_valid_uuid_string(opts.info.course)) {
body += `\n\nCourse: ${theme.DOMAIN_URL}/projects/${opts.info.course}?session=`;
}
// https://developer.zendesk.com/rest_api/docs/core/tickets#request-parameters
ticket = {
ticket: {
subject: opts.subject,
comment: {
body: body
},
tags: tags,
type: "problem",
custom_fields: custom_fields
}
};
// data assembly finished → creating or updating existing zendesk user, then sending ticket creation
return async.waterfall([
// 1. get or create user ID in zendesk-land
(cb) => {
if (DEBUG) {
return cb(null,
1234567890);
} else {
// workaround, until https://github.com/blakmatrix/node-zendesk/pull/131/files is in
return this._zd.users.request('POST',
['users',
'create_or_update'],
user,
(err,
req,
result) => {
if (err) {
dbg(`create_or_update user error: ${misc.to_json(err)}`);
try {
// we HAVE had uncaught exceptions here in production
// logged in the central_error_log!
err = `${misc.to_json(misc.from_json(err.result))}`;
} catch (error) {
// evidently err.result is not valid json so can't do better than to string it
err = `${err.result}`;
}
//if err.result?.type == "Buffer"
// err = err.result.data.map((c) -> String.fromCharCode(c)).join('')
// dbg("create_or_update zendesk message: #{err}")
cb(err);
return;
}
// result = { "id": int, "url": "https://…json", "name": …, "email": "…", "created_at": "…", "updated_at": "…", … }
// dbg(JSON.stringify(result, null, 2, true))
return cb(null,
result.id);
});
}
},
// 2. creating ticket with known zendesk user ID (an integer number)
(requester_id,
cb) => {
dbg(`create ticket ${misc.to_json(ticket)} with requester_id: ${requester_id}`);
ticket.ticket.requester_id = requester_id;
if (DEBUG) {
return cb(null,
Math.floor(Math.random() * 1e6 + 999e7));
} else {
return this._zd.tickets.create(ticket,
(err,
req,
result) => {
if (err) {
cb(err);
return;
}
// dbg(JSON.stringify(result, null, 2, true))
return cb(null,
result.id);
});
}
},
// 3. store ticket data, timestamp, and zendesk ticket number in our own DB
(ticket_id,
cb) => {
// TODO: NYI
return cb(null,
ticket_id);
}
], (err, ticket_id) => {
// dbg("done! ticket_id: #{ticket_id}, err: #{err}, and callback: #{@cb?}")
if (err) {
return typeof cb === "function" ? cb(err) : void 0;
} else {
url = misc.ticket_id_to_ticket_url(ticket_id);
return typeof cb === "function" ? cb(null, url) : void 0;
}
});
}
};
if (SMC_TEST) {
exports.fixSessions = fixSessions;
}
}).call(this);
//# sourceMappingURL=support.js.map