├── .gitignore ├── package.json ├── lib ├── cli.js ├── ui-widgets │ └── chatbox.js ├── util.js └── ui.js ├── README.md └── telecommander.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "telecommander", 3 | "version": "0.1.0", 4 | "description": "command line client for telegram", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+ssh://git@github.com/fazo96/telecommander.git" 8 | }, 9 | "bin": { 10 | "telecommander": "./telecommander.js" 11 | }, 12 | "author": "Enrico Fasoli (fazo96)", 13 | "license": "MIT", 14 | "bugs": { 15 | "url": "https://github.com/fazo96/telecommander/issues" 16 | }, 17 | "homepage": "https://github.com/fazo96/telecommander#readme", 18 | "dependencies": { 19 | "blessed": "^0.1.80", 20 | "commander": "^2.8.1", 21 | "get-log": "^1.1.5", 22 | "moment": "^2.10.6", 23 | "telegram.link": "^0.6.3" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /lib/cli.js: -------------------------------------------------------------------------------- 1 | module.exports = function(data){ 2 | data.cli = require('commander') 3 | data.cli 4 | .version('0.1.0') 5 | .description('the full-featured curses-like command line client for Telegram.\n\n More Info: https://github.com/fazo96/telecommander') 6 | .usage('[options]') 7 | .option('-d, --debug', "debug mode") 8 | .option('-t, --testdc',"use Telegram's test DataCenter") 9 | .option('-n, --nuke','delete user data and access key (overdramatic log out)') 10 | .parse(process.argv) 11 | 12 | data.debug = data.cli.debug 13 | 14 | if(data.cli.testdc){ 15 | data.dataCenter = data.telegramLink.TEST_PRIMARY_DC 16 | } else data.dataCenter = data.telegramLink.PROD_PRIMARY_DC 17 | 18 | if(data.cli.nuke){ 19 | var fs = require('fs') 20 | try { 21 | fs.unlinkSync(data.keyFile) 22 | fs.unlinkSync(data.userFile) 23 | } catch (e){ 24 | console.log("Couldn't delete "+data.keyFile+" and "+data.userFile+". They probably don't exist.") 25 | } 26 | console.log('Deleted',data.keyFile,'and',data.userFile) 27 | process.exit(0) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /lib/ui-widgets/chatbox.js: -------------------------------------------------------------------------------- 1 | var blessed = require('blessed') 2 | 3 | function ChatBox(options) { 4 | var self = this 5 | if (!(this instanceof blessed.Node)) { 6 | return new ChatBox(options) 7 | } 8 | options = options || {} 9 | options.scrollable = true 10 | this.options = options 11 | blessed.Box.call(this, options) 12 | this.on('log',function(text){ 13 | if(self.options.autoscroll) self.setScrollPerc(100); 14 | this.setLine(this.getLines().length-1,this.getLine(this.getLines().length-1).trim()) 15 | self.screen.render() 16 | }) 17 | this.on('prepend',function(){ 18 | this.setLine(this.getLines().length-1,this.getLine(this.getLines().length-1).trim()) 19 | self.screen.render() 20 | }) 21 | this.on('click',function(){ 22 | self.focus() 23 | self.screen.render() 24 | }) 25 | } 26 | 27 | ChatBox.prototype.__proto__ = blessed.Box.prototype 28 | ChatBox.prototype.type = 'chatbox' 29 | ChatBox.prototype.add = ChatBox.prototype.log = function(){ 30 | var text = Array.prototype.slice.call(arguments).join(' ') 31 | this.pushLine(text) 32 | this.emit('log',text) 33 | } 34 | ChatBox.prototype.prepend = function(){ 35 | var text = Array.prototype.slice.call(arguments).join(' ') 36 | this.insertLine(0,text) 37 | this.emit('prepend',text) 38 | } 39 | 40 | module.exports = ChatBox 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Telecommander 2 | 3 | The experimental __full-featured curses-like command line client for Telegram__ is in heavy development, but also __already usable!__ 4 | 5 | ![Demo](http://i.imgur.com/uv0Odoa.gif) 6 | 7 | It uses [enricostara](http://github.com/enricostara)'s [telegram.link](http://github.com/enricostara/telegram.link) to connect to Telegram and [chjj](http://github.com/chjj)'s [blessed](http://github.com/chjj/blessed) for the UI. 8 | 9 | In the GIF demo you can see how it looks. It was recorded with [ttystudio](http://github.com/chjj/ttystudio), which while being an excellent software, doen't really represent how good it looks with a nice font and proper color scheme. I removed most of my contacts by hacking the program to protect their privacy, but I actually have a few dozen of them and that doesn't cause issues. 10 | 11 | ### What it can and can't do 12 | 13 | As of __now__, __Telecommander lets you__: 14 | 15 | - Sign up for telegram 16 | - not sure it totally works, not really the priority for now! If it doesn't work and you need to sign up, use [Telegram Web](http://web.telegram.org) 17 | - Sign in with a code sent via SMS or Telegram until you delete the Telecommander session files or the session expires 18 | - Chat in groups and in private (it also marks messages as read when you read them) 19 | - Know when you have unread messages 20 | - View who's online and when was the last time someone logged in 21 | - Do everything __completely in the terminal, even via ssh__ using only the keyboard 22 | - Do everything (except writing messages, duh) using only the mouse, in the terminal! 23 | - Navigate your contact and chat list 24 | - Scroll back to view old messages 25 | 26 | Waht's missing for version _0.1.0_ (__Almost done!__): 27 | 28 | - Download latest messages on session start and show unreads 29 | - Fix issue #1 30 | - Correctly display joined group / left group messages 31 | 32 | What's missing (for future versions up to 1.0) 33 | 34 | - Create, modify, manage groups 35 | - Two step authentication support 36 | - Secret chats 37 | - Manage your contacts 38 | - Manage your profile 39 | - Easy bot interaction 40 | - Send and view files, audio messages, images, videos, links 41 | - Emoticons (they show up as question marks) 42 | - Sign out (without having to manually delete files) 43 | - Delete account 44 | - Reply to and Forward message support 45 | - Search, Tab completion, chatbox history 46 | - Send multiline messages 47 | - Themes and configurability! Basic scripting! 48 | - Optimization 49 | What could be available after 1.0: 50 | 51 | - polished plugin API, scripting support 52 | - Telecommander as a library 53 | - Parsable output mode 54 | - More cool stuff! 55 | 56 | Most of features depend on [telegram.link](http://telegram.link)'s still alpha-quality implementation sadly, but it'll come around eventually. 57 | 58 | ### Installing 59 | 60 | This section shows how to install Telecommander. It will be populated when __0.1__ is done! 61 | 62 | ### Hacking 63 | 64 | To start Telecommander from source you'll need: 65 | 66 | - __npm__, most of the time packaged with node 67 | - __python__ version 2.x (probably need a recent one) 68 | - __git__ to download the source 69 | 70 | Let's set it up: 71 | 72 | ```sh 73 | # Download the source 74 | 75 | $ git clone https://github.com/fazo96/Telecommander.git 76 | $ cd Telecommander 77 | 78 | # If you just need to test it 79 | 80 | $ npm install 81 | $ ./telecommander.js 82 | 83 | # If you want to install it 84 | 85 | # Try with sudo if it doesn't work 86 | $ npm install -g . 87 | $ telecommander 88 | 89 | # If npm install fails because of something about "gyp" 90 | # it probably means your python points to python 3 91 | # either swap it with python 2 or if you're on arch linux 92 | # and/or your python 2 executable is "python2" just run this: 93 | 94 | $ PYTHON=python2 npm install [-g .] 95 | ``` 96 | 97 | __PLEASE READ:__ if you fork the project and want to create a custom version of 98 | Telecommander please change the app.id and app.hash values in the source 99 | (should be in the first 30 lines of code) to the ones you can get 100 | (for free) from http://my.telegram.org 101 | 102 | ### License 103 | 104 | The MIT License (MIT) 105 | 106 | Copyright (c) 2015 Enrico Fasoli (fazo96) 107 | 108 | Permission is hereby granted, free of charge, to any person obtaining a copy 109 | of this software and associated documentation files (the "Software"), to deal 110 | in the Software without restriction, including without limitation the rights 111 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 112 | copies of the Software, and to permit persons to whom the Software is 113 | furnished to do so, subject to the following conditions: 114 | 115 | The above copyright notice and this permission notice shall be included in 116 | all copies or substantial portions of the Software. 117 | 118 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 119 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 120 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 121 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 122 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 123 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 124 | THE SOFTWARE. 125 | -------------------------------------------------------------------------------- /lib/util.js: -------------------------------------------------------------------------------- 1 | var blessed = require('blessed') 2 | var moment = require('moment') 3 | 4 | module.exports = function(data){ 5 | // Contacts holds all the contacts data 6 | data.contacts = { } 7 | // Groups hold all the data about groups 8 | data.groups = { } 9 | // unameToUid is used to match a name to its user id 10 | data.unameToUid = { } 11 | // same thing for group name -> group object 12 | data.gnameToGid = { } 13 | data.user = { } // holds data about current user 14 | data.state = { } // keeps track of the telegram update state 15 | 16 | data.updateLastKnownAction = function(){ 17 | data.lastKnownAction = moment() 18 | data.user.offline = false 19 | } 20 | 21 | data.addUser = function(u){ 22 | if(!data.user || !data.user.id) return log("Can't add invalid user object to contacts",u) 23 | var wasOnline,online = false 24 | data.contacts[u.id] = { user: u, id: u.id, online: online } 25 | var name = data.getName(u.id,'user') 26 | data.unameToUid[name] = u.id 27 | data.updateOnlineStatus(u.id,u.status) 28 | data.rebuildChatList() 29 | //data.log(u.toPrintable()) 30 | } 31 | 32 | data.updateOnlineStatus = function(id,s){ 33 | if(s === undefined) return 34 | if(s.instanceOf('api.type.UserStatusOffline')){ 35 | data.contacts[id].online_expires = moment.unix(s.was_online) 36 | } else if(s.instanceOf('api.type.UserStatusOnline')){ 37 | data.contacts[id].online_expires = moment.unix(s.expires) 38 | data.log('Online status for',data.getName(id,'user'),'expires',data.contacts[id].online_expires.fromNow()) 39 | } 40 | } 41 | 42 | data.addGroup = function(group){ 43 | if(data.groups[group.id]){ 44 | if(group.title && group.title != data.groups[group.id].title){ 45 | // Update title 46 | var old = data.groups[group.id].title 47 | data.groups[group.id].title = group.title 48 | data.chats.getItem(old).content = group.title 49 | data.screen.render() 50 | data.gnameToGid[old] = undefined 51 | return 52 | } 53 | } 54 | data.log(group.toPrintable()) 55 | if(group.left === true) return; 56 | if(group.title === undefined){ 57 | if(!isNaN(group)){ // Is ID 58 | data.groups[group] = { id: group, title: group } 59 | } else { 60 | var t = group.toPrintable ? group.toPrintable() : group 61 | data.log('Undefined group title in group ',group.toPrintable()) 62 | } 63 | } else { 64 | data.groups[group.id] = { id: group.id, title: group.title, group: group } 65 | data.gnameToGid[group.title] = group.id 66 | } 67 | data.rebuildChatList() 68 | } 69 | 70 | data.rebuildChatList = function(){ 71 | var list = [] 72 | for(c in data.contacts) list.push(data.contacts[c]) 73 | for(c in data.groups) list.push(data.groups[c]) 74 | function cmpstr(a,b){ 75 | return data.getName(a.id,a.title?'group':'user').localeCompare(data.getName(b.id,b.title?'group':'user')) 76 | } 77 | list.sort(function(a,b){ 78 | if(a.toread > 0 && b.toread > 0){ 79 | var diff = a.toread - b.toread 80 | if(diff == 0) return cmpstr(a,b) 81 | return diff 82 | } 83 | if(a.toread > 0) return -1 84 | if(b.toread > 0) return 1 85 | return cmpstr(a,b) 86 | }) 87 | data.chats.setItems(list.map(function(item){ 88 | var n = data.getName(item.id,item.title?'group':'user','chatlist') 89 | if(n === undefined || n === null || !n || (n.trim && !n.trim())) 90 | data.log('Empty list item:',JSON.stringify(item)) 91 | return n 92 | })) 93 | if(!data.chats.focused == data.screen.focused) data.chats.setScrollPerc(0) 94 | //data.chats.select(data.selectedWindow) 95 | data.screen.render() 96 | } 97 | 98 | data.markAsRead = function(name){ 99 | var obj = data.nameToObj(name) 100 | if(obj === undefined) return 101 | obj.toread = 0 102 | data.rebuildChatList() 103 | if(data.markingAsRead){ 104 | return // we don't want 2339238 requests 105 | } 106 | var maxid = obj.latest_message || 0 107 | if(obj.latest_read_message > maxid) return // already read them messages 108 | data.latest_read_message = maxid 109 | data.markingAsRead = true 110 | data.client.messages.readHistory(data.idToPeer(obj.id,obj.title?'group':'user'),maxid,0,true,function(res){ 111 | data.log('Done reading history:',res.toPrintable()) 112 | data.markingAsRead = false 113 | }) 114 | } 115 | 116 | // Updates the current state 117 | data.updateState = function(newstate){ 118 | data.state.pts = newstate.pts || data.state.pts 119 | data.state.qts = newstate.qts || data.state.qts 120 | data.state.date = newstate.date || data.state.date 121 | data.state.sqp = newstate.seq || data.state.sqp 122 | data.state.unreadCount = newstate.unread_count || data.state.unreadCount || 0 123 | } 124 | 125 | data.getName = function(id,type,f){ 126 | var name,obj,toread,online_expires 127 | if(type === undefined) throw new Error('no type') 128 | else if(id === data.user.id){ 129 | obj = data.user 130 | } else if(type === 'group' && data.groups[id]) { 131 | obj = data.groups[id] 132 | toread = obj.toread 133 | } else if(type === 'user' && data.contacts[id]) { 134 | obj = data.contacts[id].user 135 | toread = data.contacts[id].toread 136 | online_expires = data.contacts[id].online_expires 137 | } else data.log('Failed to find name for',type,id) 138 | if(obj === undefined){ 139 | name = 'Unknown '+type+' '+id 140 | } else if(type === 'user'){ 141 | // User 142 | if(obj.first_name === undefined && obj.last_name === undefined && obj.username === undefined){ 143 | data.log('Zombie User: '+obj) 144 | return 'undefined' 145 | } 146 | name = obj.first_name + (obj.last_name?' '+obj.last_name:'') + (obj.username?' {grey-fg}@'+obj.username+'{/grey-fg}':'') 147 | } else { // Group 148 | name = '{blue-fg}'+obj.title+'{/blue-fg}' 149 | } 150 | if(f === 'chatlist'){ 151 | if(toread > 0) name = '* '+name 152 | if(online_expires && moment().isBefore(online_expires)) 153 | name = '{green-fg}'+name+'{/green-fg}' 154 | } else if(f === 'statusbar'){ 155 | if(toread > 0) name = name + ' (' + toread + ')' 156 | if(online_expires) 157 | if(moment().isBefore(online_expires)) 158 | name = name + '{|}{green-fg}Online{/green-fg}' 159 | else name = name + '{|}Last Seen {grey-fg}'+moment(online_expires).fromNow()+'{/grey-fg}' 160 | } else if(f === 'label'){ 161 | name = 'to ' + name 162 | } 163 | if(f) return name 164 | return blessed.cleanTags(name) 165 | } 166 | 167 | data.escapeFromList = function(txt){ 168 | return blessed.stripTags(txt.text || txt.content || String(text)).replace('* ','') 169 | } 170 | 171 | data.nameToObj = function(name){ 172 | var id = data.gnameToGid[name] 173 | if(data.groups[id] && data.groups[id].title === name) 174 | return data.groups[id] 175 | else { 176 | id = data.unameToUid[name] 177 | return data.contacts[id] 178 | } 179 | } 180 | 181 | data.idToPeer = function(uid,type){ 182 | if(type === 'user') 183 | return new data.telegramLink.type.InputPeerContact({ props: { user_id: ''+uid } }) 184 | else if(type === 'group') 185 | return new data.telegramLink.type.InputPeerChat({ props: { chat_id: ''+uid } }) 186 | } 187 | 188 | data.quit = function(){ 189 | if(data.connected || data.client != undefined){ 190 | data.log('Closing communications and shutting down...') 191 | data.client.end(function(){ 192 | process.exit(0) 193 | }) 194 | } else process.exit(0); 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /lib/ui.js: -------------------------------------------------------------------------------- 1 | var blessed = require('blessed') 2 | var ChatBox = require('./ui-widgets/chatbox.js') 3 | 4 | module.exports = function(data){ 5 | data.statusWindow = "Status" 6 | data.selectedWindow = data.statusWindow // the currently selected window 7 | 8 | // Get msgBox for given group/user NAME, create if not exists 9 | data.getMsgBox = function(chat){ 10 | if(chat === undefined){ 11 | data.log('ERROR: asked for box for "undefined"!!') 12 | return data.msgBox[statusWindow] 13 | } 14 | if(!data.msgBox[chat]){ 15 | //log('Generating window: "'+chat+'"') 16 | data.msgBox[chat] = data.mkBox(chat) 17 | data.screen.insertBefore(data.msgBox[chat],data.loader) 18 | //data.getMessages(chat,data.msgBox[chat]) 19 | } // else log('Getting window','"'+chat+'"') 20 | return data.msgBox[chat] 21 | } 22 | 23 | data.switchToBox = function(boxname){ 24 | // Hide current window 25 | if(data.selectedWindow && data.msgBox[data.selectedWindow]) 26 | data.msgBox[data.selectedWindow].hide() 27 | if(boxname === undefined){ 28 | // Leave the clear 29 | data.statusBar.hide() 30 | } else { 31 | // Switch window 32 | data.selectedWindow = boxname; 33 | if(data.selectedWindow != data.statusWindow){ 34 | var obj = data.nameToObj(data.selectedWindow) 35 | data.cmdline.setLabel(data.getName(obj.id,obj.title?'group':'user','label')) 36 | data.markAsRead(data.selectedWindow) 37 | } else { 38 | data.cmdline.setLabel('Command for Telecommander') 39 | } 40 | var newb = data.getMsgBox(data.selectedWindow) 41 | data.refreshStatusBar() 42 | data.statusBar.show() 43 | newb.show() 44 | newb.emit('scroll') 45 | } 46 | data.screen.render() 47 | } 48 | 49 | data.refreshStatusBar = function(){ 50 | var obj = data.nameToObj(data.selectedWindow) 51 | if(obj && obj.id) 52 | data.statusBar.setContent(data.getName(obj.id,obj.title?'group':'user','statusbar')) 53 | else data.statusBar.setContent(data.selectedWindow) 54 | } 55 | 56 | data.screen = blessed.screen({ 57 | smartCSR: true, 58 | dockBorders: true 59 | }) 60 | data.screen.title = "Telecommander" 61 | 62 | data.getDefaultStyle = function(){ 63 | return { 64 | fg: 'white', 65 | border: { fg: 'grey' }, 66 | focus: { 67 | border: { fg: 'white' }, 68 | scrollbar: { 69 | fg: 'white', 70 | bg: 'white' 71 | } 72 | }, 73 | selected: { bold: true, fg: 'white' }, 74 | scrollbar: { 75 | fg: 'white', bg: 'white', 76 | track: { fg: 'grey', bg: 'grey' } 77 | } 78 | } 79 | } 80 | 81 | // Contact list window 82 | data.chats = blessed.list({ 83 | keys: true, 84 | tags: true, 85 | label: 'Conversations', 86 | left: 0, 87 | top:0, 88 | height: data.screen.height-3, 89 | width: '20%', 90 | border: { type: 'line' }, 91 | mouse: true, 92 | scrollbar: { 93 | ch: ' ', 94 | track : { 95 | ch: ' ' 96 | } 97 | }, 98 | //scrollbar: false, // disabled cause can't change track style when focused 99 | invertSelected: false, 100 | style: data.getDefaultStyle(), 101 | }) 102 | data.chats.key('tab',function(){ 103 | if(data.msgBox[data.selectedWindow]) 104 | data.msgBox[data.selectedWindow].focus() 105 | }) 106 | data.screen.append(data.chats) 107 | 108 | // Command line prompt 109 | data.cmdline = blessed.textbox({ 110 | keys: false, 111 | tags: true, 112 | mouse: true, 113 | label: 'Command for Telecommander', 114 | bottom: 0, 115 | left: 'center', 116 | width: '100%', 117 | height: 3, 118 | border: { type: 'line' }, 119 | style: data.getDefaultStyle() 120 | }) 121 | data.screen.append(data.cmdline); 122 | 123 | // Function to create a log box 124 | data.mkBox = function(txt){ 125 | var b = ChatBox({ 126 | keys: true, 127 | tags: true, 128 | mouse: true, 129 | right: 0, 130 | top: 2, 131 | //label: { text: txt, side: 'left' }, 132 | width: '80%', 133 | hidden: true, 134 | height: data.screen.height - data.cmdline.height - 2, 135 | border: { type: 'line' }, 136 | scrollable: true, 137 | autoscroll: true, 138 | scrollbar: { 139 | ch: ' ', 140 | fg: 'white', 141 | track: { 142 | ch: ' ', fg: 'grey', bg: 'grey' 143 | } 144 | }, 145 | style: data.getDefaultStyle() 146 | }) 147 | b.on('focus',function(){ 148 | data.statusBarStyle.border.fg = b.style.focus.border.fg 149 | data.screen.render() 150 | }) 151 | b.on('blur',function(){ 152 | data.statusBarStyle.border.fg = b.style.border.fg 153 | }) 154 | var obj = data.nameToObj(txt) 155 | /*if(obj && obj.id) 156 | b.setLabel(data.getName(obj.id,obj.title?'group':'user','label'))*/ 157 | b.data.downloadedHistoryTimes = 0 158 | b.key('enter',function(){ 159 | this.setScrollPerc(100) 160 | data.cmdline.focus() 161 | }) 162 | b.on('scroll',function(){ 163 | // The functions might not yet exist if this is the first window 164 | if(b.getScrollPerc() === 100 && data.markAsRead) 165 | data.markAsRead(data.selectedWindow) 166 | else if(b.getScrollPerc() === 0 && data.getMessages){ 167 | data.getMessages(txt,b) 168 | } 169 | }) 170 | return b 171 | } 172 | 173 | data.statusBarStyle = data.getDefaultStyle() 174 | data.statusBarStyle.border.fg = 'white' 175 | data.statusBar = blessed.box({ 176 | tags: true, 177 | input: false, 178 | keyable: false, 179 | keys: false, 180 | border: { type: 'line' }, 181 | height: 3, 182 | right: 0, 183 | top: 0, 184 | width: '80%', 185 | style: data.statusBarStyle 186 | }) 187 | data.screen.append(data.statusBar) 188 | 189 | data.getDefaultPopupStyle = function(){ 190 | return { 191 | width: '30%', 192 | key: true, 193 | height: 'shrink', 194 | left: 'center', 195 | top: 'center', 196 | align: 'center', 197 | valign: 'center', 198 | border: { type: 'line' }, 199 | style: data.getDefaultStyle() 200 | } 201 | } 202 | 203 | // Widget used to show loading windows 204 | data.loader = blessed.Loading(data.getDefaultPopupStyle()) 205 | data.screen.append(data.loader) 206 | data.load = function(msg){ 207 | data.loader.stop() 208 | data.loader.load(msg) 209 | } 210 | 211 | // Widget used to ask for phone number and code 212 | data.promptBox = blessed.Prompt(data.getDefaultPopupStyle()) 213 | data.screen.append(data.promptBox) 214 | 215 | // Widget used to show pop up read only messages 216 | data.popup = blessed.Message(data.getDefaultPopupStyle()) 217 | data.screen.append(data.popup) 218 | data.popup.hide() 219 | 220 | // mgsBox holds the chat window instance for every chat 221 | data.msgBox = { } 222 | 223 | // Add the status window but don't show it 224 | data.msgBox[data.statusWindow] = data.mkBox(data.statusWindow) 225 | data.screen.append(data.msgBox[data.statusWindow]) 226 | data.switchToBox() 227 | 228 | data.screen.on('resize',function(){ 229 | for(i in data.msgBox){ 230 | item = data.msgBox[i] 231 | item.height = data.screen.height - data.cmdline.height - 2 232 | } 233 | data.chats.height = data.screen.height - data.cmdline.height 234 | data.screen.render() 235 | }) 236 | data.screen.enableMouse() 237 | data.screen.on('mouse',function(){ 238 | data.updateLastKnownAction() 239 | }) 240 | data.screen.on('keypress',function(){ 241 | data.updateLastKnownAction() 242 | }) 243 | /* // Commented out cause doens't work on most terminals 244 | data.screen.on('focus',function(){ 245 | data.log('Screen Focus') 246 | }) 247 | data.screen.on('blur',function(){ 248 | data.log('Screen blur') 249 | }) 250 | */ 251 | data.screen.key('tab',function(){ 252 | data.screen.focusPush(data.chats) 253 | }) 254 | data.screen.key('0',function(){ 255 | data.switchToBox(data.statusWindow) 256 | }) 257 | data.command = function(cmd){ 258 | data.log('Commands are not implemented... sorry!') 259 | } 260 | 261 | // What happens when a different window is selected 262 | data.chats.on('select',function(selected){ 263 | //data.log('SELECT:',selected.content) 264 | if(selected === undefined) return 265 | var sel = data.escapeFromList(selected) 266 | data.switchToBox(sel) 267 | data.msgBox[data.selectedWindow].focus() 268 | }) 269 | /* 270 | data.cmdline.on('click',function(){ 271 | data.cmdline.focus() 272 | data.screen.render() 273 | }) 274 | */ 275 | // Catch ctrl-c or escape event and close program 276 | data.screen.key(['escape','C-c'], function(ch,key){ 277 | data.quit() 278 | }); 279 | 280 | data.cmdline.on('focus',function(){ 281 | data.cmdline.readInput() 282 | }) 283 | 284 | // What happens when the user submits a command in the prompt 285 | data.cmdline.on('submit',function(value){ 286 | data.getMsgBox(data.statusWindow).add('< '+value) 287 | if(data.selectedWindow === data.statusWindow || data.nameToObj(data.selectedWindow) === undefined){ 288 | //log('Window:',selectedWindow,'Eval cmd:',value) 289 | data.command(value) 290 | } else if(value.indexOf('//') === 0){ 291 | data.sendMsg(selectedWindow,value.substring(1)) 292 | } else if(value.indexOf('/') === 0){ 293 | data.command(value.substring(1)) 294 | } else { 295 | data.sendMsg(data.selectedWindow,value) 296 | } 297 | data.cmdline.clearValue() 298 | data.cmdline.focus() 299 | }) 300 | 301 | } 302 | -------------------------------------------------------------------------------- /telecommander.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var data = {} // Hold all global data 4 | 5 | var os = require('os') 6 | var fs = require('fs') 7 | var moment = require('moment') 8 | var blessed = require('blessed') 9 | var path = require('path') 10 | 11 | data.cfgDir = path.join(process.env.XDG_CONFIG_HOME || (path.join(process.env.HOME || process.env.USERPROFILE, '/.config/')), 'telecommander/') 12 | process.env.LOGGER_FILE = process.env.LOGGER_FILE || "/tmp/telecommander" 13 | data.keyFile = path.join(data.cfgDir,'key') 14 | data.userFile = path.join(data.cfgDir,'user_data.json') 15 | data.telegramLink = require('telegram.link')() 16 | 17 | // Load modules 18 | require('./lib/cli.js')(data) // Parse command line args 19 | require('./lib/util.js')(data) // Load utils 20 | require('./lib/ui.js')(data) // Load ui 21 | 22 | /* IF YOU FORK THE APP PLEASE CHANGE THE ID 23 | * AND HASH IN THE APP OBJECT! THEY IDENTIFY 24 | * THE APPLICATION CREATOR AND YOU CAN 25 | * OBTAIN YOURS FROM http://my.telegram.org 26 | */ 27 | data.app = { 28 | id: '42419', 29 | hash: '90a3c2cdbf9b391d9ed72c0639dc0786', 30 | version: require('./package.json').version, 31 | lang: 'en', 32 | deviceModel: os.type(), 33 | systemVersion: os.platform()+'/'+os.release() 34 | } 35 | 36 | // Logger 37 | var getLogger = require('get-log') 38 | getLogger.PROJECT_NAME = 'telecommander' 39 | data.logger = getLogger('main') 40 | 41 | data.authKey // our authorization key to access telegram 42 | data.connected = false // keep track of wether we are good to go and logged in 43 | 44 | // Write something in the Status box 45 | data.log = function(){ 46 | args = Array.prototype.slice.call(arguments) 47 | var msg = args.join(' ') 48 | data.getMsgBox(data.statusWindow).add(msg) 49 | data.logger.info(msg) 50 | } 51 | 52 | data.command = function(cmd){ 53 | //cmdl = cmd.split(' ') 54 | //cmdname = cmdl[0] 55 | data.log('Commands are not implemented!') 56 | } 57 | 58 | // Send a message 59 | data.sendMsg = function(name,str){ 60 | if(!data.connected){ 61 | return log('Error: not ready to send messages') 62 | } 63 | var obj = data.nameToObj(name) 64 | var peer = data.idToPeer(obj.id,obj.title?'group':'user') 65 | var randid = parseInt(Math.random() * 999999999) 66 | // Fix bug in telegram.link that doesn't send strings with accented letters 67 | str = str.replace('è',"e'").replace('ù',"u'").replace('à',"a'").replace('ò',"o'").replace('ì',"i'") 68 | //data.log('Sending Message to:',peer.toPrintable()) 69 | data.client.messages.sendMessage(peer,str,randid,function(sent){ 70 | data.log('Sent message:','"'+str+'"','to:',data.selectedWindow+':',sent.toPrintable()) 71 | }) 72 | } 73 | 74 | data.onPhoneCode = function(something,s){ 75 | if(s === null){ // User cancelled 76 | process.exit(0) 77 | } 78 | var cmdl = s.split(' ') 79 | code = cmdl[0] 80 | name = cmdl[1] 81 | lastname = cmdl[2] 82 | if(((!name || !lastname) && !data.user.registered) || !code) 83 | return log('insufficient arguments:',cmd) // TODO: handle this better! 84 | var cb = function(result){ 85 | data.user = data.user || {} 86 | data.user.id = ''+result.user.id 87 | data.user.phone = result.user.phone 88 | data.user.phoneCodeHash = result.phone_code_hash 89 | data.user.username = result.user.username 90 | data.user.first_name = result.user.first_name 91 | data.user.last_name = result.user.last_name 92 | data.user.dataCenter = data.dataCenter 93 | // Done, write user data and key to disk 94 | try { 95 | fs.mkdirSync(data.cfgDir,'0770') 96 | } catch (e) { 97 | if(e.code != 'EEXIST'){ 98 | console.error("FATAL: couldn't create configuration directory",data.cfgDir,e) 99 | process.exit(-1) 100 | } 101 | } 102 | data.log('Writing Log In token and user data to',data.cfgDir) 103 | fs.writeFile(data.cfgDir+'key',data.app.authKey,function(err){ 104 | if(err) data.log('FATAL: Could not write key to disk:',err) 105 | }) 106 | fs.writeFile(data.cfgDir+'user_data.json',JSON.stringify(data.user),function(err){ 107 | if(err) data.log("FATAL: couldn't write user_data.json:",err) 108 | }) 109 | data.whenReady() 110 | } 111 | // Log in finally 112 | if(data.user.registered) data.client.auth.signIn(data.user.phone,data.user.phoneCodeHash,code,cb) 113 | else data.client.auth.signUp(data.user.phone,data.user.phoneCodeHash,code,name,lastname,cb) 114 | } 115 | 116 | data.useDatacenter = function(toDC,f){ 117 | data.log('Using DC:',toDC) 118 | data.client.getDataCenters(function(dcs){ 119 | data.dataCenters = dcs 120 | data.dataCenter = data.dataCenters[toDC || data.dataCenters.nearest] 121 | if(f && f.call) f(data.dataCenter) 122 | }) 123 | } 124 | 125 | data.onPhoneNumber = function(something,s){ 126 | if(s === null){ // User cancelled 127 | process.exit(0) 128 | } 129 | data.user.phone = s.trim() 130 | var mindate = moment() 131 | data.log('Checking your phone number with Telegram...') 132 | data.client.auth.sendCode(data.user.phone,5,'en',function(result){ 133 | if(result.instanceOf('mtproto.type.Rpc_error')){ 134 | if(result.error_code === 303){ // PHONE_MIGRATE_X error (wrong datacenter) 135 | data.load('Finding Datacenter...') 136 | data.useDatacenter('DC_'+result.error_message.slice(-1),function(dc){ 137 | data.client.end(function(){ 138 | data.connect(true) 139 | }) 140 | }) 141 | } else { 142 | data.switchToBox(data.statusWindow) 143 | data.log('Errors:',result.error_code,result.error_message) 144 | } 145 | } else { // NO ERROR 146 | //data.log('Res:',JSON.stringify(result)) 147 | data.user.registered = result.phone_registered 148 | data.user.phoneCodeHash = result.phone_code_hash 149 | var msg 150 | if(!data.user.registered){ 151 | msg = "Your number ("+data.user.phone+") is not registered.\nTelecommander will register your account with the Telegram service." 152 | } else { 153 | msg = "Your number ("+data.user.phone+") is already assigned to a Telegram account.\nTelecommander will log you in." 154 | } 155 | msg += "\nPress ESC to exit now, or enter to continue" 156 | data.popup.display(msg,0,function(){ 157 | data.popup.hide() 158 | data.promptBox.input('Your telegram code:','',data.onPhoneCode) 159 | }) 160 | } 161 | }) 162 | } 163 | 164 | // Connects to telegram 165 | data.connect = function(re){ 166 | data.load(re?'Reconnecting...':'Connecting...') 167 | if(re){ // RE-connecting, from scratch (drop all data) 168 | data.app.authKey = undefined 169 | } 170 | data.client = data.telegramLink.createClient(data.app, data.dataCenter, function(){ 171 | if(!data.app.authKey){ 172 | data.log('Downloading Authorization Key...') 173 | data.client.createAuthKey(function(auth){ 174 | data.app.authKey = auth.key.encrypt('password') // Will add security later, I promise 175 | // Writes the new encrypted key to disk 176 | data.loader.stop() 177 | //data.log('Ready for phone number, use command: phone ') 178 | data.promptBox.input('Phone number (international format):','+',data.onPhoneNumber) 179 | }) 180 | } else { 181 | data.whenReady() 182 | } 183 | }) 184 | 185 | data.client.once('dataCenter',function(dcs){ 186 | data.log('Datacenters:',dcs.toPrintable()) 187 | }) 188 | } 189 | 190 | // Executed when connected and logged in 191 | data.whenReady = function(){ 192 | data.load('Connected') 193 | data.connected = true 194 | data.downloadData() 195 | data.chats.focus() 196 | } 197 | 198 | // Downloads stuff 199 | data.downloadData = function(){ 200 | data.load('Downloading data') 201 | data.client.contacts.getContacts('',function(cont){ 202 | //data.chats.clearItems() 203 | //data.chats.add(data.statusWindow) 204 | //data.log(cont.toPrintable()) 205 | cont.users.list.forEach(data.addUser) 206 | data.loader.stop() 207 | }) 208 | 209 | data.client.messages.getDialogs(0,0,10,function(dialogs){ 210 | if(dialogs && dialogs.chats && dialogs.chats.list) 211 | dialogs.chats.list.forEach(data.addGroup) 212 | data.loader.stop() 213 | }) 214 | 215 | data.client.updates.getState(function(astate){ 216 | data.updateState(astate) 217 | data.log(data.state.unreadCount,'unread messages') 218 | //data.log('Started receiving updates') 219 | // Can't use registerOnUpdates because it's apparently broken 220 | //data.client.registerOnUpdates(data.onUpdate) 221 | setTimeout(data.downloadUpdates,1000) 222 | }) 223 | } 224 | 225 | data.downloadUpdates = function(){ 226 | data.client.updates.getDifference(data.state.pts,data.state.date,data.state.qts,function(res){ 227 | if(!res.instanceOf('api.type.updates.DifferenceEmpty')){ 228 | //data.log('Got Diff: ',res.toPrintable()) 229 | if(res.state){ 230 | data.updateState(res.state) 231 | } 232 | if(res.chats) 233 | for(c in res.chats.list) data.addGroup(res.chats.list[c]) 234 | if(res.users) 235 | for(c in res.users.list) data.addUser(res.users.list[c]) 236 | if(res.new_messages){ 237 | res.new_messages.list.forEach(function(msg){ 238 | data.appendMsg(msg,undefined,false,false) 239 | }) 240 | } 241 | if(res.other_updates){ 242 | for(c in res.other_updates.list) data.onUpdate(res.other_updates.list[c]) 243 | } 244 | data.rebuildChatList() 245 | data.refreshStatusBar() 246 | } 247 | setTimeout(data.downloadUpdates,1000) 248 | }) 249 | } 250 | 251 | data.onUpdate = function(o){ 252 | if(o.instanceOf('api.type.UpdateUserStatus')){ 253 | data.updateOnlineStatus(o.user_id,o.status) 254 | } 255 | } 256 | 257 | // Get message history with given name in the given box 258 | // BROKEN, need to be rethinked 259 | data.getMessages = function(name,box){ 260 | if(!data.connected){ 261 | return // data.log('Uh cant get messages cuz not connected.....') 262 | } 263 | if(data.downloadingMessages == true) return 264 | //log('Name to obj:',name) 265 | var obj = data.nameToObj(name) 266 | if(!obj || !obj.id){ 267 | return //data.log("Can't get messages",obj,obj.id,obj.title) 268 | } 269 | var type = obj.title?'group':'user' 270 | var peer = data.idToPeer(obj.id,type) 271 | //box.add('Downloading message history for '+name) 272 | if(!peer) return log('Could not find peer:',name) 273 | data.downloadingMessages = true 274 | var oldnlines = box.getLines().length 275 | if(data.selectedWindow === name) data.load('Downloading history...') 276 | data.client.messages.getHistory(peer,0,obj.oldest_message||0,box.height,function(res){ 277 | //log(res.toPrintable()) 278 | //log('Got history for: '+getName(peer.user_id||peer.chat_id,peer.chat_id?'group':'user')) 279 | if(!res.messages){ 280 | return box.add(res.toPrintable()) 281 | } 282 | res.messages.list.sort(function(msg1,msg2){ 283 | return msg1.date - msg2.date 284 | }) 285 | res.messages.list.reverse() 286 | res.messages.list.forEach(function(msg){ 287 | data.appendMsg(msg,undefined,false,true) 288 | }) 289 | if(box.data.downloadedHistoryTimes === 0) // Downloading messages for the first time 290 | box.setScrollPerc(100) 291 | //box.add(obj.oldest_message) 292 | box.data.downloadedHistoryTimes++ 293 | data.loader.stop() 294 | data.downloadingMessages = false 295 | }) 296 | } 297 | 298 | data.appendToUserBox = function(msg,context){ 299 | var goesto 300 | if(context.messages.list.length > 0){ 301 | if(context.messages.list[0].to_id.chat_id){ 302 | // Group message 303 | //data.log('Chose',data.getName(context.messages.list[0].to_id.chat_id,'group')) 304 | goesto = data.getMsgBox(data.getName(context.messages.list[0].to_id.chat_id)) 305 | } 306 | } 307 | if(goesto === undefined){ 308 | if(context.users.list[0].user_id == data.user.id){ 309 | goesto = data.getMsgBox(data.getName(context.users.list[1].id,'user')) 310 | } else{ 311 | goesto = data.getMsgBox(data.getName(context.users.list[0].id,'user')) 312 | } 313 | } 314 | data.appendMsg(msg,goesto,true) 315 | } 316 | 317 | // Writes given telegram.link "message" object to given boxId 318 | data.appendMsg = function(msg,toBoxId,bare,prepend){ 319 | var box,param,obj 320 | if(toBoxId != undefined){ 321 | box = toBoxId 322 | } else { 323 | if(msg.to_id.chat_id != undefined){ 324 | // Is a group 325 | param = data.getName(msg.to_id.chat_id,'group') 326 | obj = data.groups[msg.to_id.chat_id] 327 | } else if(msg.from_id === msg.to_id.user_id || msg.from_id != data.user.id){ 328 | param = data.getName(msg.from_id,'user') 329 | obj = data.contacts[msg.from_id] 330 | } else if(msg.to_id.user_id != undefined && msg.to_id.user_id != data.user.id) { 331 | // don't forget dat .user_id! don't need it in from_id... 332 | param = data.getName(msg.to_id.user_id,'user') 333 | obj = data.contacts[msg.to_id.user_id] 334 | } 335 | // Increase unread count if necessary 336 | if(data.selectedWindow != param || data.msgBox[param] === undefined){ 337 | if(!obj.toread) obj.toread = 1 338 | else obj.toread++ 339 | } 340 | // Update oldest and latest message reference 341 | if(!obj.oldest_message || parseInt(obj.oldest_message) > parseInt(msg.id)) 342 | obj.oldest_message = parseInt(msg.id) 343 | if(!obj.latest_message || parseInt(obj.latest_message) < parseInt(msg.id)) 344 | obj.latest_message = parseInt(msg.id) 345 | box = data.getMsgBox(param) 346 | } 347 | if(bare) 348 | box.add(msg) 349 | else { 350 | var id = msg.from_id 351 | if(!id){ // Weird zombie message! 352 | data.log('Zombie Message:',msg.toPrintable()) 353 | return box 354 | } else { // Regular message 355 | var date = moment.unix(msg.date).format('DD-MM-YYYY H:mm') 356 | name = data.getName(id,'user',true) 357 | var txt = (name || id)+' {|} {grey-fg}'+date+'{/grey-fg}\n' 358 | if(msg.media){ 359 | if(msg.media.photo) 360 | txt += '{grey-fg}>>>{/grey-fg} (Photo)' 361 | else if(msg.media.audio) 362 | txt += "{grey-fg}>>>{/grey-fg} (Audio Message) "+msg.media.audio.duration+" seconds" 363 | else if(!msg.message) 364 | txt += "{grey-fg}>>>{/grey-fg} (Unsupported Message)" 365 | } 366 | if(msg.message){ 367 | txt += msg.message.split('\n').map(function(s){ 368 | return '{grey-fg}>{/grey-fg} '+s 369 | }).join('\n') 370 | } 371 | if(prepend) box.prepend(txt) 372 | else box.add(txt) 373 | } 374 | } 375 | // Mark messages as read if needed 376 | if(param === data.selectedWindow) data.markAsRead(param) 377 | return box 378 | } 379 | 380 | // - Entry Point - 381 | // Load authKey and userdata from disk, then act depending on outcome 382 | data.load('Starting up...') 383 | data.screen.render() 384 | fs.exists(data.keyFile,function(exists){ 385 | if(exists){ 386 | //log('Authorization Key found') 387 | fs.readFile(data.keyFile,function(err,content){ 388 | if(err) 389 | data.log('Error while reading key:',err) 390 | else { 391 | data.app.authKey = data.telegramLink.retrieveAuthKey(content,'password') // yeah sorry just testing 392 | data.log('Authorization Key found') 393 | fs.readFile(data.userFile,function(err,res){ 394 | if(err) 395 | data.log("FATAL: couldn't read user_data.json") 396 | else { 397 | try { 398 | data.user = JSON.parse(res) 399 | if(data.user.dataCenter) data.dataCenter = data.user.dataCenter 400 | data.log('Welcome',data.getName(data.user.id,'user')) 401 | } catch (e) { 402 | data.log("FATAL: user data corrupted:",e) 403 | } 404 | data.connect() 405 | } 406 | }) 407 | } 408 | }) 409 | } else { 410 | data.connect() 411 | } 412 | }) 413 | --------------------------------------------------------------------------------