Add 'lib/command.js'
This commit is contained in:
parent
f47d9e8a33
commit
e2862b9019
264
lib/command.js
Normal file
264
lib/command.js
Normal file
|
@ -0,0 +1,264 @@
|
||||||
|
/**
|
||||||
|
* Attaches to a chat, spawns a pty, attaches it to the terminal emulator
|
||||||
|
* and the renderer and manages them. Handles incoming commands & input,
|
||||||
|
* and posts complimentary messages such as command itself and output code.
|
||||||
|
**/
|
||||||
|
|
||||||
|
var util = require("util");
|
||||||
|
var escapeHtml = require("escape-html");
|
||||||
|
var pty = require("node-pty");
|
||||||
|
var termios = require("node-termios");
|
||||||
|
var utils = require("./utils");
|
||||||
|
var terminal = require("./terminal");
|
||||||
|
var renderer = require("./renderer");
|
||||||
|
var tsyms = termios.native.ALL_SYMBOLS;
|
||||||
|
|
||||||
|
function Command(reply, context, command) {
|
||||||
|
var toUser = reply.destination > 0;
|
||||||
|
|
||||||
|
this.startTime = Date.now();
|
||||||
|
this.reply = reply;
|
||||||
|
this.command = command;
|
||||||
|
this.pty = pty.spawn(context.shell, [context.interactive ? "-ic" : "-c", command], {
|
||||||
|
cols: context.size.columns,
|
||||||
|
rows: context.size.rows,
|
||||||
|
cwd: context.cwd,
|
||||||
|
env: context.env,
|
||||||
|
});
|
||||||
|
this.termios = new termios.Termios(this.pty._fd);
|
||||||
|
this.termios.c_lflag &= ~(tsyms.ISIG | tsyms.IEXTEN);
|
||||||
|
this.termios.c_lflag &= ~tsyms.ECHO; // disable ECHO
|
||||||
|
this.termios.c_lflag |= tsyms.ICANON | tsyms.ECHONL; // we need it for /end, it needs to be active beforehand
|
||||||
|
this.termios.c_iflag = (this.termios.c_iflag & ~(tsyms.INLCR | tsyms.IGNCR)) | tsyms.ICRNL; // CR to NL
|
||||||
|
this.termios.writeTo(this.pty._fd);
|
||||||
|
|
||||||
|
this.terminal = terminal.createTerminal({
|
||||||
|
columns: context.size.columns,
|
||||||
|
rows: context.size.rows,
|
||||||
|
});
|
||||||
|
this.state = this.terminal.state;
|
||||||
|
this.renderer = new renderer.Renderer(reply, this.state, {
|
||||||
|
cursorString: "\uD83D\uDD38",
|
||||||
|
cursorBlinkString: "\uD83D\uDD38",
|
||||||
|
hidePreview: !context.linkPreviews,
|
||||||
|
unfinishedHidePreview: true,
|
||||||
|
silent: context.silent,
|
||||||
|
unfinishedSilent: true,
|
||||||
|
maxLinesWait: toUser ? 20 : 30,
|
||||||
|
maxLinesEmitted: 30,
|
||||||
|
lineTime: toUser ? 400 : 1200,
|
||||||
|
chunkTime: toUser ? 3000 : 6000,
|
||||||
|
editTime: toUser ? 300 : 2500,
|
||||||
|
unfinishedTime: toUser ? 1000 : 2000,
|
||||||
|
startFill: "· ",
|
||||||
|
});
|
||||||
|
this._initKeypad();
|
||||||
|
//FIXME: take additional steps to reduce messages sent to group. do typing actions count?
|
||||||
|
|
||||||
|
// Post initial message
|
||||||
|
this.initialMessage = new utils.EditedMessage(reply, this._renderInitial(), "HTML");
|
||||||
|
|
||||||
|
// Process command output
|
||||||
|
this.pty.on("data", this._ptyData.bind(this));
|
||||||
|
|
||||||
|
// Handle command exit
|
||||||
|
this.pty.on("exit", this._exit.bind(this));
|
||||||
|
}
|
||||||
|
util.inherits(Command, require("events").EventEmitter);
|
||||||
|
|
||||||
|
Command.prototype._renderInitial = function _renderInitial() {
|
||||||
|
var content = "", title = this.state.metas.title, badges = this.badges || "";
|
||||||
|
if (title) {
|
||||||
|
content += "<strong>" + escapeHtml(title) + "</strong>\n";
|
||||||
|
content += badges + "<strong>$</strong> " + escapeHtml(this.command);
|
||||||
|
} else {
|
||||||
|
content += badges + "<strong>$ " + escapeHtml(this.command) + "</strong>";
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
Command.prototype._ptyData = function _ptyData(chunk) {
|
||||||
|
//FIXME: implement some backpressure, for example, read smaller chunks, stop reading if there are >= 20 lines waiting to be pushed, set the HWM
|
||||||
|
if ((typeof chunk !== "string") && !(chunk instanceof String))
|
||||||
|
throw new Error("Expected a String, you liar.");
|
||||||
|
this.interacted = true;
|
||||||
|
this.terminal.write(chunk, "utf-8", this._update.bind(this));
|
||||||
|
};
|
||||||
|
|
||||||
|
Command.prototype._update = function _update() {
|
||||||
|
this.initialMessage.edit(this._renderInitial());
|
||||||
|
this.renderer.update();
|
||||||
|
};
|
||||||
|
|
||||||
|
Command.prototype.resize = function resize(size) {
|
||||||
|
this.interacted = true;
|
||||||
|
this.metaActive = false;
|
||||||
|
this.state.resize(size);
|
||||||
|
this._update();
|
||||||
|
this.pty.resize(size.columns, size.rows);
|
||||||
|
};
|
||||||
|
|
||||||
|
Command.prototype.redraw = function redraw() {
|
||||||
|
this.interacted = true;
|
||||||
|
this.metaActive = false;
|
||||||
|
this.pty.redraw();
|
||||||
|
};
|
||||||
|
|
||||||
|
Command.prototype.sendSignal = function sendSignal(signal, group) {
|
||||||
|
this.interacted = true;
|
||||||
|
this.metaActive = false;
|
||||||
|
var pid = this.pty.pid;
|
||||||
|
if (group) pid = -pid;
|
||||||
|
process.kill(pid, signal);
|
||||||
|
};
|
||||||
|
|
||||||
|
Command.prototype.sendEof = function sendEof() {
|
||||||
|
this.interacted = true;
|
||||||
|
this.metaActive = false;
|
||||||
|
|
||||||
|
// I don't know how to cause a 'buffer flush to the app' (the effect of Control+D)
|
||||||
|
// without actually pressing it into the console. So let's do just that.
|
||||||
|
// TTY needs to be in ICANON mode from the start, enabling it now doesn't work
|
||||||
|
|
||||||
|
// write EOF control character
|
||||||
|
this.termios.loadFrom(this.pty._fd);
|
||||||
|
this.pty.write(Buffer.from([ this.termios.c_cc[tsyms.VEOF] ]));
|
||||||
|
};
|
||||||
|
|
||||||
|
Command.prototype._exit = function _exit(code, signal) {
|
||||||
|
this._update();
|
||||||
|
this.renderer.flushUnfinished();
|
||||||
|
|
||||||
|
|
||||||
|
//FIXME: could wait until all edits are made before posting exited message
|
||||||
|
if ((Date.now() - this.startTime) < 2000 && !signal && code === 0 && !this.interacted) {
|
||||||
|
// For short lived commands that completed without output, we simply add a tick to the original message
|
||||||
|
this.badges = "\u2705 ";
|
||||||
|
this.initialMessage.edit(this._renderInitial());
|
||||||
|
} else {
|
||||||
|
if (signal)
|
||||||
|
this.reply.html("\uD83D\uDC80 <strong>Killed</strong> by %s.", utils.formatSignal(signal));
|
||||||
|
else if (code === 0)
|
||||||
|
this.reply.html("\u2705 <strong>Exited</strong> correctly.");
|
||||||
|
else
|
||||||
|
this.reply.html("\u26D4 <strong>Exited</strong> with %s.", code);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._removeKeypad();
|
||||||
|
this.emit("exit");
|
||||||
|
};
|
||||||
|
|
||||||
|
Command.prototype.handleReply = function handleReply(msg) {
|
||||||
|
//FIXME: feature: if photo, file, video, voice or music, put the terminal in raw mode, hold off further input, pipe binary asset to terminal, restore
|
||||||
|
//Flags we would need to touch: -INLCR -IGNCR -ICRNL -IUCLC -ISIG -ICANON -IEXTEN, and also for convenience -ECHO -ECHONL
|
||||||
|
|
||||||
|
if (msg.type !== "text") return false;
|
||||||
|
this.sendInput(msg.text);
|
||||||
|
};
|
||||||
|
|
||||||
|
Command.prototype.sendInput = function sendInput(text, noTerminate) {
|
||||||
|
this.interacted = true;
|
||||||
|
text = text.replace(/\n/g, "\r");
|
||||||
|
if (!noTerminate) text += "\r";
|
||||||
|
if (this.metaActive) text = "\x1b" + text;
|
||||||
|
this.pty.write(text);
|
||||||
|
this.metaActive = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
Command.prototype.toggleMeta = function toggleMeta(metaActive) {
|
||||||
|
if (metaActive === undefined) metaActive = !this.metaActive;
|
||||||
|
this.metaActive = metaActive;
|
||||||
|
};
|
||||||
|
|
||||||
|
Command.prototype.setSilent = function setSilent(silent) {
|
||||||
|
this.renderer.options.silent = silent;
|
||||||
|
};
|
||||||
|
|
||||||
|
Command.prototype.setLinkPreviews = function setLinkPreviews(linkPreviews) {
|
||||||
|
this.renderer.options.hidePreview = !linkPreviews;
|
||||||
|
};
|
||||||
|
|
||||||
|
Command.prototype._initKeypad = function _initKeypad() {
|
||||||
|
this.keypadToken = utils.generateToken();
|
||||||
|
|
||||||
|
var keys = {
|
||||||
|
esc: { label: "ESC", content: "\x1b" },
|
||||||
|
tab: { label: "⇥", content: "\t" },
|
||||||
|
enter: { label: "⏎", content: "\r" },
|
||||||
|
backspace: { label: "↤", content: "\x7F" },
|
||||||
|
space: { label: " ", content: " " },
|
||||||
|
|
||||||
|
up: { label: "↑", content: "\x1b[A", appKeypadContent: "\x1bOA" },
|
||||||
|
down: { label: "↓", content: "\x1b[B", appKeypadContent: "\x1bOB" },
|
||||||
|
right: { label: "→", content: "\x1b[C", appKeypadContent: "\x1bOC" },
|
||||||
|
left: { label: "←", content: "\x1b[D", appKeypadContent: "\x1bOD" },
|
||||||
|
|
||||||
|
insert: { label: "INS", content: "\x1b[2~" },
|
||||||
|
del: { label: "DEL", content: "\x1b[3~" },
|
||||||
|
home: { label: "⇱", content: "\x1bOH" },
|
||||||
|
end: { label: "⇲", content: "\x1bOF" },
|
||||||
|
|
||||||
|
prevPage: { label: "⇈", content: "\x1b[5~" },
|
||||||
|
nextPage: { label: "⇊", content: "\x1b[6~" },
|
||||||
|
};
|
||||||
|
|
||||||
|
var keypad = [
|
||||||
|
[ "esc", "up", "backspace", "del" ],
|
||||||
|
[ "left", "space", "right", "home" ],
|
||||||
|
[ "tab", "down", "enter", "end" ],
|
||||||
|
];
|
||||||
|
|
||||||
|
this.buttons = [];
|
||||||
|
this.inlineKeyboard = keypad.map(function (row) {
|
||||||
|
return row.map(function (name) {
|
||||||
|
var button = keys[name];
|
||||||
|
var data = JSON.stringify({ token: this.keypadToken, button: this.buttons.length });
|
||||||
|
var keyboardButton = { text: button.label, callback_data: data };
|
||||||
|
this.buttons.push(button);
|
||||||
|
return keyboardButton;
|
||||||
|
}.bind(this));
|
||||||
|
}.bind(this));
|
||||||
|
|
||||||
|
this.reply.bot.callback(function (query, next) {
|
||||||
|
try {
|
||||||
|
var data = JSON.parse(query.data);
|
||||||
|
} catch (e) { return next(); }
|
||||||
|
if (data.token !== this.keypadToken) return next();
|
||||||
|
this._keypadPressed(data.button, query);
|
||||||
|
}.bind(this));
|
||||||
|
};
|
||||||
|
|
||||||
|
Command.prototype.toggleKeypad = function toggleKeypad() {
|
||||||
|
if (this.keypadMessage) {
|
||||||
|
this.keypadMessage.markup = null;
|
||||||
|
this.keypadMessage.refresh();
|
||||||
|
this.keypadMessage = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME: this is pretty badly implemented, we should wait until last message (or message with cursor) has known id
|
||||||
|
var messages = this.renderer.messages;
|
||||||
|
var msg = messages[messages.length - 1].ref;
|
||||||
|
msg.markup = {inline_keyboard: this.inlineKeyboard};
|
||||||
|
msg.refresh();
|
||||||
|
this.keypadMessage = msg;
|
||||||
|
};
|
||||||
|
|
||||||
|
Command.prototype._keypadPressed = function _keypadPressed(id, query) {
|
||||||
|
this.interacted = true;
|
||||||
|
if (typeof id !== "number" || !(id in this.buttons)) return;
|
||||||
|
var button = this.buttons[id];
|
||||||
|
var content = button.content;
|
||||||
|
if (button.appKeypadContent !== undefined && this.state.getMode("appKeypad"))
|
||||||
|
content = button.appKeypadContent;
|
||||||
|
this.pty.write(content);
|
||||||
|
query.answer();
|
||||||
|
};
|
||||||
|
|
||||||
|
Command.prototype._removeKeypad = function _removeKeypad() {
|
||||||
|
if (this.keypadMessage) this.toggleKeypad();
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
exports.Command = Command;
|
Loading…
Reference in New Issue
Block a user