├── .gitignore ├── config.example.json ├── commands.txt ├── package.json ├── README.md ├── lib ├── wizard.js ├── editor.js ├── utils.js ├── renderer.js ├── command.js └── terminal.js ├── server.js └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | /config.json 2 | node_modules/ 3 | npm-debug.log 4 | -------------------------------------------------------------------------------- /config.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "authToken": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", 3 | "owner": 34509246 4 | } 5 | -------------------------------------------------------------------------------- /commands.txt: -------------------------------------------------------------------------------- 1 | run - Execute command 2 | enter - Send input lines to command 3 | type - Type keys into command 4 | control - Type Control+Letter 5 | meta - Send the next typed key with Alt 6 | keypad - Toggle keypad for special keys 7 | redraw - Force the command to repaint 8 | end - Send EOF to command 9 | cancel - Interrupt command 10 | kill - Send signal to process 11 | status - View status and current settings 12 | cd - Change directory 13 | env - Manipulate the environment 14 | shell - Change shell used to run commands 15 | resize - Change the terminal size 16 | setsilent - Enable / disable silent output 17 | setlinkpreviews - Enable / disable link expansion 18 | setinteractive - Enable / disable shell interactive flag 19 | help - Get help 20 | file - View and edit small text files 21 | upload - Upload and overwrite raw files 22 | r - Alias for /run or /enter 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shell-bot", 3 | "private": true, 4 | "version": "0.0.1", 5 | "keywords": [ 6 | "telegram", 7 | "bot", 8 | "shell", 9 | "terminal", 10 | "botgram" 11 | ], 12 | "description": "Shellrunner Telegram bot", 13 | "homepage": "https://github.com/botgram/shell-bot", 14 | "dependencies": { 15 | "botgram": "^2.2", 16 | "escape-html": "1", 17 | "mime": "1", 18 | "node-pty": "^0.9.0", 19 | "node-termios": "0.0.13", 20 | "terminal.js": "1.0.9" 21 | }, 22 | "optionalDependencies": { 23 | "https-proxy-agent": "^5.0.0", 24 | "socks-proxy-agent": "^4.0.1" 25 | }, 26 | "bugs": { 27 | "url": "https://github.com/botgram/shell-bot/issues" 28 | }, 29 | "repository": { 30 | "type": "git", 31 | "url": "https://github.com/botgram/shell-bot.git" 32 | }, 33 | "license": "MIT", 34 | "author": "Alba Mendez (https://alba.sh)" 35 | } 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # shell-bot 2 | 3 | This is a fully functional shellrunner [Telegram bot][]. You tell it a 4 | command, it executes it and posts the live output. You can send input to the 5 | command by replying to the output messages. 6 | 7 | It's a fairly complex example, because it actually appears to the 8 | command as a terminal, interprets escape sequences and **it will 9 | update messages if their lines get touched**. This means interactive 10 | programs such as wget should work naturally, you should see the 11 | status bar update. 12 | 13 | The bot also allows files to be uploaded or downloaded, and also 14 | has a simple text editor available for convenience. 15 | 16 | Here's an example of the bot running `git` to clone a repository: 17 | 18 | ![Basic tasks](http://i.imgur.com/Xxtoe4G.png) 19 | 20 | Here's an example of the bot running alsamixer: 21 | 22 | ![Alsamixer with keypad](http://i.imgur.com/j8aXFLd.png) 23 | 24 | This bot demonstrates a great part of [Botgram][]'s API. 25 | 26 | **Note:** Due to the tight integration, running this bot on Windows is 27 | currently *not* supported. 28 | 29 | ## Install 30 | 31 | First install [node-pty dependencies](https://github.com/Microsoft/node-pty#dependencies). For example, if you're in Ubuntu/Debian: 32 | 33 | ~~~ 34 | sudo apt install -y make python build-essential 35 | ~~~ 36 | 37 | If you're using fedora instead: 38 | ``` 39 | sudo dnf install -y python 40 | sudo dnf group install -y "C Development Tools and Libraries" 41 | ``` 42 | 43 | Before using this, you should have obtained an auth token for your bot, 44 | and know your personal user's numeric ID. If you don't know what this 45 | means, check out the [blog post][] for a full step-by-step guide. 46 | 47 | ~~~ 48 | git clone https://github.com/botgram/shell-bot.git && cd shell-bot 49 | npm install 50 | ~~~ 51 | 52 | To start the bot: 53 | 54 | ~~~ 55 | node server 56 | ~~~ 57 | 58 | The first time you run it, it will ask you some questions and create 59 | the configuration file automatically: `config.json`. You can also 60 | write it manually, see `config.example.json`. 61 | 62 | When started it will print a `Bot ready.` message when it's up and running. 63 | For convenience, you might want to talk to the BotFather and set the 64 | command list to the contents of `commands.txt`. 65 | 66 | ## Authorization 67 | 68 | When first started, the bot will just accept messages coming from your user. 69 | This is for security reasons: you don't want arbitrary people to issue 70 | commands to your computer! 71 | 72 | If you want to allow another user to use the bot, use `/token` and give 73 | that user the resulting link. If you want to use this bot on a group, 74 | `/token` will give you a message to forward into the group. 75 | 76 | ## Proxy server 77 | 78 | shell-bot obeys the `https_proxy` or `all_proxy` environment variable 79 | to use a proxy, and supports HTTP/HTTPS/SOCKS4/SOCKS4A/SOCKS5 proxies. 80 | Examples: 81 | 82 | ~~~ bash 83 | export https_proxy="http://168.63.76.32:3128" 84 | node server 85 | 86 | export https_proxy="socks://127.0.0.1:9050" 87 | node server 88 | ~~~ 89 | 90 | **Warning:** For SOCKS proxies, you need to use an IP address (not a DNS hostname). 91 | 92 | 93 | 94 | [Telegram bot]: https://core.telegram.org/bots 95 | [Botgram]: https://botgram.js.org 96 | [blog post]: https://alba.sh/blog/telegram-shell-bot/ 97 | -------------------------------------------------------------------------------- /lib/wizard.js: -------------------------------------------------------------------------------- 1 | var readline = require("readline"); 2 | var botgram = require("botgram"); 3 | var fs = require("fs"); 4 | var util = require("util"); 5 | var utils = require("./utils"); 6 | 7 | // Wizard functions 8 | 9 | function stepAuthToken(rl, config) { 10 | return question(rl, "First, enter your bot API token: ") 11 | .then(function (token) { 12 | token = token.trim(); 13 | //if (!/^\d{5,}:[a-zA-Z0-9_+/-]{20,}$/.test(token)) 14 | // throw new Error(); 15 | config.authToken = token; 16 | return createBot(token); 17 | }).catch(function (err) { 18 | console.error("Invalid token was entered, please try again.\n%s\n", err); 19 | return stepAuthToken(rl, config); 20 | }); 21 | } 22 | 23 | function stepOwner(rl, config, getNextMessage) { 24 | console.log("Waiting for a message..."); 25 | return getNextMessage().then(function (msg) { 26 | var prompt = util.format("Should %s «%s» (%s) be the bot's owner? [y/n]: ", msg.chat.type, msg.chat.name, msg.chat.id); 27 | return question(rl, prompt) 28 | .then(function (answer) { 29 | console.log(); 30 | answer = answer.trim().toLowerCase(); 31 | if (answer === "y" || answer === "yes") 32 | config.owner = msg.chat.id; 33 | else 34 | return stepOwner(rl, config, getNextMessage); 35 | }); 36 | }); 37 | } 38 | 39 | function configWizard(options) { 40 | var rl = readline.createInterface({ 41 | input: process.stdin, 42 | output: process.stdout, 43 | }); 44 | var config = {}; 45 | var bot = null; 46 | 47 | return Promise.resolve() 48 | .then(function () { 49 | return stepAuthToken(rl, config); 50 | }) 51 | .then(function (bot_) { 52 | bot = bot_; 53 | console.log("\nNow, talk to me so I can discover your Telegram user:\n%s\n", bot.link()); 54 | }) 55 | .then(function () { 56 | var getNextMessage = getPromiseFactory(bot); 57 | return stepOwner(rl, config, getNextMessage); 58 | }) 59 | .then(function () { 60 | console.log("All done, writing the configuration..."); 61 | var contents = JSON.stringify(config, null, 4) + "\n"; 62 | return writeFile(options.configFile, contents); 63 | }) 64 | 65 | .catch(function (err) { 66 | console.error("Error, wizard crashed:\n%s", err.stack); 67 | process.exit(1); 68 | }) 69 | .then(function () { 70 | rl.close(); 71 | if (bot) bot.stop(); 72 | process.exit(0); 73 | }); 74 | } 75 | 76 | // Promise utilities 77 | 78 | function question(interface, query) { 79 | return new Promise(function (resolve, reject) { 80 | interface.question(query, resolve); 81 | }); 82 | } 83 | 84 | function writeFile(file, contents) { 85 | return new Promise(function (resolve, reject) { 86 | fs.writeFile(file, contents, "utf-8", function (err) { 87 | if (err) reject(err); 88 | else resolve(); 89 | }); 90 | }); 91 | } 92 | 93 | function createBot(token) { 94 | return new Promise(function (resolve, reject) { 95 | var bot = botgram(token, { agent: utils.createAgent() }); 96 | bot.on("error", function (err) { 97 | bot.stop(); 98 | reject(err); 99 | }); 100 | bot.on("ready", resolve.bind(this, bot)); 101 | }); 102 | } 103 | 104 | function getPromiseFactory(bot) { 105 | var resolveCbs = []; 106 | bot.message(function (msg, reply, next) { 107 | if (!msg.queued) { 108 | resolveCbs.forEach(function (resolve) { 109 | resolve(msg); 110 | }); 111 | resolveCbs = []; 112 | } 113 | next(); 114 | }); 115 | return function () { 116 | return new Promise(function (resolve, reject) { 117 | resolveCbs.push(resolve); 118 | }); 119 | }; 120 | } 121 | 122 | 123 | 124 | exports.configWizard = configWizard; 125 | -------------------------------------------------------------------------------- /lib/editor.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Implements a simple select-replace file editor in Telegram. 3 | * It works as follows: 4 | * 5 | * 1. The user invokes the editor with a non-empty file. 6 | * 2. The contents of the file are posted as a message. 7 | * 3. The user replies to that message with (part of) the text. 8 | * The bot will locate that substring in the file contents and track the message. 9 | * 4. The user edits his message. 10 | * The bot will then replace the original substring, save the file and edit its message. 11 | * If there are any problems with saving the file, the editor may detach. 12 | * 13 | * NOTE: sync I/O is used for simplicity; be careful! (TODO) 14 | **/ 15 | 16 | var fs = require("fs"); 17 | var escapeHtml = require("escape-html"); 18 | var utils = require("./utils"); 19 | 20 | function ChunkedString(text) { 21 | this.text = text; 22 | this.chunks = []; 23 | } 24 | 25 | ChunkedString.prototype.findAcquire = function findAcquire(text) { 26 | if (text.length == 0) throw Error("Empty find text not allowed"); 27 | var index = this.text.indexOf(text); 28 | if (index == -1) 29 | throw Error("The substring was not found. Wrapping in tildes may be necessary."); 30 | if (index != this.text.lastIndexOf(text)) 31 | throw Error("There are multiple instances of the passed substring"); 32 | return this.acquire(index, text.length); 33 | }; 34 | 35 | ChunkedString.prototype.acquire = function acquire(offset, length) { 36 | if (offset < 0 || length <= 0 || offset + length > this.text.length) 37 | throw Error("Invalid coordinates"); 38 | for (var i = 0; i < this.chunks.length; i++) { 39 | var c = this.chunks[i]; 40 | if (offset + length > c.offset || c.offset + c.text.length > offset) 41 | throw Error("Chunk overlaps"); 42 | } 43 | var chunk = { offset: offset, text: this.text.substring(offset, offset + length) }; 44 | this.chunks.push(chunk); 45 | return chunk; 46 | }; 47 | 48 | ChunkedString.prototype.release = function release(chunk) { 49 | if (this.chunks.indexOf(chunk) == -1) throw Error("Invalid chunk given"); 50 | this.chunks.splice(index, 1); 51 | }; 52 | 53 | ChunkedString.prototype.modify = function modify(chunk, text) { 54 | if (this.chunks.indexOf(chunk) == -1) throw Error("Invalid chunk given"); 55 | if (text.length == 0) throw Error("Empty replacement not allowed"); 56 | var end = chunk.offset + chunk.text.length; 57 | this.text = this.text.substring(0, chunk.offset) + text + this.text.substring(end); 58 | var diff = text.length - chunk.text.length; 59 | chunk.text = text; 60 | this.chunks.forEach(function (c) { 61 | if (c.offset > chunk.offset) c.offset += diff; 62 | }); 63 | }; 64 | 65 | 66 | function Editor(reply, file, encoding) { 67 | if (!encoding) encoding = "utf-8"; 68 | this.reply = reply; 69 | this.file = file; 70 | this.encoding = encoding; 71 | 72 | // TODO: support for longer files (paginated, etc.) 73 | // FIXME: do it correctly using fd, keeping it open 74 | var contents = fs.readFileSync(file, encoding); 75 | if (contents.length > 1500 || contents.split("\n") > 50) 76 | throw Error("The file is too long"); 77 | 78 | this.contents = new ChunkedString(contents); 79 | this.chunks = {}; // associates each message ID to an active chunk 80 | 81 | this.message = new utils.EditedMessage(reply, this._render(), "HTML"); 82 | this.fileTouched = false; 83 | } 84 | 85 | Editor.prototype._render = function _render() { 86 | if (!this.contents.text.trim()) return "(empty file)"; 87 | return "
" + escapeHtml(this.contents.text) + "
"; 88 | }; 89 | 90 | Editor.prototype.handleReply = function handleReply(msg) { 91 | this.message.idPromise.then(function (id) { 92 | if (this.detached) return; 93 | if (msg.reply.id != id) return; 94 | try { 95 | this.chunks[msg.id] = this.contents.findAcquire(msg.text); 96 | } catch (e) { 97 | this.reply.html("%s", e.message); 98 | } 99 | }.bind(this)); 100 | }; 101 | 102 | Editor.prototype.handleEdit = function handleEdit(msg) { 103 | if (this.detached) return false; 104 | if (!Object.hasOwnProperty.call(this.chunks, msg.id)) return false; 105 | this.contents.modify(this.chunks[msg.id], msg.text); 106 | this.attemptSave(); 107 | return true; 108 | }; 109 | 110 | Editor.prototype.attemptSave = function attemptSave() { 111 | this.fileTouched = true; 112 | process.nextTick(function () { 113 | if (!this.fileTouched) return; 114 | if (this.detached) return; 115 | this.fileTouched = false; 116 | 117 | // TODO: check for file external modification, fail then 118 | try { 119 | fs.writeFileSync(this.file, this.contents.text, this.encoding); 120 | } catch (e) { 121 | this.reply.html("Couldn't save file: %s", e.message); 122 | return; 123 | } 124 | this.message.edit(this._render()); 125 | }.bind(this)); 126 | }; 127 | 128 | Editor.prototype.detach = function detach() { 129 | this.detached = true; 130 | }; 131 | 132 | module.exports.Editor = Editor; 133 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Miscellaneous utilities. 3 | **/ 4 | 5 | var fs = require("fs"); 6 | var util = require("util"); 7 | var mime = require("mime"); 8 | var crypto = require("crypto"); 9 | var url = require("url"); 10 | 11 | 12 | /** TIMER **/ 13 | 14 | function Timer(delay) { 15 | this.delay = delay; 16 | } 17 | util.inherits(Timer, require("events").EventEmitter); 18 | 19 | /* Starts the timer, does nothing if started already. */ 20 | Timer.prototype.set = function set() { 21 | if (this.timeout) return; 22 | this.timeout = setTimeout(function () { 23 | this.timeout = null; 24 | this.emit("fire"); 25 | }.bind(this), this.delay); 26 | this.emit("active"); 27 | }; 28 | 29 | /* Cancels the timer if set. */ 30 | Timer.prototype.cancel = function cancel() { 31 | if (!this.timeout) return; 32 | clearTimeout(this.timeout); 33 | delete this.timeout; 34 | }; 35 | 36 | /* Starts the timer, cancelling first if set. */ 37 | Timer.prototype.reset = function reset() { 38 | this.cancel(); 39 | this.set(); 40 | }; 41 | 42 | /** EDITED MESSAGE **/ 43 | 44 | function EditedMessage(reply, text, mode) { 45 | this.reply = reply; 46 | this.mode = mode; 47 | 48 | this.lastText = text; 49 | this.markup = reply.parameters["reply_markup"]; 50 | this.disablePreview = reply.parameters["disable_web_page_preview"]; 51 | this.text = text; 52 | this.callbacks = []; 53 | this.pendingText = null; 54 | this.pendingCallbacks = []; 55 | 56 | this.idPromise = new Promise(function (resolve, reject) { 57 | reply.text(this.text, this.mode).then(function (err, msg) { 58 | if (err) reject(err); 59 | else resolve(msg.id); 60 | this._whenEdited(err, msg); 61 | }.bind(this)); 62 | }.bind(this)); 63 | } 64 | util.inherits(EditedMessage, require("events").EventEmitter); 65 | 66 | EditedMessage.prototype.refresh = function refresh(callback) { 67 | if (callback) this.pendingCallbacks.push(callback); 68 | this.pendingText = this.lastText; 69 | if (this.callbacks === undefined) this._flushEdit(); 70 | }; 71 | 72 | EditedMessage.prototype.edit = function edit(text, callback) { 73 | this.lastText = text; 74 | var idle = this.callbacks === undefined; 75 | if (callback) this.pendingCallbacks.push(callback); 76 | 77 | if (text === this.text) { 78 | this.callbacks = (this.callbacks || []).concat(this.pendingCallbacks); 79 | this.pendingText = null; 80 | this.pendingCallbacks = []; 81 | if (idle) this._whenEdited(); 82 | } else { 83 | this.pendingText = text; 84 | if (idle) this._flushEdit(); 85 | } 86 | }; 87 | 88 | EditedMessage.prototype._flushEdit = function _flushEdit() { 89 | this.text = this.pendingText; 90 | this.callbacks = this.pendingCallbacks; 91 | this.pendingText = null; 92 | this.pendingCallbacks = []; 93 | this.reply.parameters["reply_markup"] = this.markup; 94 | this.reply.parameters["disable_web_page_preview"] = this.disablePreview; 95 | this.reply.editText(this.id, this.text, this.mode).then(this._whenEdited.bind(this)); 96 | }; 97 | 98 | EditedMessage.prototype._whenEdited = function _whenEdited(err, msg) { 99 | if (err) this.emit(this.id === undefined ? "error" : "editError", err); 100 | if (this.id === undefined) this.id = msg.id; 101 | var callbacks = this.callbacks; 102 | delete this.callbacks; 103 | callbacks.forEach(function (callback) { callback(); }); 104 | if (this.pendingText !== null) this._flushEdit(); 105 | }; 106 | 107 | /** SANITIZED ENV **/ 108 | 109 | function getSanitizedEnv() { 110 | // Adapted from pty.js source 111 | var env = {}; 112 | Object.keys(process.env).forEach(function (key) { 113 | env[key] = process.env[key]; 114 | }); 115 | 116 | // Make sure we didn't start our 117 | // server from inside tmux. 118 | delete env.TMUX; 119 | delete env.TMUX_PANE; 120 | 121 | // Make sure we didn't start 122 | // our server from inside screen. 123 | // http://web.mit.edu/gnu/doc/html/screen_20.html 124 | delete env.STY; 125 | delete env.WINDOW; 126 | 127 | // Delete some variables that 128 | // might confuse our terminal. 129 | delete env.WINDOWID; 130 | delete env.TERMCAP; 131 | delete env.COLUMNS; 132 | delete env.LINES; 133 | 134 | // Set $TERM to screen. This disables multiplexers 135 | // that have login hooks, such as byobu. 136 | env.TERM = "screen"; 137 | 138 | return env; 139 | } 140 | 141 | /** RESOLVE SIGNAL **/ 142 | 143 | var SIGNALS = "HUP INT QUIT ILL TRAP ABRT BUS FPE KILL USR1 SEGV USR2 PIPE ALRM TERM STKFLT CHLD CONT STOP TSTP TTIN TTOU URG XCPU XFSZ VTALRM PROF WINCH POLL PWR SYS".split(" "); 144 | 145 | function formatSignal(signal) { 146 | signal--; 147 | if (signal in SIGNALS) return "SIG" + SIGNALS[signal]; 148 | return "unknown signal " + signal; 149 | } 150 | 151 | /** SHELLS **/ 152 | 153 | function getShells() { 154 | var lines = fs.readFileSync("/etc/shells", "utf-8").split("\n") 155 | var shells = lines.map(function (line) { return line.split("#")[0]; }) 156 | .filter(function (line) { return line.trim().length; }); 157 | // Add process.env.SHELL at #1 position 158 | var shell = process.env.SHELL; 159 | if (shell) { 160 | var idx = shells.indexOf(shell); 161 | if (idx !== -1) shells.splice(idx, 1); 162 | shells.unshift(shell); 163 | } 164 | return shells; 165 | } 166 | 167 | var shells = getShells(); 168 | 169 | /** RESOLVE SHELLS **/ 170 | 171 | function resolveShell(shell) { 172 | return shell; //TODO: if found in list, otherwise resolve with which & verify access 173 | } 174 | 175 | /** TOKEN GENERATION **/ 176 | 177 | function generateToken() { 178 | return crypto.randomBytes(12).toString("hex"); 179 | } 180 | 181 | /** RESOLVE BOOLEAN **/ 182 | 183 | var BOOLEANS = { 184 | "yes": true, "no": false, 185 | "y": true, "n": false, 186 | "on": true, "off": false, 187 | "enable": true, "disable": false, 188 | "enabled": true, "disabled": false, 189 | "active": true, "inactive": false, 190 | "true": true, "false": false, 191 | }; 192 | 193 | function resolveBoolean(arg) { 194 | arg = arg.trim().toLowerCase(); 195 | if (!Object.hasOwnProperty.call(BOOLEANS, arg)) return null; 196 | return BOOLEANS[arg]; 197 | } 198 | 199 | /** GENERATE FILENAME WHEN NOT AVAILABLE **/ 200 | 201 | function constructFilename(msg) { 202 | return "upload." + mime.extension(msg.file.mime); 203 | } 204 | 205 | /** AGENT **/ 206 | 207 | function createAgent() { 208 | var proxy = process.env["https_proxy"] || process.env["all_proxy"]; 209 | if (!proxy) return; 210 | 211 | try { 212 | proxy = url.parse(proxy); 213 | } catch (e) { 214 | console.error("Error parsing proxy URL:", e, "Ignoring proxy."); 215 | return; 216 | } 217 | 218 | if ([ "socks:", "socks4:", "socks4a:", "socks5:", "socks5h:" ].indexOf(proxy.protocol) !== -1) { 219 | try { 220 | var SocksProxyAgent = require('socks-proxy-agent'); 221 | } catch (e) { 222 | console.error("Error loading SOCKS proxy support, verify socks-proxy-agent is correctly installed. Ignoring proxy."); 223 | return; 224 | } 225 | return new SocksProxyAgent(proxy); 226 | } 227 | if ([ "http:", "https:" ].indexOf(proxy.protocol) !== -1) { 228 | try { 229 | var HttpsProxyAgent = require('https-proxy-agent'); 230 | } catch (e) { 231 | console.error("Error loading HTTPS proxy support, verify https-proxy-agent is correctly installed. Ignoring proxy."); 232 | return; 233 | } 234 | return new HttpsProxyAgent(proxy); 235 | } 236 | 237 | console.error("Unknown proxy protocol:", util.inspect(proxy.protocol), "Ignoring proxy."); 238 | } 239 | 240 | 241 | 242 | exports.Timer = Timer; 243 | exports.EditedMessage = EditedMessage; 244 | exports.getSanitizedEnv = getSanitizedEnv; 245 | exports.formatSignal = formatSignal; 246 | exports.shells = shells; 247 | exports.resolveShell = resolveShell; 248 | exports.generateToken = generateToken; 249 | exports.resolveBoolean = resolveBoolean; 250 | exports.constructFilename = constructFilename; 251 | exports.createAgent = createAgent; 252 | -------------------------------------------------------------------------------- /lib/renderer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This class keeps a logical mapping of lines to messages. 3 | * It doesn't actually render or send messages, it delegates 4 | * that task to the renderer. 5 | * 6 | * FIXME: do something to prevent extremely long messages to be 7 | * sent (and rejected) when too many lines are inserted in between 8 | * a message. 9 | **/ 10 | 11 | var escapeHtml = require("escape-html"); 12 | var utils = require("./utils"); 13 | 14 | function Renderer(reply, state, options) { 15 | if (!options) options = {}; 16 | this.reply = reply; 17 | this.state = state; 18 | this.options = options; 19 | 20 | this.offset = 0; 21 | this.messages = []; 22 | this.orphanLines = []; 23 | this.unfinishedLine = null; 24 | this.totalLines = 0; 25 | 26 | state.on("lineChanged", this._lineChanged.bind(this)); 27 | state.on("linesRemoving", this._linesRemoving.bind(this)); 28 | state.on("linesScrolling", this._linesScrolling.bind(this)); 29 | state.on("linesInserted", this._linesInserted.bind(this)); 30 | 31 | this.initTimers(); 32 | } 33 | 34 | 35 | /** MESSAGE MAPPING **/ 36 | 37 | Renderer.prototype.ensureLinesCreated = function ensureLinesCreated(y) { 38 | if (this.totalLines < y) { 39 | this.orphanLines = this.orphanLines.concat(this.state.lines.slice(this.totalLines, y)); 40 | this.totalLines = y; 41 | this.newLinesChanged = true; 42 | } 43 | }; 44 | 45 | Renderer.prototype._lineChanged = function _lineChanged(y) { 46 | if (this.state.length - y <= this.orphanLines.length) 47 | this.newLinesChanged = true; 48 | }; 49 | 50 | Renderer.prototype._linesRemoving = function _linesRemoving(y, n) { 51 | this.ensureLinesCreated(this.state.lines.length); 52 | 53 | // Seek until we arrive at the wanted line 54 | y += this.offset; 55 | var idx = 0, lineIdx = 0; 56 | while (y) { 57 | var lines = (idx === this.messages.length) ? this.orphanLines : this.messages[idx].lines; 58 | if (lineIdx < lines.length) { lineIdx++; y--; } 59 | else { idx++; lineIdx = 0; } 60 | } 61 | 62 | // Remove following lines 63 | this.totalLines -= n; 64 | while (n) { 65 | var lines = (idx === this.messages.length) ? this.orphanLines : this.messages[idx].lines; 66 | if (lines.splice(lineIdx, 1).length) n--; 67 | else { idx++; lineIdx = 0; } 68 | } 69 | 70 | if (idx >= this.messages.length) this.newLinesChanged = true; 71 | }; 72 | 73 | Renderer.prototype._linesScrolling = function _linesScrolling(n) { 74 | this.ensureLinesCreated(this.state.lines.length); 75 | 76 | if (n > 0) { 77 | // Scrolling up: increment offset, discarding message if necessary 78 | this.offset += n; 79 | this.totalLines -= n; 80 | while (this.messages.length) { 81 | var message = this.messages[0]; 82 | if (message.lines.length > this.offset) break; 83 | if (message.rendered !== message.ref.lastText) break; 84 | this.offset -= message.lines.length; 85 | this.messages.shift(); 86 | } 87 | } else { 88 | // Scrolling down: just delete bottom lines (leaving them would complicate everything) 89 | n = -n; 90 | this._linesRemoving(this.state.lines.length - n, n); 91 | } 92 | }; 93 | 94 | Renderer.prototype._linesInserted = function _linesInserted(y, n) { 95 | this.ensureLinesCreated(y); 96 | var pos = y; 97 | 98 | // Seek until we arrive at the wanted line, *just before the next one* 99 | y += this.offset; 100 | var idx = 0, lineIdx = 0; 101 | while (true) { 102 | var lines = (idx === this.messages.length) ? this.orphanLines : this.messages[idx].lines; 103 | if (lineIdx < lines.length) { 104 | if (!y) break; 105 | lineIdx++; y--; 106 | } else { idx++; lineIdx = 0; } 107 | } 108 | 109 | // Insert lines 110 | this.totalLines += n; 111 | while (n) { 112 | var lines = (idx === this.messages.length) ? this.orphanLines : this.messages[idx].lines; 113 | lines.splice(lineIdx, 0, this.state.lines[pos]); 114 | n--, lineIdx++, pos++; 115 | } 116 | 117 | if (idx === this.messages.length) this.newLinesChanged = true; 118 | }; 119 | 120 | Renderer.prototype.update = function update() { 121 | this.ensureLinesCreated(this.state.lines.length); 122 | 123 | // Rerender messages, scheduling flush if some changed 124 | var linesChanged = false; 125 | this.messages.forEach(function (message) { 126 | var rendered = this.render(message); 127 | if (rendered !== message.rendered) { 128 | message.rendered = rendered; 129 | linesChanged = true; 130 | } 131 | }.bind(this)); 132 | 133 | if (linesChanged) this.editedLineTimer.set(); 134 | if (this.newLinesChanged) this.newLineTimer.reset(); 135 | this.newLinesChanged = false; 136 | 137 | // Make sure orphan lines are processed 138 | this.orphanLinesUpdated(); 139 | }; 140 | 141 | Renderer.prototype.emitMessage = function emitMessage(count, silent, disablePreview) { 142 | if (count < 0 || count > this.orphanLines.length) throw new Error("Should not happen."); 143 | 144 | if (count > this.options.maxLinesEmitted) 145 | count = this.options.maxLinesEmitted; 146 | var lines = this.orphanLines.splice(0, count); 147 | var message = { lines: lines }; 148 | this.messages.push(message); 149 | message.rendered = this.render(message); 150 | var reply = this.reply.silent(silent).disablePreview(disablePreview); 151 | message.ref = new utils.EditedMessage(reply, message.rendered, "HTML"); 152 | this.orphanLinesUpdated(); 153 | }; 154 | 155 | 156 | /** HTML RENDERING **/ 157 | 158 | /* Given a line, return true if potentially monospaced */ 159 | Renderer.prototype.evaluateCode = function evaluateCode(str) { 160 | //FIXME: line just between two code lines should be come code 161 | if (str.indexOf(" ") !== -1 || /[-_,:;<>()/\\~|'"=^]{4}/.exec(str)) 162 | return true; 163 | return false; 164 | }; 165 | 166 | /* Given a message object, render to an HTML snippet */ 167 | Renderer.prototype.render = function render(message) { 168 | var cursorString = this.state.getMode("cursorBlink") ? this.options.cursorBlinkString : this.options.cursorString; 169 | var isWhitespace = true, x = this.state.cursor[0]; 170 | 171 | var html = message.lines.map(function (line, idx) { 172 | var hasCursor = (this.state.getMode("cursor")) && (this.state.getLine() === line); 173 | if (!line.code && this.evaluateCode(line.str)) line.code = true; 174 | 175 | var content = line.str; 176 | if (hasCursor || line.str.trim().length) isWhitespace = false; 177 | if (idx === 0 && !content.substring(0, this.options.startFill.length).trim()) { 178 | // The message would start with spaces, which would get trimmed by telegram 179 | if (!(hasCursor && x < this.options.startFill.length)) 180 | content = this.options.startFill + content.substring(this.options.startFill.length); 181 | } 182 | 183 | if (hasCursor) 184 | content = escapeHtml(content.substring(0, x)) + cursorString + escapeHtml(content.substring(x)); 185 | else 186 | content = escapeHtml(content); 187 | 188 | if (line.code) content = "" + content + ""; 189 | return content; 190 | }.bind(this)).join("\n"); 191 | 192 | if (isWhitespace) return "(empty)"; 193 | return html; 194 | }; 195 | 196 | 197 | /** FLUSH SCHEDULING **/ 198 | 199 | Renderer.prototype.initTimers = function initTimers() { 200 | // Set when an existent line changes, cancelled when edited lines flushed 201 | this.editedLineTimer = new utils.Timer(this.options.editTime).on("fire", this.flushEdited.bind(this)); 202 | 203 | // Set when a new line is added or changed, cancelled on new lines flush 204 | this.newChunkTimer = new utils.Timer(this.options.chunkTime).on("fire", this.flushNew.bind(this)); 205 | // Reset when a new line is added or changed, cancelled on new lines flush 206 | this.newLineTimer = new utils.Timer(this.options.lineTime).on("fire", this.flushNew.bind(this)); 207 | 208 | // Set when there is an unfinished nonempty line, cancelled when reference changes or line becomes empty 209 | this.unfinishedLineTimer = new utils.Timer(this.options.unfinishedTime).on("fire", this.flushUnfinished.bind(this)); 210 | 211 | this.newChunkTimer.on("active", function () { 212 | this.reply.action("typing"); 213 | }.bind(this)); 214 | //FIXME: should we emit actions on edits? 215 | }; 216 | 217 | Renderer.prototype.orphanLinesUpdated = function orphanLinesUpdated() { 218 | var newLines = this.orphanLines.length - 1; 219 | if (newLines >= this.options.maxLinesWait) { 220 | // Flush immediately 221 | this.flushNew(); 222 | } else if (newLines > 0) { 223 | this.newChunkTimer.set(); 224 | } else { 225 | this.newChunkTimer.cancel(); 226 | this.newLineTimer.cancel(); 227 | } 228 | 229 | // Update unfinished line 230 | var unfinishedLine = this.orphanLines[this.orphanLines.length - 1]; 231 | if (unfinishedLine && this.totalLines === this.state.rows && unfinishedLine.str.length === this.state.columns) 232 | unfinishedLine = null; 233 | 234 | if (this.unfinishedLine !== unfinishedLine) { 235 | this.unfinishedLine = unfinishedLine; 236 | this.unfinishedLineTimer.cancel(); 237 | } 238 | 239 | if (unfinishedLine && unfinishedLine.str.length) this.unfinishedLineTimer.set(); 240 | else this.unfinishedLineTimer.cancel(); 241 | }; 242 | 243 | Renderer.prototype.flushEdited = function flushEdited() { 244 | this.messages.forEach(function (message) { 245 | if (message.rendered !== message.ref.lastText) 246 | message.ref.edit(message.rendered); 247 | }); 248 | this.editedLineTimer.cancel(); 249 | }; 250 | 251 | Renderer.prototype.flushNew = function flushNew() { 252 | this.flushEdited(); 253 | var count = this.orphanLines.length; 254 | if (this.unfinishedLine) count--; 255 | if (count <= 0) return; 256 | this.emitMessage(count, !!this.options.silent, !!this.options.hidePreview); 257 | }; 258 | 259 | Renderer.prototype.flushUnfinished = function flushUnfinished() { 260 | do this.flushNew(); while (this.orphanLines.length > 1); 261 | if (this.orphanLines.length < 1 || this.orphanLines[0].str.length === 0) return; 262 | this.emitMessage(1, !!this.options.unfinishedSilent, !!this.options.unfinishedHidePreview); 263 | }; 264 | 265 | 266 | 267 | exports.Renderer = Renderer; 268 | -------------------------------------------------------------------------------- /lib/command.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Attaches to a chat, spawns a pty, attaches it to the terminal emulator 3 | * and the renderer and manages them. Handles incoming commands & input, 4 | * and posts complimentary messages such as command itself and output code. 5 | **/ 6 | 7 | var util = require("util"); 8 | var escapeHtml = require("escape-html"); 9 | var pty = require("node-pty"); 10 | var termios = require("node-termios"); 11 | var utils = require("./utils"); 12 | var terminal = require("./terminal"); 13 | var renderer = require("./renderer"); 14 | var tsyms = termios.native.ALL_SYMBOLS; 15 | 16 | function Command(reply, context, command) { 17 | var toUser = reply.destination > 0; 18 | 19 | this.startTime = Date.now(); 20 | this.reply = reply; 21 | this.command = command; 22 | this.pty = pty.spawn(context.shell, [context.interactive ? "-ic" : "-c", command], { 23 | cols: context.size.columns, 24 | rows: context.size.rows, 25 | cwd: context.cwd, 26 | env: context.env, 27 | }); 28 | this.termios = new termios.Termios(this.pty._fd); 29 | this.termios.c_lflag &= ~(tsyms.ISIG | tsyms.IEXTEN); 30 | this.termios.c_lflag &= ~tsyms.ECHO; // disable ECHO 31 | this.termios.c_lflag |= tsyms.ICANON | tsyms.ECHONL; // we need it for /end, it needs to be active beforehand 32 | this.termios.c_iflag = (this.termios.c_iflag & ~(tsyms.INLCR | tsyms.IGNCR)) | tsyms.ICRNL; // CR to NL 33 | this.termios.writeTo(this.pty._fd); 34 | 35 | this.terminal = terminal.createTerminal({ 36 | columns: context.size.columns, 37 | rows: context.size.rows, 38 | }); 39 | this.state = this.terminal.state; 40 | this.renderer = new renderer.Renderer(reply, this.state, { 41 | cursorString: "\uD83D\uDD38", 42 | cursorBlinkString: "\uD83D\uDD38", 43 | hidePreview: !context.linkPreviews, 44 | unfinishedHidePreview: true, 45 | silent: context.silent, 46 | unfinishedSilent: true, 47 | maxLinesWait: toUser ? 20 : 30, 48 | maxLinesEmitted: 30, 49 | lineTime: toUser ? 400 : 1200, 50 | chunkTime: toUser ? 3000 : 6000, 51 | editTime: toUser ? 300 : 2500, 52 | unfinishedTime: toUser ? 1000 : 2000, 53 | startFill: "· ", 54 | }); 55 | this._initKeypad(); 56 | //FIXME: take additional steps to reduce messages sent to group. do typing actions count? 57 | 58 | // Post initial message 59 | this.initialMessage = new utils.EditedMessage(reply, this._renderInitial(), "HTML"); 60 | 61 | // Process command output 62 | this.pty.on("data", this._ptyData.bind(this)); 63 | 64 | // Handle command exit 65 | this.pty.on("exit", this._exit.bind(this)); 66 | } 67 | util.inherits(Command, require("events").EventEmitter); 68 | 69 | Command.prototype._renderInitial = function _renderInitial() { 70 | var content = "", title = this.state.metas.title, badges = this.badges || ""; 71 | if (title) { 72 | content += "" + escapeHtml(title) + "\n"; 73 | content += badges + "$ " + escapeHtml(this.command); 74 | } else { 75 | content += badges + "$ " + escapeHtml(this.command) + ""; 76 | } 77 | return content; 78 | } 79 | 80 | Command.prototype._ptyData = function _ptyData(chunk) { 81 | //FIXME: implement some backpressure, for example, read smaller chunks, stop reading if there are >= 20 lines waiting to be pushed, set the HWM 82 | if ((typeof chunk !== "string") && !(chunk instanceof String)) 83 | throw new Error("Expected a String, you liar."); 84 | this.interacted = true; 85 | this.terminal.write(chunk, "utf-8", this._update.bind(this)); 86 | }; 87 | 88 | Command.prototype._update = function _update() { 89 | this.initialMessage.edit(this._renderInitial()); 90 | this.renderer.update(); 91 | }; 92 | 93 | Command.prototype.resize = function resize(size) { 94 | this.interacted = true; 95 | this.metaActive = false; 96 | this.state.resize(size); 97 | this._update(); 98 | this.pty.resize(size.columns, size.rows); 99 | }; 100 | 101 | Command.prototype.redraw = function redraw() { 102 | this.interacted = true; 103 | this.metaActive = false; 104 | this.pty.redraw(); 105 | }; 106 | 107 | Command.prototype.sendSignal = function sendSignal(signal, group) { 108 | this.interacted = true; 109 | this.metaActive = false; 110 | var pid = this.pty.pid; 111 | if (group) pid = -pid; 112 | process.kill(pid, signal); 113 | }; 114 | 115 | Command.prototype.sendEof = function sendEof() { 116 | this.interacted = true; 117 | this.metaActive = false; 118 | 119 | // I don't know how to cause a 'buffer flush to the app' (the effect of Control+D) 120 | // without actually pressing it into the console. So let's do just that. 121 | // TTY needs to be in ICANON mode from the start, enabling it now doesn't work 122 | 123 | // write EOF control character 124 | this.termios.loadFrom(this.pty._fd); 125 | this.pty.write(Buffer.from([ this.termios.c_cc[tsyms.VEOF] ])); 126 | }; 127 | 128 | Command.prototype._exit = function _exit(code, signal) { 129 | this._update(); 130 | this.renderer.flushUnfinished(); 131 | 132 | 133 | //FIXME: could wait until all edits are made before posting exited message 134 | if ((Date.now() - this.startTime) < 2000 && !signal && code === 0 && !this.interacted) { 135 | // For short lived commands that completed without output, we simply add a tick to the original message 136 | this.badges = "\u2705 "; 137 | this.initialMessage.edit(this._renderInitial()); 138 | } else { 139 | if (signal) 140 | this.reply.html("\uD83D\uDC80 Killed by %s.", utils.formatSignal(signal)); 141 | else if (code === 0) 142 | this.reply.html("\u2705 Exited correctly."); 143 | else 144 | this.reply.html("\u26D4 Exited with %s.", code); 145 | } 146 | 147 | this._removeKeypad(); 148 | this.emit("exit"); 149 | }; 150 | 151 | Command.prototype.handleReply = function handleReply(msg) { 152 | //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 153 | //Flags we would need to touch: -INLCR -IGNCR -ICRNL -IUCLC -ISIG -ICANON -IEXTEN, and also for convenience -ECHO -ECHONL 154 | 155 | if (msg.type !== "text") return false; 156 | this.sendInput(msg.text); 157 | }; 158 | 159 | Command.prototype.sendInput = function sendInput(text, noTerminate) { 160 | this.interacted = true; 161 | text = text.replace(/\n/g, "\r"); 162 | if (!noTerminate) text += "\r"; 163 | if (this.metaActive) text = "\x1b" + text; 164 | this.pty.write(text); 165 | this.metaActive = false; 166 | }; 167 | 168 | Command.prototype.toggleMeta = function toggleMeta(metaActive) { 169 | if (metaActive === undefined) metaActive = !this.metaActive; 170 | this.metaActive = metaActive; 171 | }; 172 | 173 | Command.prototype.setSilent = function setSilent(silent) { 174 | this.renderer.options.silent = silent; 175 | }; 176 | 177 | Command.prototype.setLinkPreviews = function setLinkPreviews(linkPreviews) { 178 | this.renderer.options.hidePreview = !linkPreviews; 179 | }; 180 | 181 | Command.prototype._initKeypad = function _initKeypad() { 182 | this.keypadToken = utils.generateToken(); 183 | 184 | var keys = { 185 | esc: { label: "ESC", content: "\x1b" }, 186 | tab: { label: "⇥", content: "\t" }, 187 | enter: { label: "⏎", content: "\r" }, 188 | backspace: { label: "↤", content: "\x7F" }, 189 | space: { label: " ", content: " " }, 190 | 191 | up: { label: "↑", content: "\x1b[A", appKeypadContent: "\x1bOA" }, 192 | down: { label: "↓", content: "\x1b[B", appKeypadContent: "\x1bOB" }, 193 | right: { label: "→", content: "\x1b[C", appKeypadContent: "\x1bOC" }, 194 | left: { label: "←", content: "\x1b[D", appKeypadContent: "\x1bOD" }, 195 | 196 | insert: { label: "INS", content: "\x1b[2~" }, 197 | del: { label: "DEL", content: "\x1b[3~" }, 198 | home: { label: "⇱", content: "\x1bOH" }, 199 | end: { label: "⇲", content: "\x1bOF" }, 200 | 201 | prevPage: { label: "⇈", content: "\x1b[5~" }, 202 | nextPage: { label: "⇊", content: "\x1b[6~" }, 203 | }; 204 | 205 | var keypad = [ 206 | [ "esc", "up", "backspace", "del" ], 207 | [ "left", "space", "right", "home" ], 208 | [ "tab", "down", "enter", "end" ], 209 | ]; 210 | 211 | this.buttons = []; 212 | this.inlineKeyboard = keypad.map(function (row) { 213 | return row.map(function (name) { 214 | var button = keys[name]; 215 | var data = JSON.stringify({ token: this.keypadToken, button: this.buttons.length }); 216 | var keyboardButton = { text: button.label, callback_data: data }; 217 | this.buttons.push(button); 218 | return keyboardButton; 219 | }.bind(this)); 220 | }.bind(this)); 221 | 222 | this.reply.bot.callback(function (query, next) { 223 | try { 224 | var data = JSON.parse(query.data); 225 | } catch (e) { return next(); } 226 | if (data.token !== this.keypadToken) return next(); 227 | this._keypadPressed(data.button, query); 228 | }.bind(this)); 229 | }; 230 | 231 | Command.prototype.toggleKeypad = function toggleKeypad() { 232 | if (this.keypadMessage) { 233 | this.keypadMessage.markup = null; 234 | this.keypadMessage.refresh(); 235 | this.keypadMessage = null; 236 | return; 237 | } 238 | 239 | // FIXME: this is pretty badly implemented, we should wait until last message (or message with cursor) has known id 240 | var messages = this.renderer.messages; 241 | var msg = messages[messages.length - 1].ref; 242 | msg.markup = {inline_keyboard: this.inlineKeyboard}; 243 | msg.refresh(); 244 | this.keypadMessage = msg; 245 | }; 246 | 247 | Command.prototype._keypadPressed = function _keypadPressed(id, query) { 248 | this.interacted = true; 249 | if (typeof id !== "number" || !(id in this.buttons)) return; 250 | var button = this.buttons[id]; 251 | var content = button.content; 252 | if (button.appKeypadContent !== undefined && this.state.getMode("appKeypad")) 253 | content = button.appKeypadContent; 254 | this.pty.write(content); 255 | query.answer(); 256 | }; 257 | 258 | Command.prototype._removeKeypad = function _removeKeypad() { 259 | if (this.keypadMessage) this.toggleKeypad(); 260 | }; 261 | 262 | 263 | 264 | exports.Command = Command; 265 | -------------------------------------------------------------------------------- /lib/terminal.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Implements a terminal emulator. We use terminal.js for the 3 | * dirty work of parsing the escape sequences, but implement our 4 | * own TermState, with some quirks when compared to a standard 5 | * terminal emulator: 6 | * 7 | * - Lines and characters are created on demand. The terminal 8 | * starts out with no content. The reason being, you don't want 9 | * empty lines to be immediately pushed to your Telegram chat 10 | * after starting a command. 11 | * 12 | * - Allows lines of length higher than the column size. The extra 13 | * characters are appended but the cursor keeps right at the edge. 14 | * Telegram already wraps long lines, having them wrapped by the 15 | * terminal would be ugly. 16 | * 17 | * - Graphic attributes not implemented for now (would not be used 18 | * for Telegram anyways). 19 | * 20 | * - Doesn't have an alternate buffer for now (wouldn't make much 21 | * sense for Telegram rendering...) FIXME 22 | * 23 | * The terminal emulator emits events when lines get inserted, changed, 24 | * removed or go out of view, similar to the original TermState. 25 | **/ 26 | 27 | var util = require("util"); 28 | var Terminal = require("terminal.js"); 29 | 30 | //FIXME: investigate using a patched palette for better support on android 31 | var GRAPHICS = { 32 | '`': '\u25C6', 33 | 'a': '\u2592', 34 | 'b': '\u2409', 35 | 'c': '\u240C', 36 | 'd': '\u240D', 37 | 'e': '\u240A', 38 | 'f': '\u00B0', 39 | 'g': '\u00B1', 40 | 'h': '\u2424', 41 | 'i': '\u240B', 42 | 'j': '\u2518', 43 | 'k': '\u2510', 44 | 'l': '\u250C', 45 | 'm': '\u2514', 46 | 'n': '\u253C', 47 | 'o': '\u23BA', 48 | 'p': '\u23BB', 49 | 'q': '\u2500', 50 | 'r': '\u23BC', 51 | 's': '\u23BD', 52 | 't': '\u251C', 53 | 'u': '\u2524', 54 | 'v': '\u2534', 55 | 'w': '\u252C', 56 | 'x': '\u2502', 57 | 'y': '\u2264', 58 | 'z': '\u2265', 59 | '{': '\u03C0', 60 | '|': '\u2260', 61 | '}': '\u00A3', 62 | '~': '\u00B7' 63 | }; 64 | 65 | 66 | /** INITIALIZATION & ACCESSORS **/ 67 | 68 | function TermState(options) { 69 | if (!options) options = {}; 70 | this.rows = options.rows || 24; 71 | this.columns = options.columns || 80; 72 | 73 | this.defaultAttributes = { 74 | fg: null, 75 | bg: null, 76 | bold: false, 77 | underline: false, 78 | italic: false, 79 | blink: false, 80 | inverse: false, 81 | }; 82 | this.reset(); 83 | } 84 | util.inherits(TermState, require("events").EventEmitter); 85 | 86 | TermState.prototype.reset = function reset() { 87 | this.lines = []; 88 | this.cursor = [0,0]; 89 | this.savedCursor = [0,0]; 90 | 91 | this.modes = { 92 | cursor: true, 93 | cursorBlink: false, 94 | appKeypad: false, 95 | wrap: true, 96 | insert: false, 97 | crlf: false, 98 | mousebtn: false, 99 | mousemtn: false, 100 | reverse: false, 101 | graphic: false, 102 | mousesgr: false, 103 | }; 104 | this.attributes = Object.create(this.defaultAttributes); 105 | this._charsets = { 106 | "G0": "unicode", 107 | "G1": "unicode", 108 | "G2": "unicode", 109 | "G3": "unicode", 110 | }; 111 | this._mappedCharset = "G0"; 112 | this._mappedCharsetNext = "G0"; 113 | this.metas = { 114 | title: '', 115 | icon: '' 116 | }; 117 | this.leds = {}; 118 | this._tabs = []; 119 | this.emit("reset"); 120 | }; 121 | 122 | function getGenericSetter(field) { 123 | return function genericSetter(name, value) { 124 | this[field + "s"][name] = value; 125 | this.emit(field, name); 126 | }; 127 | } 128 | 129 | TermState.prototype.setMode = getGenericSetter("mode"); 130 | TermState.prototype.setMeta = getGenericSetter("meta"); 131 | TermState.prototype.setAttribute = getGenericSetter("attribute"); 132 | TermState.prototype.setLed = getGenericSetter("led"); 133 | 134 | TermState.prototype.getMode = function getMode(mode) { 135 | return this.modes[mode]; 136 | }; 137 | 138 | TermState.prototype.getLed = function getLed(led) { 139 | return !!this.leds[led]; 140 | }; 141 | 142 | TermState.prototype.ledOn = function ledOn(led) { 143 | this.setLed(led, true); 144 | return this; 145 | }; 146 | 147 | TermState.prototype.resetLeds = function resetLeds() { 148 | this.leds = {}; 149 | return this; 150 | }; 151 | 152 | TermState.prototype.resetAttribute = function resetAttribute(name) { 153 | this.attributes[name] = this.defaultAttributes[name]; 154 | return this; 155 | }; 156 | 157 | TermState.prototype.mapCharset = function(target, nextOnly) { 158 | this._mappedCharset = target; 159 | if (!nextOnly) this._mappedCharsetNext = target; 160 | this.modes.graphic = this._charsets[this._mappedCharset] === "graphics"; // backwards compatibility 161 | }; 162 | 163 | TermState.prototype.selectCharset = function(charset, target) { 164 | if (!target) target = this._mappedCharset; 165 | this._charsets[target] = charset; 166 | this.modes.graphic = this._charsets[this._mappedCharset] === "graphics"; // backwards compatibility 167 | }; 168 | 169 | 170 | /** CORE METHODS **/ 171 | 172 | /* Move the cursor */ 173 | TermState.prototype.setCursor = function setCursor(x, y) { 174 | if (typeof x === 'number') 175 | this.cursor[0] = x; 176 | 177 | if (typeof y === 'number') 178 | this.cursor[1] = y; 179 | 180 | this.cursor = this.getCursor(); 181 | this.emit("cursor"); 182 | return this; 183 | }; 184 | 185 | /* Get the real cursor position (the logical one may be off-bounds) */ 186 | TermState.prototype.getCursor = function getCursor() { 187 | var x = this.cursor[0], y = this.cursor[1]; 188 | 189 | if (x >= this.columns) x = this.columns - 1; 190 | else if (x < 0) x = 0; 191 | 192 | if (y >= this.rows) y = this.rows - 1; 193 | else if (y < 0) y = 0; 194 | 195 | return [x,y]; 196 | }; 197 | 198 | /* Get the line at specified position, allocating it if necessary */ 199 | TermState.prototype.getLine = function getLine(y) { 200 | if (typeof y !== "number") y = this.getCursor()[1]; 201 | if (y < 0) throw new Error("Invalid position to write to"); 202 | 203 | // Insert lines until the line at this position is available 204 | while (!(y < this.lines.length)) 205 | this.lines.push({ str: "", attr: null }); 206 | 207 | return this.lines[y]; 208 | }; 209 | 210 | /* Replace the line at specified position, allocating it if necessary */ 211 | TermState.prototype.setLine = function setLine(y, line) { 212 | if (typeof y !== "number") line = y, y = this.getCursor()[1]; 213 | this.getLine(y); 214 | this.lines[y] = line; 215 | return this; 216 | }; 217 | 218 | /* Write chunk of text (single-line assumed) beginning at position */ 219 | TermState.prototype._writeChunk = function _writeChunk(position, chunk, insert) { 220 | var x = position[0], line = this.getLine(position[1]); 221 | if (x < 0) throw new Error("Invalid position to write to"); 222 | 223 | // Insert spaces until the wanted column is available 224 | while (line.str.length < x) 225 | line.str += " "; 226 | 227 | // Write the chunk at position 228 | line.str = line.str.substring(0, x) + chunk + line.str.substring(x + (insert ? 0 : chunk.length)); 229 | //TODO: add attribute 230 | 231 | this.emit("lineChanged", position[1]); 232 | return this; 233 | }; 234 | 235 | /* Remove characters beginning at position */ 236 | TermState.prototype.removeChar = function removeChar(n) { 237 | var x = this.cursor[0], line = this.getLine(); 238 | if (x < 0) throw new Error("Invalid position to delete from"); 239 | 240 | // Insert spaces until the wanted column is available 241 | while (line.str.length < x) 242 | line.str += " "; 243 | 244 | // Remove characters 245 | line.str = line.str.substring(0, x) + line.str.substring(x + n); 246 | 247 | this.emit("lineChanged", this.cursor[1]); 248 | return this; 249 | }; 250 | 251 | TermState.prototype.eraseInLine = function eraseInLine(n) { 252 | var x = this.cursor[0], line = this.getLine(); 253 | switch (n || 0) { 254 | case "after": 255 | case 0: 256 | line.str = line.str.substring(0, x); 257 | break; 258 | 259 | case "before": 260 | case 1: 261 | var str = ""; 262 | while (str.length < x) str += " "; 263 | line.str = str + line.str.substring(x); 264 | break; 265 | 266 | case "all": 267 | case 2: 268 | line.str = ""; 269 | break; 270 | } 271 | this.emit("lineChanged", this.cursor[1]); 272 | return this; 273 | }; 274 | 275 | TermState.prototype.eraseInDisplay = function eraseInDisplay(n) { 276 | switch (n || 0) { 277 | case "below": 278 | case "after": 279 | case 0: 280 | this.eraseInLine(n); 281 | this.removeLine(this.lines.length - (this.cursor[1]+1), this.cursor[1]+1); 282 | break; 283 | 284 | case "above": 285 | case "before": 286 | case 1: 287 | for (var y = 0; y < this.cursor[1]; y++) { 288 | this.lines[y].str = ""; 289 | this.emit("lineChanged", y); 290 | } 291 | this.eraseInLine(n); 292 | break; 293 | 294 | case "all": 295 | case 2: 296 | this.removeLine(this.lines.length, 0); 297 | break; 298 | } 299 | return this; 300 | }; 301 | 302 | TermState.prototype.removeLine = function removeLine(n, y) { 303 | if (typeof y !== "number") y = this.cursor[1]; 304 | if (n <= 0) return this; 305 | 306 | if (y + n > this.lines.length) 307 | n = this.lines.length - y; 308 | if (n <= 0) return this; 309 | 310 | this.emit("linesRemoving", y, n); 311 | this.lines.splice(y, n); 312 | return this; 313 | }; 314 | 315 | TermState.prototype.insertLine = function insertLine(n, y) { 316 | if (typeof y !== "number") y = this.cursor[1]; 317 | if (n <= 0) return this; 318 | 319 | if (y + n > this.rows) 320 | n = this.rows - y; 321 | if (n <= 0) return this; 322 | 323 | this.getLine(y); 324 | this.removeLine((this.lines.length + n) - this.rows, this.rows - n); 325 | for (var i = 0; i < n; i++) 326 | this.lines.splice(y, 0, { str: "", attr: null }); 327 | this.emit("linesInserted", y, n); 328 | return this; 329 | }; 330 | 331 | TermState.prototype.scroll = function scroll(n) { 332 | if (n > 0) { // up 333 | if (n > this.lines.length) n = this.lines.length; //FIXME: is this okay? 334 | if (n > 0) this.emit("linesScrolling", n); 335 | this.lines = this.lines.slice(n); 336 | } else if (n < 0) { // down 337 | n = -n; 338 | if (n > this.rows) n = this.rows; //FIXME: is this okay? 339 | var extraLines = (this.lines.length + n) - this.rows; 340 | if (extraLines > 0) this.emit("linesScrolling", -extraLines); 341 | this.lines = this.lines.slice(0, this.rows - n); 342 | this.insertLine(n, 0); 343 | } 344 | return this; 345 | }; 346 | 347 | 348 | /** HIGH LEVEL **/ 349 | 350 | TermState.prototype._graphConvert = function(content) { 351 | // optimization for 99% of the time 352 | if(this._mappedCharset === this._mappedCharsetNext && !this.modes.graphic) { 353 | return content; 354 | } 355 | 356 | var result = "", i; 357 | for(i = 0; i < content.length; i++) { 358 | result += (this.modes.graphic && content[i] in GRAPHICS) ? 359 | GRAPHICS[content[i]] : 360 | content[i]; 361 | this._mappedCharset = this._mappedCharsetNext; 362 | this.modes.graphic = this._charsets[this._mappedCharset] === "graphics"; // backwards compatibility 363 | } 364 | return result; 365 | }; 366 | 367 | TermState.prototype.write = function write(chunk) { 368 | chunk.split("\n").forEach(function (line, i) { 369 | if (i > 0) { 370 | // Begin new line 371 | if (this.cursor[1] + 1 >= this.rows) 372 | this.scroll(1); 373 | this.mvCursor(0, 1); 374 | this.getLine(); 375 | } 376 | 377 | if (!line.length) return; 378 | if (this.getMode("graphic")) this.getLine().code = true; 379 | line = this._graphConvert(line); 380 | this._writeChunk(this.cursor, line, this.getMode("insert")); 381 | this.cursor[0] += line.length; 382 | }.bind(this)); 383 | this.emit("cursor"); 384 | return this; 385 | }; 386 | 387 | TermState.prototype.resize = function resize(size) { 388 | if (this.lines.length > size.rows) 389 | this.scroll(this.lines.length - size.rows); 390 | this.rows = size.rows; 391 | this.columns = size.columns; 392 | this.setCursor(); 393 | this.emit("resize", size); 394 | return this; 395 | }; 396 | 397 | TermState.prototype.mvCursor = function mvCursor(x, y) { 398 | var cursor = this.getCursor(); 399 | return this.setCursor(cursor[0] + x, cursor[1] + y); 400 | }; 401 | 402 | TermState.prototype.toString = function toString() { 403 | return this.lines.map(function (line) { return line.str; }).join("\n"); 404 | }; 405 | 406 | TermState.prototype.prevLine = function prevLine() { 407 | if (this.cursor[1] > 0) this.mvCursor(0, -1); 408 | else this.scroll(-1); 409 | return this; 410 | }; 411 | 412 | TermState.prototype.nextLine = function nextLine() { 413 | if (this.cursor[1] < this.rows - 1) this.mvCursor(0, +1); 414 | else this.scroll(+1); 415 | return this; 416 | }; 417 | 418 | TermState.prototype.saveCursor = function saveCursor() { 419 | this.savedCursor = this.getCursor(); 420 | return this; 421 | }; 422 | 423 | TermState.prototype.restoreCursor = function restoreCursor() { 424 | this.cursor = this.savedCursor; 425 | return this.setCursor(); 426 | }; 427 | 428 | TermState.prototype.insertBlank = function insertBlank(n) { 429 | var str = ""; 430 | while (str.length < n) str += " "; 431 | return this._writeChunk(this.cursor, str, true); 432 | }; 433 | 434 | TermState.prototype.eraseCharacters = function eraseCharacters(n) { 435 | var str = ""; 436 | while (str.length < n) str += " "; 437 | return this._writeChunk(this.cursor, str, false); 438 | }; 439 | 440 | TermState.prototype.setScrollRegion = function setScrollRegion(n, m) { 441 | //TODO 442 | return this; 443 | }; 444 | 445 | TermState.prototype.switchBuffer = function switchBuffer(alt) { 446 | if (this.alt !== alt) { 447 | this.scroll(this.lines.length); 448 | this.alt = alt; 449 | } 450 | return this; 451 | }; 452 | 453 | TermState.prototype.getBufferRowCount = function getBufferRowCount() { 454 | return this.lines.length; 455 | }; 456 | 457 | 458 | /** 459 | * moves Cursor forward or backward a specified amount of tabs 460 | * @param n {number} - number of tabs to move. <0 moves backward, >0 moves 461 | * forward 462 | */ 463 | TermState.prototype.mvTab = function(n) { 464 | var x = this.getCursor()[0]; 465 | var tabMax = this._tabs[this._tabs.length - 1] || 0; 466 | var positive = n > 0; 467 | n = Math.abs(n); 468 | while(n !== 0 && x > 0 && x < this.columns-1) { 469 | x += positive ? 1 : -1; 470 | if(this._tabs.indexOf(x) != -1 || (x > tabMax && x % 8 === 0)) 471 | n--; 472 | } 473 | this.setCursor(x); 474 | }; 475 | 476 | /** 477 | * set tab at specified position 478 | * @param pos {number} - position to set a tab at 479 | */ 480 | TermState.prototype.setTab = function(pos) { 481 | // Set the default to current cursor if no tab position is specified 482 | if(pos === undefined) { 483 | pos = this.getCursor()[0]; 484 | } 485 | // Only add the tab position if it is not there already 486 | if (this._tabs.indexOf(pos) != -1) { 487 | this._tabs.push(pos); 488 | this._tabs.sort(); 489 | } 490 | }; 491 | 492 | /** 493 | * remove a tab 494 | * @param pos {number} - position to remove a tab. Do nothing if the tab isn't 495 | * set at this position 496 | */ 497 | TermState.prototype.removeTab = function(pos) { 498 | var i, tabs = this._tabs; 499 | for(i = 0; i < tabs.length && tabs[i] !== pos; i++); 500 | tabs.splice(i, 1); 501 | }; 502 | 503 | /** 504 | * removes a tab at a given index 505 | * @params n {number} - can be one of the following 506 | *