diff --git a/lib/command.js b/lib/command.js new file mode 100644 index 0000000..ad2f1c1 --- /dev/null +++ b/lib/command.js @@ -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 += "" + escapeHtml(title) + "\n"; + content += badges + "$ " + escapeHtml(this.command); + } else { + content += badges + "$ " + escapeHtml(this.command) + ""; + } + 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 Killed by %s.", utils.formatSignal(signal)); + else if (code === 0) + this.reply.html("\u2705 Exited correctly."); + else + this.reply.html("\u26D4 Exited 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;