UNPKG

smc-hub

Version:

CoCalc: Backend webserver component

447 lines (420 loc) 14.8 kB
// 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