513 lines
17 KiB
JavaScript
513 lines
17 KiB
JavaScript
#!/usr/bin/env node
|
|
// Starts the bot, handles permissions and chat context,
|
|
// interprets commands and delegates the actual command
|
|
// running to a Command instance. When started, an owner
|
|
// ID should be given.
|
|
|
|
var path = require("path");
|
|
var fs = require("fs");
|
|
var botgram = require("botgram");
|
|
var escapeHtml = require("escape-html");
|
|
var utils = require("./lib/utils");
|
|
var Command = require("./lib/command").Command;
|
|
var Editor = require("./lib/editor").Editor;
|
|
|
|
var CONFIG_FILE = path.join(__dirname, "config.json");
|
|
try {
|
|
var config = require(CONFIG_FILE);
|
|
} catch (e) {
|
|
console.error("Couldn't load the configuration file, starting the wizard.\n");
|
|
require("./lib/wizard").configWizard({ configFile: CONFIG_FILE });
|
|
return;
|
|
}
|
|
|
|
var bot = botgram(config.authToken, { agent: utils.createAgent() });
|
|
var owner = config.owner;
|
|
var tokens = {};
|
|
var granted = {};
|
|
var contexts = {};
|
|
var defaultCwd = process.env.HOME || process.cwd();
|
|
|
|
var fileUploads = {};
|
|
|
|
bot.on("updateError", function (err) {
|
|
console.error("Error when updating:", err);
|
|
});
|
|
|
|
bot.on("synced", function () {
|
|
console.log("Bot ready.");
|
|
});
|
|
|
|
|
|
function rootHook(msg, reply, next) {
|
|
if (msg.queued) return;
|
|
|
|
var id = msg.chat.id;
|
|
var allowed = id === owner || granted[id];
|
|
|
|
// If this message contains a token, check it
|
|
if (!allowed && msg.command === "start" && Object.hasOwnProperty.call(tokens, msg.args())) {
|
|
var token = tokens[msg.args()];
|
|
delete tokens[msg.args()];
|
|
granted[id] = true;
|
|
allowed = true;
|
|
|
|
// Notify owner
|
|
// FIXME: reply to token message
|
|
var contents = (msg.user ? "User" : "Chat") + " <em>" + escapeHtml(msg.chat.name) + "</em>";
|
|
if (msg.chat.username) contents += " (@" + escapeHtml(msg.chat.username) + ")";
|
|
contents += " can now use the bot. To revoke, use:";
|
|
reply.to(owner).html(contents).command("revoke", id);
|
|
}
|
|
|
|
// If chat is not allowed, but user is, use its context
|
|
if (!allowed && (msg.from.id === owner || granted[msg.from.id])) {
|
|
id = msg.from.id;
|
|
allowed = true;
|
|
}
|
|
|
|
// Check that the chat is allowed
|
|
if (!allowed) {
|
|
if (msg.command === "start") reply.html("Not authorized to use this bot.");
|
|
return;
|
|
}
|
|
|
|
if (!contexts[id]) contexts[id] = {
|
|
id: id,
|
|
shell: utils.shells[0],
|
|
env: utils.getSanitizedEnv(),
|
|
cwd: defaultCwd,
|
|
size: {columns: 40, rows: 20},
|
|
silent: true,
|
|
interactive: false,
|
|
linkPreviews: false,
|
|
};
|
|
|
|
msg.context = contexts[id];
|
|
next();
|
|
}
|
|
bot.all(rootHook);
|
|
bot.edited.all(rootHook);
|
|
|
|
|
|
// Replies
|
|
bot.message(function (msg, reply, next) {
|
|
if (msg.reply === undefined || msg.reply.from.id !== this.get("id")) return next();
|
|
if (msg.file)
|
|
return handleDownload(msg, reply);
|
|
if (msg.context.editor)
|
|
return msg.context.editor.handleReply(msg);
|
|
if (!msg.context.command)
|
|
return reply.html("No command is running.");
|
|
msg.context.command.handleReply(msg);
|
|
});
|
|
|
|
// Edits
|
|
bot.edited.message(function (msg, reply, next) {
|
|
if (msg.context.editor)
|
|
return msg.context.editor.handleEdit(msg);
|
|
next();
|
|
});
|
|
|
|
// Convenience command -- behaves as /run or /enter
|
|
// depending on whether a command is already running
|
|
bot.command("r", function (msg, reply, next) {
|
|
// A little hackish, but it does show the power of
|
|
// Botgram's fallthrough system!
|
|
msg.command = msg.context.command ? "enter" : "run";
|
|
next();
|
|
});
|
|
|
|
// Signal sending
|
|
bot.command("cancel", "kill", function (msg, reply, next) {
|
|
var arg = msg.args(1)[0];
|
|
if (!msg.context.command)
|
|
return reply.html("No command is running.");
|
|
|
|
var group = msg.command === "cancel";
|
|
var signal = group ? "SIGINT" : "SIGTERM";
|
|
if (arg) signal = arg.trim().toUpperCase();
|
|
if (signal.substring(0,3) !== "SIG") signal = "SIG" + signal;
|
|
try {
|
|
msg.context.command.sendSignal(signal, group);
|
|
} catch (err) {
|
|
reply.reply(msg).html("Couldn't send signal.");
|
|
}
|
|
});
|
|
|
|
// Input sending
|
|
bot.command("enter", "type", function (msg, reply, next) {
|
|
var args = msg.args();
|
|
if (!msg.context.command)
|
|
return reply.html("No command is running.");
|
|
if (msg.command === "type" && !args) args = " ";
|
|
msg.context.command.sendInput(args, msg.command === "type");
|
|
});
|
|
bot.command("control", function (msg, reply, next) {
|
|
var arg = msg.args(1)[0];
|
|
if (!msg.context.command)
|
|
return reply.html("No command is running.");
|
|
if (!arg || !/^[a-zA-Z]$/i.test(arg))
|
|
return reply.html("Use /control <letter> to send Control+letter to the process.");
|
|
var code = arg.toUpperCase().charCodeAt(0) - 0x40;
|
|
msg.context.command.sendInput(String.fromCharCode(code), true);
|
|
});
|
|
bot.command("meta", function (msg, reply, next) {
|
|
var arg = msg.args(1)[0];
|
|
if (!msg.context.command)
|
|
return reply.html("No command is running.");
|
|
if (!arg)
|
|
return msg.context.command.toggleMeta();
|
|
msg.context.command.toggleMeta(true);
|
|
msg.context.command.sendInput(arg, true);
|
|
});
|
|
bot.command("end", function (msg, reply, next) {
|
|
if (!msg.context.command)
|
|
return reply.html("No command is running.");
|
|
msg.context.command.sendEof();
|
|
});
|
|
|
|
// Redraw
|
|
bot.command("redraw", function (msg, reply, next) {
|
|
if (!msg.context.command)
|
|
return reply.html("No command is running.");
|
|
msg.context.command.redraw();
|
|
});
|
|
|
|
// Command start
|
|
bot.command("run", function (msg, reply, next) {
|
|
var args = msg.args();
|
|
if (!args)
|
|
return reply.html("Use /run <command> to execute something.");
|
|
|
|
if (msg.context.command) {
|
|
var command = msg.context.command;
|
|
return reply.text("A command is already running.");
|
|
}
|
|
|
|
if (msg.editor) msg.editor.detach();
|
|
msg.editor = null;
|
|
|
|
console.log("Chat «%s»: running command «%s»", msg.chat.name, args);
|
|
msg.context.command = new Command(reply, msg.context, args);
|
|
msg.context.command.on("exit", function() {
|
|
msg.context.command = null;
|
|
});
|
|
});
|
|
|
|
// Editor start
|
|
bot.command("file", function (msg, reply, next) {
|
|
var args = msg.args();
|
|
if (!args)
|
|
return reply.html("Use /file <file> to view or edit a text file.");
|
|
|
|
if (msg.context.command) {
|
|
var command = msg.context.command;
|
|
return reply.reply(command.initialMessage.id || msg).text("A command is running.");
|
|
}
|
|
|
|
if (msg.editor) msg.editor.detach();
|
|
msg.editor = null;
|
|
|
|
try {
|
|
var file = path.resolve(msg.context.cwd, args);
|
|
msg.context.editor = new Editor(reply, file);
|
|
} catch (e) {
|
|
reply.html("Couldn't open file: %s", e.message);
|
|
}
|
|
});
|
|
|
|
// Keypad
|
|
bot.command("keypad", function (msg, reply, next) {
|
|
if (!msg.context.command)
|
|
return reply.html("No command is running.");
|
|
try {
|
|
msg.context.command.toggleKeypad();
|
|
} catch (e) {
|
|
reply.html("Couldn't toggle keypad.");
|
|
}
|
|
});
|
|
|
|
// File upload / download
|
|
bot.command("upload", function (msg, reply, next) {
|
|
var args = msg.args();
|
|
if (!args)
|
|
return reply.html("Use /upload <file> and I'll send it to you");
|
|
|
|
var file = path.resolve(msg.context.cwd, args);
|
|
try {
|
|
var stream = fs.createReadStream(file);
|
|
} catch (e) {
|
|
return reply.html("Couldn't open file: %s", e.message);
|
|
}
|
|
|
|
// Catch errors but do nothing, they'll be propagated to the handler below
|
|
stream.on("error", function (e) {});
|
|
|
|
reply.action("upload_document").document(stream).then(function (e, msg) {
|
|
if (e)
|
|
return reply.html("Couldn't send file: %s", e.message);
|
|
fileUploads[msg.id] = file;
|
|
});
|
|
});
|
|
function handleDownload(msg, reply) {
|
|
if (Object.hasOwnProperty.call(fileUploads, msg.reply.id))
|
|
var file = fileUploads[msg.reply.id];
|
|
else if (msg.context.lastDirMessageId == msg.reply.id)
|
|
var file = path.join(msg.context.cwd, msg.filename || utils.constructFilename(msg));
|
|
else
|
|
return;
|
|
|
|
try {
|
|
var stream = fs.createWriteStream(file);
|
|
} catch (e) {
|
|
return reply.html("Couldn't write file: %s", e.message);
|
|
}
|
|
bot.fileStream(msg.file, function (err, ostream) {
|
|
if (err) throw err;
|
|
reply.action("typing");
|
|
ostream.pipe(stream);
|
|
ostream.on("end", function () {
|
|
reply.html("File written: %s", file);
|
|
});
|
|
});
|
|
}
|
|
|
|
// Status
|
|
bot.command("status", function (msg, reply, next) {
|
|
var content = "", context = msg.context;
|
|
|
|
// Running command
|
|
if (context.editor) content += "Editing file: " + escapeHtml(context.editor.file) + "\n\n";
|
|
else if (!context.command) content += "No command running.\n\n";
|
|
else content += "Command running, PID "+context.command.pty.pid+".\n\n";
|
|
|
|
// Chat settings
|
|
content += "Shell: " + escapeHtml(context.shell) + "\n";
|
|
content += "Size: " + context.size.columns + "x" + context.size.rows + "\n";
|
|
content += "Directory: " + escapeHtml(context.cwd) + "\n";
|
|
content += "Silent: " + (context.silent ? "yes" : "no") + "\n";
|
|
content += "Shell interactive: " + (context.interactive ? "yes" : "no") + "\n";
|
|
content += "Link previews: " + (context.linkPreviews ? "yes" : "no") + "\n";
|
|
var uid = process.getuid(), gid = process.getgid();
|
|
if (uid !== gid) uid = uid + "/" + gid;
|
|
content += "UID/GID: " + uid + "\n";
|
|
|
|
// Granted chats (msg.chat.id is intentional)
|
|
if (msg.chat.id === owner) {
|
|
var grantedIds = Object.keys(granted);
|
|
if (grantedIds.length) {
|
|
content += "\nGranted chats:\n";
|
|
content += grantedIds.map(function (id) { return id.toString(); }).join("\n");
|
|
} else {
|
|
content += "\nNo chats granted. Use /grant or /token to allow another chat to use the bot.";
|
|
}
|
|
}
|
|
|
|
if (context.command) reply.reply(context.command.initialMessage.id);
|
|
reply.html(content);
|
|
});
|
|
|
|
// Settings: Shell
|
|
bot.command("shell", function (msg, reply, next) {
|
|
var arg = msg.args(1)[0];
|
|
if (arg) {
|
|
if (msg.context.command) {
|
|
var command = msg.context.command;
|
|
return reply.reply(command.initialMessage.id || msg).html("Can't change the shell while a command is running.");
|
|
}
|
|
try {
|
|
var shell = utils.resolveShell(arg);
|
|
msg.context.shell = shell;
|
|
reply.html("Shell changed.");
|
|
} catch (err) {
|
|
reply.html("Couldn't change the shell.");
|
|
}
|
|
} else {
|
|
var shell = msg.context.shell;
|
|
var otherShells = utils.shells.slice(0);
|
|
var idx = otherShells.indexOf(shell);
|
|
if (idx !== -1) otherShells.splice(idx, 1);
|
|
|
|
var content = "Current shell: " + escapeHtml(shell);
|
|
if (otherShells.length)
|
|
content += "\n\nOther shells:\n" + otherShells.map(escapeHtml).join("\n");
|
|
reply.html(content);
|
|
}
|
|
});
|
|
|
|
// Settings: Working dir
|
|
bot.command("cd", function (msg, reply, next) {
|
|
var arg = msg.args(1)[0];
|
|
if (arg) {
|
|
if (msg.context.command) {
|
|
var command = msg.context.command;
|
|
return reply.reply(command.initialMessage.id || msg).html("Can't change directory while a command is running.");
|
|
}
|
|
var newdir = path.resolve(msg.context.cwd, arg);
|
|
try {
|
|
fs.readdirSync(newdir);
|
|
msg.context.cwd = newdir;
|
|
} catch (err) {
|
|
return reply.html("%s", err);
|
|
}
|
|
}
|
|
|
|
reply.html("Now at: %s", msg.context.cwd).then().then(function (m) {
|
|
msg.context.lastDirMessageId = m.id;
|
|
});
|
|
});
|
|
|
|
// Settings: Environment
|
|
bot.command("env", function (msg, reply, next) {
|
|
var env = msg.context.env, key = msg.args();
|
|
if (!key)
|
|
return reply.reply(msg).html("Use %s to see the value of a variable, or %s to change it.", "/env <name>", "/env <name>=<value>");
|
|
|
|
var idx = key.indexOf("=");
|
|
if (idx === -1) idx = key.indexOf(" ");
|
|
|
|
if (idx !== -1) {
|
|
if (msg.context.command) {
|
|
var command = msg.context.command;
|
|
return reply.reply(command.initialMessage.id || msg).html("Can't change the environment while a command is running.");
|
|
}
|
|
|
|
var value = key.substring(idx + 1);
|
|
key = key.substring(0, idx).trim().replace(/\s+/g, " ");
|
|
if (value.length) env[key] = value;
|
|
else delete env[key];
|
|
}
|
|
|
|
reply.reply(msg).text(printKey(key));
|
|
|
|
function printKey(k) {
|
|
if (Object.hasOwnProperty.call(env, k))
|
|
return k + "=" + JSON.stringify(env[k]);
|
|
return k + " unset";
|
|
}
|
|
});
|
|
|
|
// Settings: Size
|
|
bot.command("resize", function (msg, reply, next) {
|
|
var arg = msg.args(1)[0] || "";
|
|
var match = /(\d+)\s*((\sby\s)|x|\s|,|;)\s*(\d+)/i.exec(arg.trim());
|
|
if (match) var columns = parseInt(match[1]), rows = parseInt(match[4]);
|
|
if (!columns || !rows)
|
|
return reply.text("Use /resize <columns> <rows> to resize the terminal.");
|
|
|
|
msg.context.size = { columns: columns, rows: rows };
|
|
if (msg.context.command) msg.context.command.resize(msg.context.size);
|
|
reply.reply(msg).html("Terminal resized.");
|
|
});
|
|
|
|
// Settings: Silent
|
|
bot.command("setsilent", function (msg, reply, next) {
|
|
var arg = utils.resolveBoolean(msg.args());
|
|
if (arg === null)
|
|
return reply.html("Use /setsilent [yes|no] to control whether new output from the command will be sent silently.");
|
|
|
|
msg.context.silent = arg;
|
|
if (msg.context.command) msg.context.command.setSilent(arg);
|
|
reply.html("Output will " + (arg ? "" : "not ") + "be sent silently.");
|
|
});
|
|
|
|
// Settings: Interactive
|
|
bot.command("setinteractive", function (msg, reply, next) {
|
|
var arg = utils.resolveBoolean(msg.args());
|
|
if (arg === null)
|
|
return reply.html("Use /setinteractive [yes|no] to control whether shell is interactive. Enabling it will cause your aliases in i.e. .bashrc to be honored, but can cause bugs in some shells such as fish.");
|
|
|
|
if (msg.context.command) {
|
|
var command = msg.context.command;
|
|
return reply.reply(command.initialMessage.id || msg).html("Can't change the interactive flag while a command is running.");
|
|
}
|
|
msg.context.interactive = arg;
|
|
reply.html("Commands will " + (arg ? "" : "not ") + "be started with interactive shells.");
|
|
});
|
|
|
|
// Settings: Link previews
|
|
bot.command("setlinkpreviews", function (msg, reply, next) {
|
|
var arg = utils.resolveBoolean(msg.args());
|
|
if (arg === null)
|
|
return reply.html("Use /setlinkpreviews [yes|no] to control whether links in the output get expanded.");
|
|
|
|
msg.context.linkPreviews = arg;
|
|
if (msg.context.command) msg.context.command.setLinkPreviews(arg);
|
|
reply.html("Links in the output will " + (arg ? "" : "not ") + "be expanded.");
|
|
});
|
|
|
|
// Settings: Other chat access
|
|
bot.command("grant", "revoke", function (msg, reply, next) {
|
|
if (msg.context.id !== owner) return;
|
|
var arg = msg.args(1)[0], id = parseInt(arg);
|
|
if (!arg || isNaN(id))
|
|
return reply.html("Use %s or %s to control whether the chat with that ID can use this bot.", "/grant <id>", "/revoke <id>");
|
|
reply.reply(msg);
|
|
if (msg.command === "grant") {
|
|
granted[id] = true;
|
|
reply.html("Chat %s can now use this bot. Use /revoke to undo.", id);
|
|
} else {
|
|
if (contexts[id] && contexts[id].command)
|
|
return reply.html("Couldn't revoke specified chat because a command is running.");
|
|
delete granted[id];
|
|
delete contexts[id];
|
|
reply.html("Chat %s has been revoked successfully.", id);
|
|
}
|
|
});
|
|
bot.command("token", function (msg, reply, next) {
|
|
if (msg.context.id !== owner) return;
|
|
var token = utils.generateToken();
|
|
tokens[token] = true;
|
|
reply.disablePreview().html("One-time access token generated. The following link can be used to get access to the bot:\n%s\nOr by forwarding me this:", bot.link(token));
|
|
reply.command(true, "start", token);
|
|
});
|
|
|
|
// Welcome message, help
|
|
bot.command("start", function (msg, reply, next) {
|
|
if (msg.args() && msg.context.id === owner && Object.hasOwnProperty.call(tokens, msg.args())) {
|
|
reply.html("You were already authenticated; the token has been revoked.");
|
|
} else {
|
|
reply.html("Welcome! Use /run to execute commands, and reply to my messages to send input. /help for more info.");
|
|
}
|
|
});
|
|
|
|
bot.command("help", function (msg, reply, next) {
|
|
reply.html(
|
|
"Use /run <command> and I'll execute it for you. While it's running, you can:\n" +
|
|
"\n" +
|
|
"‣ Reply to one of my messages to send input to the command, or use /enter.\n" +
|
|
"‣ Use /end to send an EOF (Ctrl+D) to the command.\n" +
|
|
"‣ Use /cancel to send SIGINT (Ctrl+C) to the process group, or the signal you choose.\n" +
|
|
"‣ Use /kill to send SIGTERM to the root process, or the signal you choose.\n" +
|
|
"‣ For graphical applications, use /redraw to force a repaint of the screen.\n" +
|
|
"‣ Use /type or /control to press keys, /meta to send the next key with Alt, or /keypad to show a keyboard for special keys.\n" +
|
|
"\n" +
|
|
"You can see the current status and settings for this chat with /status. Use /env to " +
|
|
"manipulate the environment, /cd to change the current directory, /shell to see or " +
|
|
"change the shell used to run commands and /resize to change the size of the terminal.\n" +
|
|
"\n" +
|
|
"By default, output messages are sent silently (without sound) and links are not expanded. " +
|
|
"This can be changed through /setsilent and /setlinkpreviews. Note: links are " +
|
|
"never expanded in status lines.\n" +
|
|
"\n" +
|
|
"<em>Additional features</em>\n" +
|
|
"\n" +
|
|
"Use /upload <file> and I'll send that file to you. If you reply to that " +
|
|
"message by uploading me a file, I'll overwrite it with yours.\n" +
|
|
"\n" +
|
|
"You can also use /file <file> to display the contents of file as a text " +
|
|
"message. This also allows you to edit the file, but you have to know how..."
|
|
);
|
|
});
|
|
|
|
// FIXME: add inline bot capabilities!
|
|
// FIXME: possible feature: restrict chats to UIDs
|
|
// FIXME: persistence
|
|
// FIXME: shape messages so we don't hit limits, and react correctly when we do
|
|
|
|
|
|
bot.command(function (msg, reply, next) {
|
|
reply.reply(msg).text("Invalid command.");
|
|
});
|