-
-
Save samholmes/388ca4552c5936b52c5d to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Load config | |
global.config = require('./config'); | |
var http = require('http'), | |
emailer = require('./emailer'), | |
mysql = require('mysql'), | |
fs = require('fs'), | |
paralyze = require('paralyze'), | |
async = require('async'); | |
var APP_PORT = process.env.APP_PORT || 8000; | |
/** | |
* Database | |
*/ | |
var db = mysql.createConnection({ | |
host: global.config.db.host, | |
database: global.config.db.database, | |
user: global.config.db.user, | |
password: global.config.db.password | |
}); | |
db.config.queryFormat = function(query, values){ | |
if (!values) return query; | |
return query.replace(/\:(\w+)/g, function (txt, key) { | |
if (values.hasOwnProperty(key)) { | |
return this.escape(values[key]); | |
} | |
return txt; | |
}.bind(this)); | |
}; | |
/** | |
* Functions | |
*/ | |
var funcs = require('./funcs.js')(db); | |
/** | |
* HTTP Template Previews | |
*/ | |
var server = http.createServer(); | |
server | |
.on('request', function(req, res){ | |
var parts = req.url.substr(1).split('/'), | |
template = parts[0], | |
domain = parts[1]; | |
if (!template || !domain) { | |
return error("Please provide a template name and domain in the URL (e.g. SECRET)"); | |
} | |
funcs.getEmailTemplateLocals({ | |
domain: domain | |
}, | |
getEmailTemplateLocalsCallback) | |
function getEmailTemplateLocalsCallback(err, locals){ | |
if (err) return error(err); | |
emailer.renderEmail({ | |
template: template, | |
locals: locals | |
}, | |
renderEmailCallback); | |
} | |
function renderEmailCallback(err, html){ | |
if (err) return error(err); | |
res.end(html); | |
} | |
function error(err){ | |
if (typeof err == 'string') | |
err = new Error(err); | |
res.statusCode = 500; | |
res.end(err.stack); | |
} | |
}) | |
.listen(APP_PORT, | |
function(){ | |
console.log("HTTP server listening on " + APP_PORT) | |
startReplicon(); | |
}); | |
/** | |
* Replicon Commands | |
*/ | |
function startReplicon(){ | |
var replicon = require('./replicon') | |
/** | |
* Emailing | |
* | |
* send-email | |
* Sends an email template from a specified site to a specified subscriber. | |
* | |
* blast-emails | |
* Sends an email template to subscribers for a specified site (by domain). | |
*/ | |
replicon | |
.add('send-email', function(template, domain, subscriberId){ | |
return function(cb){ | |
funcs.getEmailTemplateLocals({ | |
domain: domain, | |
subscriberId: subscriberId | |
}, | |
getEmailTemplateLocalsCallback); | |
function getEmailTemplateLocalsCallback(err, locals){ | |
if (err) return error(err); | |
emailer.sendTemplate({ | |
to: locals.subscriber.email, | |
from: locals.site.title+" <"+locals.site.email+">", | |
template: template, | |
locals: locals | |
}, | |
sendTemplateCallback); | |
} | |
function sendTemplateCallback(err){ | |
if (err) return error(err); | |
cb(null); | |
} | |
function error(err){ | |
cb(err); | |
} | |
} | |
}) | |
.doc('send-email', "\n\ | |
Sends an email template from a specified site to a specified subscriber.\n\ | |
\n\ | |
Syntax:\n\ | |
send-email [template] [domain] [subscriberId]"); | |
replicon | |
.add('blast-emails', function(template, domain, offset){ | |
offset = offset || 0; | |
return function(cb){ | |
var subscribers | |
funcs.getEmailTemplateLocals({ | |
domain: domain | |
}, | |
getEmailTemplateLocalsCallback); | |
function getEmailTemplateLocalsCallback(err, locals){ | |
if (err) return error(err); | |
// First warn about NULL accessKeys. | |
// Count number of rows with NULL accessKeys | |
db.query("SELECT COUNT(*) nullAccessKeysCount \ | |
FROM subscribers \ | |
JOIN site_subscribers ON site_subscribers.subscriberId = subscribers.id \ | |
WHERE \ | |
site_subscribers.siteId = :siteId \ | |
AND NOT subscribers.dnd \ | |
AND subscribers.accessKey IS NULL", | |
{ | |
siteId: locals.site.id | |
}, | |
selectCountOfNullAccessKeysQueryHandler); | |
function selectCountOfNullAccessKeysQueryHandler(err, results){ | |
if (err) return error(err); | |
var row = results[0]; | |
if (row && row.nullAccessKeysCount) { | |
return error("Some subscribers have NULL access keys. Run gen-access-keys command."); | |
} | |
db.query("SELECT subscribers.* \ | |
FROM subscribers \ | |
JOIN site_subscribers ON site_subscribers.subscriberId = subscribers.id \ | |
WHERE \ | |
site_subscribers.siteId = :siteId \ | |
AND NOT subscribers.dnd \ | |
LIMIT 1000000000 OFFSET :offset", | |
{ | |
siteId: locals.site.id, | |
offset: offset | |
}, | |
selectSubscribersQueryHandler) | |
} | |
function selectSubscribersQueryHandler(err, results){ | |
if (err) return error(err); | |
subscribers = results; | |
async.eachSeries(subscribers, function(subscriber, cb){ | |
locals.subscriber = subscriber; | |
locals.subscriber.akey = locals.subscriber.accessKey.toString('base64'); | |
emailer.sendTemplate({ | |
to: locals.subscriber.email, | |
from: locals.site.title+" <"+locals.site.email+">", | |
template: template, | |
locals: locals | |
}, | |
function(err){ | |
if (err) return cb(err); | |
console.log("Sent to "+subscriber.email); | |
cb(); | |
}); | |
}, | |
function(err){ | |
if (err) return error(err); | |
console.log("Finished!"); | |
done(); | |
}); | |
} | |
} | |
function done(){ | |
cb(null, "Sent "+subscribers.length+" emails."); | |
} | |
function error(err){ | |
err = new Error(err); | |
cb(err.stack); | |
} | |
} | |
}) | |
.doc('blast-emails', "\n\ | |
Sends an email template to subscribers for a specified site (by domain) at an optional offset.\n\ | |
\n\ | |
Syntax:\n\ | |
blast-emails [template] [domain] [offset]"); | |
/** | |
* Access Keys | |
* | |
* gen-access-keys | |
* Generates accessKey column for all subscribers in the database | |
* that have a NULL accessKey. | |
* | |
* get-access-key | |
* Returns the accessKey for a specified subscriber encoded in base64. | |
*/ | |
replicon | |
.add('gen-access-keys', function(){ | |
var crypto = require('crypto'); | |
return function(cb){ | |
db.query("SELECT id FROM subscribers WHERE accessKey IS NULL", | |
selectSubscribersQueryHandler); | |
function selectSubscribersQueryHandler(err, results){ | |
if (err) return cb(err); | |
var wait = paralyze(done); | |
results.forEach(function(row){ | |
var subscriberId = row.id; | |
crypto.randomBytes(256, wait(function(err, buf) { | |
if (err) return cb(err); | |
var accessKey = buf; | |
db.query("UPDATE subscribers \ | |
SET accessKey = :accessKey \ | |
WHERE id = :subscriberId", | |
{ | |
accessKey: accessKey, | |
subscriberId: subscriberId | |
}, | |
wait(function(err, results){ | |
if (err) return cb(err); | |
})); | |
})); | |
}) | |
} | |
function done(){ | |
cb(null); | |
} | |
} | |
}) | |
.doc('gen-access-keys', "\n\ | |
Generates accessKey column for all subscribers in the database\n\ | |
that have a NULL accessKey.\n\ | |
\n\ | |
Syntax:\n\ | |
gen-access-keys"); | |
replicon | |
.add('get-access-key', function(subscriberId){ | |
return function(cb){ | |
db.query("SELECT accessKey FROM subscribers WHERE id = :subscriberId LIMIT 1", | |
{ | |
subscriberId: subscriberId | |
}, | |
function(err, results){ | |
if (err) return cb(err); | |
if (!results.length) | |
return cb(new Error("Could not find subscriber with that ID")); | |
var accessKeyBuffer = results[0].accessKey; | |
var accessKey = accessKeyBuffer.toString('base64'); | |
cb(null, accessKey); | |
}) | |
} | |
}) | |
.doc('get-access-key', "\n\ | |
Returns the accessKey for a specified subscriber encoded in base64.\n\ | |
\n\ | |
Syntax:\n\ | |
get-access-key [subscriberId]") | |
/** | |
* Templates | |
* | |
* template-list | |
* List all available email templates. | |
*/ | |
replicon | |
.add('template-ls', function(){ | |
return function(cb){ | |
emailer.getEmailTemplateNames(function(err, names){ | |
if (err) return cb(err); | |
cb(null, names.join(", ")); | |
}) | |
} | |
}) | |
.doc('template-ls', "\n\ | |
Returns a list of all template names.\n\ | |
\n\ | |
Syntax:\n\ | |
template-ls"); | |
/** | |
* Subscribers | |
* | |
* count-subscribers | |
* Counts the number of subscribers for a specific site or for all sites. | |
*/ | |
replicon | |
.add('count-subscribers', function(domain){ | |
return function(cb){ | |
db.query("SELECT dnd, COUNT(*) subscriberCount \ | |
FROM site_subscribers \ | |
JOIN sites ON sites.id = site_subscribers.siteId \ | |
JOIN subscribers ON subscribers.id = site_subscribers.subscriberId \ | |
WHERE \ | |
IF (LENGTH(:domain), sites.domain = :domain, TRUE) \ | |
GROUP BY dnd", | |
{ | |
domain: domain | |
}, | |
function(err, results){ | |
if (err) return error(err); | |
var subscriberCount = 0; | |
var dndCount = 0; | |
results.forEach(function(row){ | |
if (row.dnd) { | |
dndCount = row.subscriberCount; | |
} | |
else { | |
subscriberCount = row.subscriberCount; | |
} | |
}) | |
var output = subscriberCount + (dndCount && " ("+dndCount+" dnd)") + " for "; | |
output += domain ? domain : "all sites"; | |
cb(null, output); | |
}); | |
function error(err){ | |
cb(new Error(err)); | |
} | |
} | |
}) | |
.doc('count-subscribers', "\n\ | |
Counts the number of subscribers for a specific domain.\n\ | |
\n\ | |
Syntax:\n\ | |
count-subscribers [domain]"); | |
// End Replicon | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
var nodemailer = require('nodemailer'), | |
mandrillTransport = require('nodemailer-mandrill-transport'), | |
htmlToText = require('nodemailer-html-to-text').htmlToText, | |
stylus = require('stylus'), | |
nib = require('nib'), | |
jade = require('jade'), | |
juice = require('juice'), | |
async = require('async'), | |
fs = require('fs'), | |
path = require('path'), | |
cheerio = require('cheerio'); | |
var templateDir = './emails/'; | |
var transport = nodemailer.createTransport(mandrillTransport({ | |
auth: { | |
apiKey: global.config.smtp.password | |
} | |
})); | |
transport.use('compile', htmlToText()); | |
function sendTemplate(info, cb){ | |
cb = cb || new Function; | |
if (!info.to) | |
return error(new Error('missing info.to')); | |
if (!info.from) | |
return error(new Error('missing info.from')); | |
if (!info.template && !info.templateFiles) | |
return error(new Error('missing info.template or info.templateFiles')); | |
var locals = info.locals || {}; | |
renderEmail({ | |
template: info.template, | |
templateInfo: info.templateInfo, | |
locals: locals | |
}, renderEmailHandler); | |
function renderEmailHandler(err, html){ | |
if (err) return error(err); | |
if (!info.subject) { | |
var $ = cheerio.load(html); | |
info.subject = $('title').text(); | |
} | |
transport.sendMail({ | |
from: info.from, | |
to: info.to, | |
subject: info.subject, | |
html: html | |
}, | |
function(err, responseStatus){ | |
if (err) return error(err); | |
cb(null); | |
}) | |
} | |
function error(err){ | |
cb(err); | |
} | |
} | |
function renderEmail(info, cb){ | |
if (info.template) { | |
getEmailTemplateFiles({ | |
template: info.template | |
}, | |
function(err, templateFiles){ | |
if (err) return error(err); | |
processTemplateFiles(templateFiles); | |
}) | |
} | |
else if (info.templateFiles) { | |
processTemplateFiles(info.templateFiles); | |
} | |
else { | |
return error(new Error('missing info.template or info.templateFiles')); | |
} | |
function processTemplateFiles(templateFiles){ | |
var locals = info.locals || {}; | |
// Store jade filename in locals (required by jade for includes and layouts) | |
locals.filename = templateFiles.jadeFilename; | |
jade.render(templateFiles.jadeContent, locals, function(err, html){ | |
if (err) return error(err); | |
html = juice.inlineContent(html, templateFiles.cssContent); | |
cb(null, html); | |
}); | |
} | |
function error(err){ | |
cb(new Error(err)); | |
} | |
} | |
function getEmailTemplateFiles(info, cb){ | |
if (!info.template) | |
return error(new Error('missing info.template')); | |
var templateName = info.template, | |
jadeFilename = templateDir + templateName + '/content.jade', | |
stylFilename = templateDir + templateName + '/style.styl'; | |
var outfo = { | |
jadeFilename: jadeFilename | |
}; | |
// Get Jade and Styl files | |
async.parallel({ | |
jade: async.apply(fs.readFile, jadeFilename, 'utf8'), | |
styl: async.apply(fs.readFile, stylFilename, 'utf8') | |
}, | |
function(err, fileContents){ | |
if (err) return error(err); | |
// Jade | |
outfo.jadeContent = fileContents.jade; | |
// CSS | |
stylus(fileContents.styl) | |
.set('compile', true) | |
.use(nib()) | |
.render(function(err, css){ | |
if (err) return error(err); | |
outfo.cssContent = css; | |
cb(null, outfo); | |
}); | |
}); | |
function error(err){ | |
cb(new Error(err)); | |
} | |
} | |
function getEmailTemplateNames(info, cb){ | |
if (typeof info === 'function') { | |
cb = info; | |
info = {}; | |
} | |
fs.readdir(templateDir, function(err, files){ | |
if (err) return error(err); | |
var files = files.filter(function(file){ | |
var stat = fs.statSync(path.join(templateDir, file)); | |
return stat.isDirectory(); | |
}); | |
cb(null, files); | |
}) | |
function error(err){ | |
cb(new Error); | |
} | |
} | |
exports.sendTemplate = sendTemplate; | |
exports.renderEmail = renderEmail; | |
exports.getEmailTemplateFiles = getEmailTemplateFiles; | |
exports.getEmailTemplateNames = getEmailTemplateNames; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
var paralyze = require('paralyze'), | |
fs = require('fs'); | |
module.exports = function(db){ | |
funcs.db = db; | |
return funcs; | |
} | |
var funcs = {}; | |
/** | |
* Get Email Template Locals | |
*/ | |
funcs.getEmailTemplateLocals = function(info, cb){ | |
var locals = {}; | |
if (!info.domain) | |
return error("Missing info.domain"); | |
// To be used for URLs (must get rid of invalid filename characters) | |
locals.domainFilename = info.domain.replace(/[^a-z0-9.-]/gi, ''); | |
// www subdomain support | |
if (locals.domainFilename.indexOf('www.') === 0) | |
locals.domainFilename = locals.domainFilename.substr(4); | |
var wait = paralyze(done); | |
this.db.query("SELECT * FROM sites WHERE domain = :domain LIMIT 1", | |
{ | |
domain: info.domain | |
}, | |
wait(function(err, results){ | |
if (err) return error(err); | |
if (!results.length) | |
return error("Site not found"); | |
locals.site = results[0]; | |
try { | |
locals.siteData = JSON.parse(locals.site.siteData); | |
} | |
catch (err) { | |
return error(err); | |
} | |
})); | |
if (info.subscriberId) { | |
this.db.query("SELECT * FROM subscribers WHERE id = :subscriberId LIMIT 1", | |
{ | |
subscriberId: info.subscriberId | |
}, | |
wait(function(err, results){ | |
if (err) return error(err); | |
if (!results.length) | |
return error("Subscriber not found"); | |
locals.subscriber = results[0]; | |
locals.subscriber.akey = locals.subscriber.accessKey.toString('base64'); | |
})); | |
} | |
function done(){ | |
cb(null, locals); | |
} | |
function error(err){ | |
err = new Error(err); | |
cb(err); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
0 info it worked if it ends with ok | |
1 verbose cli [ '/root/.nvm/versions/io.js/v1.6.1/bin/iojs', | |
1 verbose cli '/root/.nvm/versions/io.js/v1.6.1/bin/npm', | |
1 verbose cli 'run', | |
1 verbose cli 'live' ] | |
2 info using npm@2.7.1 | |
3 info using node@v1.6.1 | |
4 verbose run-script [ 'prelive', 'live', 'postlive' ] | |
5 info prelive emailer@0.0.0 | |
6 info live emailer@0.0.0 | |
7 verbose unsafe-perm in lifecycle true | |
8 info emailer@0.0.0 Failed to exec live script | |
9 verbose stack Error: emailer@0.0.0 live: `NODE_ENV=production node app.js` | |
9 verbose stack Exit status 137 | |
9 verbose stack at EventEmitter.<anonymous> (/root/.nvm/versions/io.js/v1.6.1/lib/node_modules/npm/lib/utils/lifecycle.js:213:16) | |
9 verbose stack at emitTwo (events.js:87:13) | |
9 verbose stack at EventEmitter.emit (events.js:169:7) | |
9 verbose stack at ChildProcess.<anonymous> (/root/.nvm/versions/io.js/v1.6.1/lib/node_modules/npm/lib/utils/spawn.js:14:12) | |
9 verbose stack at emitTwo (events.js:87:13) | |
9 verbose stack at ChildProcess.emit (events.js:169:7) | |
9 verbose stack at maybeClose (child_process.js:984:16) | |
9 verbose stack at Process.ChildProcess._handle.onexit (child_process.js:1057:5) | |
10 verbose pkgid emailer@0.0.0 | |
11 verbose cwd /apps/emailer | |
12 error Linux 3.2.0-4-amd64 | |
13 error argv "/root/.nvm/versions/io.js/v1.6.1/bin/iojs" "/root/.nvm/versions/io.js/v1.6.1/bin/npm" "run" "live" | |
14 error node v1.6.1 | |
15 error npm v2.7.1 | |
16 error code ELIFECYCLE | |
17 error emailer@0.0.0 live: `NODE_ENV=production node app.js` | |
17 error Exit status 137 | |
18 error Failed at the emailer@0.0.0 live script 'NODE_ENV=production node app.js'. | |
18 error This is most likely a problem with the emailer package, | |
18 error not with npm itself. | |
18 error Tell the author that this fails on your system: | |
18 error NODE_ENV=production node app.js | |
18 error You can get their info via: | |
18 error npm owner ls emailer | |
18 error There is likely additional logging output above. | |
19 verbose exit [ 1, true ] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"name": "emailer", | |
"version": "0.0.0", | |
"description": "SECRET emailer program", | |
"main": "emailer.js", | |
"scripts": { | |
"dev": "APP_PORT=8001 node app.js", | |
"live": "NODE_ENV=production node app.js" | |
}, | |
"author": "Samuel Holmes", | |
"license": "ISC", | |
"dependencies": { | |
"async": "^0.9.0", | |
"cheerio": "^0.19.0", | |
"jade": "^1.9.2", | |
"juice": "^1.0.0", | |
"mysql": "^2.6.2", | |
"nib": "^1.1.0", | |
"nodemailer": "^1.3.2", | |
"nodemailer-html-to-text": "^1.0.1", | |
"nodemailer-mandrill-transport": "^0.1.1", | |
"paralyze": "^1.0.0", | |
"stylus": "^0.50.0" | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
var readline = require('readline'); | |
/** | |
* Init | |
*/ | |
var rl = readline.createInterface({ | |
input: process.stdin, | |
output: process.stdout, | |
completer: completer | |
}); | |
rl | |
.on('line', processLine) | |
.on('close', function() { | |
echo('') | |
process.exit(0); | |
}); | |
rl.setPrompt('# '); | |
rl.prompt(); | |
/** | |
* Private Variables | |
*/ | |
var _commands = {}, | |
_docs = {}; | |
/** | |
* Private Methods | |
*/ | |
function completer(line) { | |
var completions = Object.keys(_commands); | |
var hits = completions.filter(function(c) { return c.indexOf(line) == 0 }) | |
// show all completions if none found | |
return [hits, line] | |
} | |
function processLine(line){ | |
var args = prepareArgs(line); | |
var commandName = args.shift(), | |
args = washArguments(args); | |
var command = _commands[commandName]; | |
if (typeof command === 'function') { | |
var cb = command.apply(null, args); | |
// When command returned a function, it means command is async | |
if (typeof cb === 'function') { | |
// Pause the readline interface... | |
rl.pause(); | |
// ...until command is finished | |
cb(function(err, output){ | |
if (err) echo(err); | |
if (typeof output !== 'undefined') echo(output); | |
rl.prompt(); | |
}); | |
} | |
else { | |
// Otherwise, the command already executed, therefore initiate another prompt | |
rl.prompt(); | |
} | |
} | |
else { | |
echo("Command not found: "+commandName); | |
} | |
} | |
function prepareArgs(line){ | |
var args = []; | |
// Created an array were even elements are double-quoted strings | |
var stripedArray = line.split('"'); | |
// Transform striped array into final args array | |
stripedArray.forEach(function(e, i){ | |
// Don't process even elements because even elements are strings | |
if (i % 2) { | |
// Even elements are directly pushed into args array (don't mess with a string's spaces). | |
// However, add the double quotes back to explicitly say this is a string type | |
args.push('"'+e+'"'); | |
} | |
else { | |
// Odd elements are processed for spaces and then concatenated to args | |
e = e.trim(); | |
if (e) { | |
args = args.concat(e.split(/\s+/)); | |
} | |
} | |
}); | |
return args; | |
} | |
function washArguments(args){ | |
var washedArgs = []; | |
args.forEach(function(arg){ | |
arg = arg.trim(); | |
// Numbers | |
if (/^\d+(\.\d+)?$/.test(arg)) { | |
arg = Number(arg); | |
} | |
// Explicit Strings | |
else if (/^".*"$/.test(arg)) { | |
arg = arg.substring(1, arg.length-1) | |
} | |
washedArgs.push(arg); | |
}); | |
return washedArgs; | |
} | |
function echo(){ | |
console.log.apply(console, arguments); | |
} | |
/** | |
* Public Methods | |
*/ | |
exports.add = function(commandName, fn){ | |
_commands[commandName] = fn; | |
return exports; | |
}; | |
exports.doc = function(commandName, docs){ | |
// Support for removal of preceeding tabs/spaces on multi-line strings | |
// For mult-line tab/space truncation, strings must begin with a new-line | |
if (docs[0] === '\n') { | |
var measurements = /^\n(\t+|\s+)/.exec(docs); | |
docs = docs.replace(RegExp(measurements[0]), '').replace(RegExp(measurements[1], 'g'), ''); | |
} | |
_docs[commandName] = docs; | |
return exports; | |
}; | |
/** | |
* Built-in Commands | |
*/ | |
exports.add('help', function(commandName){ | |
if (!commandName) { | |
var methods = Object.keys(_commands); | |
echo("Available commands: \n"); | |
echo(methods.join(', ')+"\n"); | |
echo("Type `help command` for more information."); | |
} | |
else { | |
var command = _commands[commandName] | |
if (typeof command !== 'function') | |
return echo("Command not found: "+commandName); | |
if (_docs[commandName]) { | |
echo(_docs[commandName]); | |
} | |
else { | |
var sig = /^function\s*\(([^)]*)\)/.exec(command.toString())[1].replace(',', ''); | |
echo([commandName, sig].join(' ')); | |
} | |
} | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment