├── .gitignore ├── src ├── lib │ ├── eventemitter.js │ ├── api │ │ ├── whoami.js │ │ ├── get-space-details.js │ │ ├── get-available-rooms.js │ │ ├── get-chats.js │ │ ├── hide-chat.js │ │ ├── get-thread-messages.js │ │ ├── get-chat-messages.js │ │ ├── mark-read.js │ │ ├── request-batch.js │ │ ├── create-thread.js │ │ ├── get-users.js │ │ ├── get-chat-threads.js │ │ ├── request.js │ │ ├── send-chat-message.js │ │ ├── send-thread-message.js │ │ ├── set-room-membership.js │ │ ├── parse.js │ │ ├── events.js │ │ ├── auth.js │ │ └── unpack.js │ ├── random-id.js │ ├── timestamp.js │ ├── config.js │ ├── model │ │ ├── user.js │ │ └── chat.js │ ├── format.js │ └── state.js ├── screens │ ├── confirm.js │ ├── working.js │ ├── messages.js │ ├── input.js │ ├── chats.js │ ├── search.js │ ├── threads.js │ └── prune.js └── screen.js ├── .prettierrc ├── .eslintrc ├── package.json ├── LICENSE ├── index.js ├── constants.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode 3 | .DS_Store -------------------------------------------------------------------------------- /src/lib/eventemitter.js: -------------------------------------------------------------------------------- 1 | const EE = require('events'); 2 | 3 | module.exports = new EE(); 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "semi": true, 4 | "singleQuote": true, 5 | "arrowParens": "avoid" 6 | } 7 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint:recommended", 3 | "plugins": ["prettier"], 4 | "rules": { 5 | "prettier/prettier": "error" 6 | }, 7 | "env": { 8 | "es6": true, 9 | "node": true 10 | }, 11 | "parserOptions": { 12 | "ecmaVersion": 11 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/lib/api/whoami.js: -------------------------------------------------------------------------------- 1 | const { URL_DATA, ACTIONID_WHOAMI } = require('../../../constants'); 2 | const request = require('./request'); 3 | 4 | module.exports = function () { 5 | return request('POST', URL_DATA, { 6 | 'f.req': JSON.stringify([ 7 | [[ACTIONID_WHOAMI, [{ [ACTIONID_WHOAMI]: [] }], null, null, 0]], 8 | ]), 9 | }).then(resp => resp[ACTIONID_WHOAMI][0]); 10 | // resp has some other metadata, but don't know what it is quite yet 11 | }; 12 | -------------------------------------------------------------------------------- /src/lib/api/get-space-details.js: -------------------------------------------------------------------------------- 1 | const request = require('./request-batch'); 2 | const { RPCID_GET_SPACE_DETAILS } = require('../../../constants'); 3 | 4 | module.exports = function (chat) { 5 | return request({ 6 | 'f.req': JSON.stringify([ 7 | [ 8 | [ 9 | RPCID_GET_SPACE_DETAILS, 10 | JSON.stringify([[chat.uri, chat.id, 2]]), 11 | null, 12 | '2', 13 | ], 14 | ], 15 | ]), 16 | }).then(resp => resp[0][0]); 17 | }; 18 | -------------------------------------------------------------------------------- /src/lib/random-id.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | 3 | /** 4 | * AFACT this just needs to be a unique, base64-ish id 5 | * in testing, sending a "+" in this id causes a 500 error 6 | * so we generate too much data, remove things we don't care about 7 | * and hope we have enough data :) 8 | * 9 | * @param {Integer} size 10 | * 11 | * @return {String} 12 | */ 13 | module.exports = function (size) { 14 | return crypto 15 | .randomBytes(24) 16 | .toString('base64') 17 | .replace(/\+|\/|-|_/g, '') 18 | .substring(0, size); 19 | }; 20 | -------------------------------------------------------------------------------- /src/lib/api/get-available-rooms.js: -------------------------------------------------------------------------------- 1 | const request = require('./request'); 2 | const { 3 | URL_DATA, 4 | ACTIONID_GET_AVAILABLE_ROOMS, 5 | } = require('../../../constants'); 6 | 7 | module.exports = function () { 8 | return request('POST', URL_DATA, { 9 | 'f.req': JSON.stringify([ 10 | [ 11 | [ 12 | ACTIONID_GET_AVAILABLE_ROOMS, 13 | [{ [ACTIONID_GET_AVAILABLE_ROOMS]: [] }], 14 | null, 15 | null, 16 | 0, 17 | ], 18 | ], 19 | ]), 20 | }).then(resp => resp[ACTIONID_GET_AVAILABLE_ROOMS][0]); 21 | }; 22 | -------------------------------------------------------------------------------- /src/lib/api/get-chats.js: -------------------------------------------------------------------------------- 1 | const request = require('./request'); 2 | const { URL_DATA, ACTIONID_GET_CHATS } = require('../../../constants'); 3 | 4 | module.exports = function () { 5 | return request('POST', URL_DATA, { 6 | 'f.req': JSON.stringify([ 7 | [ 8 | [ 9 | ACTIONID_GET_CHATS, 10 | [ 11 | { 12 | [ACTIONID_GET_CHATS]: [null, [], []], 13 | }, 14 | ], 15 | null, 16 | null, 17 | 0, 18 | ], 19 | ], 20 | ]), 21 | }).then(resp => resp[ACTIONID_GET_CHATS]); 22 | }; 23 | -------------------------------------------------------------------------------- /src/lib/api/hide-chat.js: -------------------------------------------------------------------------------- 1 | const { URL_MUTATE, ACTIONID_HIDE_CHAT } = require('../../../constants'); 2 | const request = require('./request'); 3 | 4 | module.exports = function (chat) { 5 | return request('POST', URL_MUTATE, { 6 | 'f.req': JSON.stringify([ 7 | 'af.maf', 8 | [ 9 | [ 10 | 'af.add', 11 | ACTIONID_HIDE_CHAT, 12 | [{ [ACTIONID_HIDE_CHAT]: [[chat.uri, chat.id, 5], true] }], 13 | ], 14 | ], 15 | ]), 16 | }).then(resp => resp[0][0][1][ACTIONID_HIDE_CHAT]); 17 | // resp has some other metadata, but don't know what it is quite yet 18 | }; 19 | -------------------------------------------------------------------------------- /src/screens/confirm.js: -------------------------------------------------------------------------------- 1 | const blessed = require('neo-blessed'); 2 | 3 | const confirm = blessed.question({ 4 | top: 'center', 5 | left: 'center', 6 | border: { 7 | type: 'line', 8 | }, 9 | height: '50%', 10 | width: '50%', 11 | shadow: true, 12 | align: 'center', 13 | valign: 'center', 14 | }); 15 | 16 | function ask(question) { 17 | confirm.show(); 18 | return new Promise(res => { 19 | confirm.ask(`${question} [Yn]`, (err, ans) => { 20 | confirm.hide(); 21 | confirm.screen.render(); 22 | 23 | res(ans); 24 | }); 25 | }); 26 | } 27 | 28 | module.exports = { 29 | confirm, 30 | ask, 31 | }; 32 | -------------------------------------------------------------------------------- /src/lib/api/get-thread-messages.js: -------------------------------------------------------------------------------- 1 | const request = require('./request'); 2 | const { 3 | URL_DATA, 4 | ACTIONID_GET_THREAD_MESSAGES, 5 | } = require('../../../constants'); 6 | 7 | module.exports = function (thread) { 8 | return request('POST', URL_DATA, { 9 | 'f.req': JSON.stringify([ 10 | [ 11 | [ 12 | ACTIONID_GET_THREAD_MESSAGES, 13 | [ 14 | { 15 | [ACTIONID_GET_THREAD_MESSAGES]: [ 16 | [ 17 | thread.id, 18 | null, 19 | [`space/${thread.room.id}`, thread.room.id, 2], 20 | ], 21 | ], 22 | }, 23 | ], 24 | null, 25 | null, 26 | 0, 27 | ], 28 | ], 29 | ]), 30 | }).then(resp => resp[ACTIONID_GET_THREAD_MESSAGES][0]); 31 | }; 32 | -------------------------------------------------------------------------------- /src/lib/api/get-chat-messages.js: -------------------------------------------------------------------------------- 1 | const { URL_DATA, ACTIONID_GET_CHAT_MESSAGES } = require('../../../constants'); 2 | const timestamp = require('../timestamp'); 3 | const request = require('./request'); 4 | 5 | module.exports = function (chat, before) { 6 | return request('POST', URL_DATA, { 7 | 'f.req': JSON.stringify([ 8 | [ 9 | [ 10 | ACTIONID_GET_CHAT_MESSAGES, 11 | [ 12 | { 13 | [ACTIONID_GET_CHAT_MESSAGES]: [ 14 | [`dm/${chat.id}`, chat.id, 5], 15 | before || timestamp.now(), 16 | null, 17 | null, 18 | null, 19 | ], 20 | }, 21 | ], 22 | null, 23 | null, 24 | 0, 25 | ], 26 | ], 27 | ]), 28 | }).then(resp => resp[ACTIONID_GET_CHAT_MESSAGES][0]); 29 | }; 30 | -------------------------------------------------------------------------------- /src/lib/api/mark-read.js: -------------------------------------------------------------------------------- 1 | const { 2 | URL_DATA, 3 | ACTIONID_MARK_THREAD_READ, 4 | ACTIONID_MARK_DM_READ, 5 | } = require('../../../constants'); 6 | const request = require('./request'); 7 | const { now } = require('../timestamp'); 8 | 9 | module.exports = function (obj) { 10 | const actionId = obj.isDm ? ACTIONID_MARK_DM_READ : ACTIONID_MARK_THREAD_READ; 11 | const payload = obj.isDm 12 | ? [obj.uri, obj.id, 5] 13 | : [obj.id, null, [obj.room.uri, obj.room.id, 2]]; 14 | 15 | return request('POST', URL_DATA, { 16 | 'f.req': JSON.stringify([ 17 | [ 18 | [ 19 | actionId, 20 | [ 21 | { 22 | [actionId]: [payload, now()], 23 | }, 24 | ], 25 | null, 26 | null, 27 | 0, 28 | ], 29 | ], 30 | ]), 31 | }).then(res => res[actionId]); 32 | }; 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "axios": "^0.21.2", 4 | "chalk": "^4.1.2", 5 | "docopt": "^0.6.2", 6 | "lodash.get": "^4.4.2", 7 | "lodash.set": "^4.3.2", 8 | "moment": "^2.29.2", 9 | "neo-blessed": "https://github.com/helloandre/neo-blessed.git", 10 | "puppeteer": "^10.2.0", 11 | "qs": "^6.9.3" 12 | }, 13 | "name": "@helloandre/glycerin", 14 | "version": "0.0.1", 15 | "main": "index.js", 16 | "bin": { 17 | "gln": "./index.js" 18 | }, 19 | "license": "MIT", 20 | "devDependencies": { 21 | "babel-eslint": "^10.1.0", 22 | "eslint": "^6.8.0", 23 | "eslint-plugin-prettier": "^3.1.2", 24 | "prettier": "^2.0.4" 25 | }, 26 | "scripts": { 27 | "bootstrap": "yarn", 28 | "start": "node index.js", 29 | "debug": "node --inspect-brk index.js", 30 | "ev": "node index.js -e", 31 | "de": "node --inspect-brk index.js -e", 32 | "leave": "node index.js leave" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/lib/api/request-batch.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | const qs = require('qs'); 3 | const { fromBatchExecute } = require('./parse'); 4 | const auth = require('./auth'); 5 | const { DEFAULT_HEADERS, URL_BATCHEXECUTE } = require('../../../constants'); 6 | 7 | /** 8 | * 9 | * @param {String} method 10 | * @param {String} url 11 | * @param {Object} [data] default: {} 12 | * @param {Object} [params] default: {} 13 | * @param {Object} [headers] default: {} 14 | */ 15 | module.exports = function (data = {}) { 16 | const { at, cookie } = auth.requestData(); 17 | return axios({ 18 | method: 'POST', 19 | url: URL_BATCHEXECUTE, 20 | data: qs.stringify({ 21 | at, 22 | ...data, 23 | }), 24 | headers: { 25 | ...DEFAULT_HEADERS, 26 | cookie, 27 | }, 28 | }) 29 | .then(({ data }) => fromBatchExecute(data)) 30 | .catch(e => { 31 | console.log(e); 32 | return e.response.data; 33 | }); 34 | }; 35 | -------------------------------------------------------------------------------- /src/lib/timestamp.js: -------------------------------------------------------------------------------- 1 | const moment = require('moment'); 2 | 3 | /** 4 | * @param {Integer} [adjust] default: 0 - adjustment to the timestamp 5 | * 6 | * @return {String} 7 | */ 8 | function now(adjust = 0) { 9 | return (Date.now() + adjust).toString() + '000'; 10 | } 11 | 12 | /** 13 | * get a timestamp usable for fetching "more" of a thing 14 | * based on the original most recent timestamp 15 | * i.e. parse a string, subtract 1, .toString() 16 | * 17 | * @param {String} orig 18 | */ 19 | function more(orig) { 20 | return (parseInt(orig, 10) - 1).toString(); 21 | } 22 | 23 | /** 24 | * convert an api response 16-character- or integer-timestamp 25 | * to Moment to make it easier to format 26 | * 27 | * @param {String|Integer} ts 28 | * 29 | * @return {Moment} 30 | */ 31 | function from(ts) { 32 | return typeof ts === 'string' 33 | ? moment(parseInt(ts.substring(0, 13), 10)) 34 | : moment(ts); 35 | } 36 | 37 | module.exports = { 38 | now, 39 | from, 40 | more, 41 | }; 42 | -------------------------------------------------------------------------------- /src/lib/api/create-thread.js: -------------------------------------------------------------------------------- 1 | const { URL_MUTATE, ACTIONID_CREATE_THREAD } = require('../../../constants'); 2 | const randomId = require('../random-id'); 3 | const request = require('./request'); 4 | 5 | /** 6 | * WARNING: not tested 7 | * 8 | * @param {String} msg 9 | * @param {Object} room 10 | */ 11 | module.exports = function (msg, room) { 12 | // threads take their id from the first message id 13 | // so this is doing double duty 14 | const msgId = randomId(11); 15 | return request('POST', URL_MUTATE, { 16 | 'f.req': JSON.stringify([ 17 | 'af.maf', 18 | [ 19 | [ 20 | 'af.add', 21 | ACTIONID_CREATE_THREAD, 22 | [ 23 | { 24 | [ACTIONID_CREATE_THREAD]: [ 25 | [room.uri, room.id, 2], 26 | msg, 27 | [], 28 | msgId, 29 | [1], 30 | null, 31 | msgId, 32 | true, 33 | ], 34 | }, 35 | ], 36 | ], 37 | ], 38 | ]), 39 | }); 40 | }; 41 | -------------------------------------------------------------------------------- /src/lib/api/get-users.js: -------------------------------------------------------------------------------- 1 | const { URL_DATA, ACTIONID_GET_USERS } = require('../../../constants'); 2 | const request = require('./request'); 3 | 4 | module.exports = function (data) { 5 | return request('POST', URL_DATA, { 6 | 'f.req': JSON.stringify([ 7 | [ 8 | [ 9 | ACTIONID_GET_USERS, 10 | [ 11 | { 12 | [ACTIONID_GET_USERS]: [ 13 | [], 14 | null, 15 | data.map(datum => [ 16 | [`space/${datum.room.id}`, datum.room.id, 2], 17 | [ 18 | `user/${datum.id}`, 19 | null, 20 | datum.id, 21 | null, 22 | [datum.id, `human/${datum.id}`, 0], 23 | `user/human/${datum.id}`, 24 | ], 25 | ]), 26 | ], 27 | }, 28 | ], 29 | null, 30 | null, 31 | 0, 32 | ], 33 | ], 34 | ]), 35 | }).then(resp => resp[ACTIONID_GET_USERS][1]); 36 | }; 37 | -------------------------------------------------------------------------------- /src/screens/working.js: -------------------------------------------------------------------------------- 1 | const blessed = require('neo-blessed'); 2 | 3 | const FGS = [ 4 | '#000000', 5 | '#666666', 6 | '#999999', 7 | '#cccccc', 8 | '#ffffff', 9 | '#cccccc', 10 | '#999999', 11 | '#666666', 12 | ]; 13 | 14 | const working = blessed.box({ 15 | bottom: 0, 16 | left: 0, 17 | height: 1, 18 | width: '100%', 19 | hidden: true, 20 | style: { 21 | fg: FGS[0], 22 | bg: '#333333', 23 | }, 24 | content: 'working...', 25 | }); 26 | working._ = { 27 | counter: 0, 28 | }; 29 | 30 | function show() { 31 | working.show(); 32 | 33 | if (working._.timer) { 34 | working._.counter = 0; 35 | clearInterval(working._.timer); 36 | } 37 | 38 | working._.timer = setInterval(function () { 39 | working.style.fg = FGS[working._.counter % FGS.length]; 40 | working.screen.render(); 41 | working._.counter++; 42 | }, 150); 43 | } 44 | 45 | function hide() { 46 | clearInterval(working._.timer); 47 | working.style.fg = FGS[0]; 48 | working.hide(); 49 | working.screen.render(); 50 | } 51 | 52 | module.exports = { 53 | working, 54 | show, 55 | hide, 56 | }; 57 | -------------------------------------------------------------------------------- /src/lib/api/get-chat-threads.js: -------------------------------------------------------------------------------- 1 | const { URL_DATA, ACTIONID_GET_CHAT_THREADS } = require('../../../constants'); 2 | const request = require('./request'); 3 | 4 | module.exports = function (chat, before, preview = false) { 5 | const param = preview 6 | ? [[chat.uri, chat.id, 2], true, null, null, true, null, true] 7 | : [ 8 | [`space/${chat.id}`, chat.id, 2], 9 | null, 10 | null, 11 | null, 12 | null, 13 | null, 14 | null, 15 | before, 16 | null, 17 | false, 18 | true, 19 | null, 20 | false, 21 | null, 22 | [1, false], 23 | false, 24 | false, 25 | ]; 26 | return request('POST', URL_DATA, { 27 | 'f.req': JSON.stringify([ 28 | [ 29 | [ 30 | ACTIONID_GET_CHAT_THREADS, 31 | [ 32 | { 33 | [ACTIONID_GET_CHAT_THREADS]: param, 34 | }, 35 | ], 36 | null, 37 | null, 38 | 0, 39 | ], 40 | ], 41 | ]), 42 | }).then(resp => resp[ACTIONID_GET_CHAT_THREADS]); 43 | }; 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Andre Bluehs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/lib/api/request.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | const qs = require('qs'); 3 | const parse = require('./parse'); 4 | const auth = require('./auth'); 5 | const { DEFAULT_HEADERS } = require('../../../constants'); 6 | 7 | const DEFAULT_PARAMS = { 8 | rt: 'c', 9 | }; 10 | 11 | /** 12 | * 13 | * @param {String} method 14 | * @param {String} url 15 | * @param {Object} [data] default: {} 16 | * @param {Object} [params] default: {} 17 | * @param {Object} [headers] default: {} 18 | */ 19 | module.exports = function (method, url, data = {}, params = {}, headers = {}) { 20 | const { at, cookie } = auth.requestData(); 21 | return axios({ 22 | method, 23 | url, 24 | data: qs.stringify({ 25 | at, 26 | ...data, 27 | }), 28 | params: { 29 | ...DEFAULT_PARAMS, 30 | ...params, 31 | }, 32 | headers: { 33 | ...DEFAULT_HEADERS, 34 | cookie, 35 | ...headers, 36 | }, 37 | }) 38 | .then(({ data }) => { 39 | const resp = parse.fromResponse(data); 40 | return resp[0][0][2] ? resp[0][0][2] : resp; 41 | }) 42 | .catch(e => { 43 | console.log(e); 44 | return e.response.data; 45 | }); 46 | }; 47 | -------------------------------------------------------------------------------- /src/lib/config.js: -------------------------------------------------------------------------------- 1 | const os = require('os'); 2 | const path = require('path'); 3 | const fs = require('fs'); 4 | const loget = require('lodash.get'); 5 | const loset = require('lodash.set'); 6 | 7 | const FILENAME = '.glycerinconfig.json'; 8 | const FILE = path.join(os.homedir(), FILENAME); 9 | let configData = undefined; 10 | 11 | function get(path, def) { 12 | if (!configData) { 13 | try { 14 | configData = JSON.parse(fs.readFileSync(FILE, 'utf8')); 15 | } catch (e) { 16 | if (e instanceof SyntaxError) { 17 | console.error(`${FILE} invalid json`); 18 | process.exit(1); 19 | } 20 | 21 | // assume file doesn't exist, nuke it 22 | configData = {}; 23 | // and write it as empty 24 | persist(); 25 | } 26 | } 27 | 28 | if (!path) { 29 | return configData; 30 | } 31 | 32 | return loget(configData, path, def); 33 | } 34 | 35 | function set(path, value) { 36 | if (!configData) { 37 | get(); 38 | } 39 | 40 | loset(configData, path, value); 41 | 42 | persist(); 43 | } 44 | 45 | function persist() { 46 | fs.writeFileSync(FILE, JSON.stringify(configData)); 47 | } 48 | 49 | module.exports = { 50 | get, 51 | set, 52 | }; 53 | -------------------------------------------------------------------------------- /src/lib/api/send-chat-message.js: -------------------------------------------------------------------------------- 1 | const { 2 | URL_MUTATE, 3 | ACTIONID_SEND_CHAT_MESSAGE, 4 | } = require('../../../constants'); 5 | const randomId = require('../random-id'); 6 | const request = require('./request'); 7 | 8 | /** 9 | * @param {String} msg 10 | * @param {Object} room 11 | * @param {Boolean} [history] default: false 12 | * 13 | * @return {Promise} 14 | */ 15 | module.exports = function (msg, room, history = false) { 16 | const msgId = randomId(11); 17 | 18 | return request('POST', URL_MUTATE, { 19 | 'f.req': JSON.stringify([ 20 | 'af.maf', 21 | [ 22 | [ 23 | 'af.add', 24 | // shrug 25 | parseInt(ACTIONID_SEND_CHAT_MESSAGE, 10), 26 | [ 27 | { 28 | [ACTIONID_SEND_CHAT_MESSAGE]: [ 29 | [`dm/${room.id}`, room.id, 5], 30 | msg, 31 | [], 32 | msgId, 33 | // 1 = history on 34 | // 2 = history off 35 | [history ? 1 : 2], 36 | null, 37 | msgId, 38 | true, 39 | ], 40 | }, 41 | ], 42 | ], 43 | ], 44 | ]), 45 | }); 46 | }; 47 | -------------------------------------------------------------------------------- /src/lib/api/send-thread-message.js: -------------------------------------------------------------------------------- 1 | const { 2 | URL_MUTATE, 3 | ACTIONID_SEND_THREAD_MESSAGE, 4 | } = require('../../../constants'); 5 | const randomId = require('../random-id'); 6 | const request = require('./request'); 7 | 8 | module.exports = function (msg, thread) { 9 | const msgId = randomId(11); 10 | return request('POST', URL_MUTATE, { 11 | 'f.req': JSON.stringify([ 12 | 'af.maf', 13 | [ 14 | [ 15 | 'af.add', 16 | ACTIONID_SEND_THREAD_MESSAGE, 17 | [ 18 | { 19 | [ACTIONID_SEND_THREAD_MESSAGE]: [ 20 | msgId, 21 | [`space/${thread.room.id}`, thread.room.id, 2], 22 | thread.id, 23 | null, 24 | null, 25 | msg, 26 | [], 27 | [ 28 | msgId, 29 | null, 30 | [ 31 | thread.id, 32 | null, 33 | [`space/${thread.room.id}`, thread.room.id, 2], 34 | ], 35 | ], 36 | ], 37 | }, 38 | ], 39 | ], 40 | ], 41 | ]), 42 | }).then(resp => resp[0][0][1][ACTIONID_SEND_THREAD_MESSAGE]); 43 | }; 44 | -------------------------------------------------------------------------------- /src/lib/api/set-room-membership.js: -------------------------------------------------------------------------------- 1 | const { 2 | URL_DATA, 3 | ACTIONID_SET_ROOM_MEMBERSHIP, 4 | } = require('../../../constants'); 5 | const request = require('./request'); 6 | 7 | module.exports = function (chat, user, join = true) { 8 | const action = join ? 2 : 3; // hooray magic values! 9 | return request('POST', URL_DATA, { 10 | 'f.req': JSON.stringify([ 11 | [ 12 | [ 13 | ACTIONID_SET_ROOM_MEMBERSHIP, 14 | [ 15 | { 16 | [ACTIONID_SET_ROOM_MEMBERSHIP]: [ 17 | [], 18 | [chat.uri, chat.id, 2], 19 | action, 20 | [ 21 | [ 22 | `user/${user.id}`, 23 | null, 24 | user.id, 25 | null, 26 | [user.id, `human/${user.id}`, 0], 27 | `user/human/${user.id}`, 28 | ], 29 | ], 30 | null, 31 | null, 32 | [], 33 | ], 34 | }, 35 | ], 36 | null, 37 | null, 38 | 0, 39 | ], 40 | ], 41 | ]), 42 | }).then(resp => resp[ACTIONID_SET_ROOM_MEMBERSHIP][0]); 43 | // resp has some other metadata, but don't know what it is quite yet 44 | }; 45 | -------------------------------------------------------------------------------- /src/screen.js: -------------------------------------------------------------------------------- 1 | const blessed = require('neo-blessed'); 2 | const EE = require('./lib/eventemitter'); 3 | 4 | function bootstrap() { 5 | /** 6 | * set up our global screen object 7 | * MUST be done before screens are required for... reasons 8 | */ 9 | const screen = blessed.screen({ 10 | smartCSR: true, 11 | fullUnicode: true, 12 | cursor: { 13 | shape: 'line', 14 | blink: true, 15 | }, 16 | }); 17 | 18 | const { chats } = require('./screens/chats'); 19 | const { threads } = require('./screens/threads'); 20 | const { messages } = require('./screens/messages'); 21 | const { input } = require('./screens/input'); 22 | const { search } = require('./screens/search'); 23 | const { confirm } = require('./screens/confirm'); 24 | const { working } = require('./screens/working'); 25 | 26 | /** 27 | * add all our objects to the screen 28 | */ 29 | screen.append(chats); 30 | screen.append(threads); 31 | screen.append(messages); 32 | screen.append(input); 33 | screen.append(search); 34 | screen.append(confirm); 35 | screen.append(working); 36 | 37 | // initial focus given to sidebar to select a chat room 38 | chats.focus(); 39 | 40 | screen.title = 'GChat TUI'; 41 | 42 | screen.key('C-f /', () => EE.emit('search.local')); 43 | screen.key('C-f f', () => EE.emit('search.remote')); 44 | screen.key('C-n', async () => EE.emit('unread.next')); 45 | screen.key('C-d', () => process.exit(0)); 46 | 47 | EE.emit('screen.ready'); 48 | } 49 | 50 | module.exports = { 51 | bootstrap, 52 | }; 53 | -------------------------------------------------------------------------------- /src/lib/model/user.js: -------------------------------------------------------------------------------- 1 | const getUsers = require('../api/get-users'); 2 | const wai = require('../api/whoami'); 3 | const unpack = require('../api/unpack'); 4 | 5 | const cache = {}; 6 | let dirty = []; 7 | let _self; 8 | 9 | /** 10 | * fetch a result from a "whoami" api 11 | */ 12 | async function whoami() { 13 | if (!_self) { 14 | _self = await wai().then(unpack.user); 15 | if (!cache[_self.id]) { 16 | cache[_self.id] = _self; 17 | } 18 | } 19 | 20 | return _self; 21 | } 22 | 23 | /** 24 | * Tell the cache about a user we've seen in a thread/dm 25 | * we then lazily collect all users we haven't seen yet in dirty 26 | * and fetch them as late as possible (on first call to name()) 27 | * 28 | * @param {unpack.user} user 29 | * 30 | * @return {void} 31 | */ 32 | function prefetch(user) { 33 | // if we've got a DM, the user comes already fetched 34 | if (user.name && user.name.length) { 35 | cache[user.id] = user; 36 | } 37 | 38 | // otherwise it's a user stub and we'll need to fetch later 39 | if (!cache[user.id]) { 40 | dirty.push(user); 41 | } 42 | } 43 | 44 | /** 45 | * go get any users in dirty 46 | */ 47 | async function fetch() { 48 | if (dirty.length) { 49 | const users = await getUsers(dirty); 50 | users.forEach(u => { 51 | const unpacked = unpack.user(u[1][1]); 52 | cache[unpacked.id] = unpacked; 53 | }); 54 | dirty = []; 55 | } 56 | } 57 | 58 | /** 59 | * wait for any users to be fetched, then return 60 | * the now-guaranteed-to-be-in-cache user's name 61 | * fetch() is a no-op if no users need to be fetched 62 | * 63 | * @param {unpack.user} user 64 | * 65 | * @return {String} 66 | */ 67 | function name(user) { 68 | return fetch().then(() => cache[user.id].name); 69 | } 70 | 71 | module.exports = { 72 | prefetch, 73 | name, 74 | whoami, 75 | }; 76 | -------------------------------------------------------------------------------- /src/lib/api/parse.js: -------------------------------------------------------------------------------- 1 | function fromResponse(contents) { 2 | // first two lines are garbage 3 | // we ignore all three 4 | // eslint-disable-next-line no-unused-vars 5 | const [_, rest] = readUntil(contents, '\n'); 6 | return parse(rest.trimLeft()); 7 | } 8 | 9 | /** 10 | * Events endpoing has a slightly different "structure" than data endpoint 11 | * so we need to treat it like the special endpoint it is 12 | * 13 | * @param {String} contents 14 | */ 15 | function fromEvents(contents) { 16 | return parse(contents, 0); 17 | } 18 | 19 | /** 20 | * Batch Events are "simpler" 21 | * 22 | * @param {String} contents 23 | */ 24 | function fromBatchExecute(contents) { 25 | // first line is garbage 26 | // eslint-disable-next-line no-unused-vars 27 | const [_, rest] = readUntil(contents, '\n'); 28 | // yeah, nested strings of json 29 | return JSON.parse(rest.trimLeft()) 30 | .filter(req => req[0] === 'wrb.fr') 31 | .map(req => JSON.parse(req[2])); 32 | } 33 | 34 | /** 35 | * parse a response from /data 36 | * structure is: 37 | * - length of response 38 | * 39 | * ... other length + responses 40 | * @param {String} contents 41 | */ 42 | function parse(contents, adjust = 1) { 43 | let rest = contents; 44 | let resp; 45 | let nextIdx; 46 | const responses = []; 47 | while (rest.length) { 48 | [nextIdx, rest] = readUntil(rest, '\n'); 49 | [resp, rest] = readUntil(rest, parseInt(nextIdx, 10) - adjust); 50 | responses.push(JSON.parse(resp)); 51 | } 52 | return responses; 53 | } 54 | 55 | /** 56 | * reads a string until either the given index of character 57 | * there are probably better ways to do this 58 | * 59 | * @param {String} str 60 | * @param {Number|String} until 61 | * 62 | * @returns [String, String] - [desired read value, rest] 63 | */ 64 | function readUntil(str, until) { 65 | const len = typeof until === 'string' ? str.indexOf(until) + 1 : until; 66 | return [str.substring(0, len), str.substring(len)]; 67 | } 68 | 69 | module.exports = { 70 | fromResponse, 71 | fromEvents, 72 | fromBatchExecute, 73 | }; 74 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const { docopt } = require('docopt'); 2 | const auth = require('./src/lib/api/auth'); 3 | const EE = require('./src/lib/eventemitter'); 4 | // all other imports must be inside auth.init().then() function 5 | // as anything that requires auth will need to happen inside here 6 | 7 | const doc = ` 8 | Glycerin - Google Chat Terminal User Interface 9 | 10 | Usage: 11 | gln [options] 12 | gln leave [--auth] 13 | gln run [options] 14 | gln func 15 | 16 | Options: 17 | -h --help Show This Message 18 | -a --auth Force reauthenticate with Firefox 19 | -e --events Do not start a UI, instead print events stream. Overrides all other actions. 20 | `; 21 | 22 | const opts = docopt(doc); 23 | auth.init(opts).then(() => { 24 | const events = require('./src/lib/api/events'); 25 | 26 | if (opts.func) { 27 | // const getAvailableRooms = require('./src/lib/api/get-available-rooms'); 28 | // const getChats = require('./src/lib/api/get-chats'); 29 | // const getSpaceDetails = require('./src/lib/api/get-space-details'); 30 | const Chat = require('./src/lib/model/chat'); 31 | // const unpack = require('./src/lib/api/unpack'); 32 | const format = require('./src/lib/format'); 33 | 34 | // getSpaceDetails({ uri: 'space/AAAAsCC42fA', id: 'AAAAsCC42fA' }).then( 35 | // d => { 36 | // console.log(JSON.stringify(unpack.threads(d), null, 2)); 37 | // } 38 | // ); 39 | //chat.google.com/room/AAAAoxNPLX8/T51Q_Qp8rZA 40 | https: Chat.fetchThreads({ 41 | uri: 'space/AAAAsCC42fA', 42 | id: 'AAAAsCC42fA', 43 | }).then(d => { 44 | Chat.fetchMessages(d.threads[4]) 45 | .then(m => Promise.all(m.map(x => format.message(x)))) 46 | .then(ms => ms.forEach(m => console.log(m))); 47 | // console.log(d); 48 | }); 49 | } else if (opts['--events']) { 50 | events(); 51 | EE.on('events.*', evt => { 52 | console.log(JSON.stringify(evt, null, 2)); 53 | }); 54 | } else if (opts.leave) { 55 | const Prune = require('./src/screens/prune'); 56 | Prune.bootstrap(); 57 | } else if (opts.run) { 58 | const req = require('./src/lib/api/request'); 59 | const { URL_DATA } = require('./constants'); 60 | req('POST', URL_DATA, { 'f.req': opts[''] }).then(resp => 61 | console.log(JSON.stringify(resp, null, 2)) 62 | ); 63 | } else { 64 | events(); 65 | const Screen = require('./src/screen'); 66 | Screen.bootstrap(); 67 | } 68 | }); 69 | -------------------------------------------------------------------------------- /src/lib/api/events.js: -------------------------------------------------------------------------------- 1 | const https = require('https'); 2 | const qs = require('qs'); 3 | const auth = require('./auth'); 4 | const parse = require('./parse'); 5 | const EE = require('../eventemitter'); 6 | const { URL_EVENTS, DEFAULT_HEADERS } = require('../../../constants'); 7 | const unpack = require('./unpack'); 8 | 9 | let AID = 0; 10 | 11 | /** 12 | * Long poll for events to come in and update the chat 13 | * NOTE: there should only ever be one events() call running at a time 14 | * 15 | * @see auth.register() 16 | * @see auth.eventsData() 17 | * 18 | * @param {Boolean} [refresh] default: false - force a re-register 19 | * 20 | * @return {Promise} 21 | */ 22 | module.exports = function () { 23 | process.nextTick(async () => { 24 | let refresh = true; 25 | // eslint-disable-next-line no-constant-condition 26 | while (true) { 27 | try { 28 | await longPoll(refresh); 29 | refresh = false; 30 | } catch (e) { 31 | EE.emit('events.error', e); 32 | refresh = true; 33 | } 34 | } 35 | }); 36 | }; 37 | 38 | async function longPoll(refresh = false) { 39 | if (refresh) { 40 | AID = 0; 41 | } 42 | 43 | const { SID, cookie } = await auth.eventsData(refresh); 44 | 45 | return new Promise(resolve => { 46 | https 47 | .get( 48 | URL_EVENTS + 49 | '?' + 50 | qs.stringify({ 51 | RID: 'rpc', 52 | SID, 53 | AID, // ID of the last message from the previous /events call 54 | }), 55 | { 56 | headers: { 57 | ...DEFAULT_HEADERS, 58 | cookie, 59 | }, 60 | }, 61 | response => { 62 | response.setEncoding('utf8'); 63 | 64 | let data = ''; 65 | response.on('data', chunk => { 66 | data += chunk.toString(); 67 | 68 | try { 69 | const parsed = parse.fromEvents(data); 70 | parsed[0].map(unpack.event).forEach(evt => { 71 | EE.emit(`events.${evt._type}`, evt); 72 | EE.emit(`events.*`, evt); 73 | }); 74 | AID = parsed[0][parsed[0].length - 1][0]; 75 | 76 | data = ''; 77 | } catch (e) { 78 | // console.log(e, data); 79 | // can be expected as chunks aren't always complete json, 80 | // just keep going, we'll append more data next time around 81 | } 82 | }); 83 | response.on('end', () => resolve()); 84 | } 85 | ) 86 | .on('error', e => { 87 | console.log(e); 88 | }); 89 | }); 90 | } 91 | -------------------------------------------------------------------------------- /src/screens/messages.js: -------------------------------------------------------------------------------- 1 | const blessed = require('neo-blessed'); 2 | const format = require('../lib/format'); 3 | const State = require('../lib/state'); 4 | // const Chat = require('../lib/model/chat'); 5 | const EE = require('../lib/eventemitter'); 6 | 7 | const DEFAULT_CONTENT = ' Select A Chat or Thread'; 8 | 9 | const messages = blessed.box({ 10 | label: 'Messages', 11 | left: '25%', 12 | top: '25%', 13 | height: '65%', 14 | width: '75%', 15 | tags: true, 16 | border: { 17 | type: 'line', 18 | }, 19 | content: DEFAULT_CONTENT, 20 | scrollable: true, 21 | scrollbar: { 22 | style: { 23 | fg: 'black', 24 | bg: 'white', 25 | }, 26 | }, 27 | alwaysScroll: true, 28 | }); 29 | 30 | async function display() { 31 | const displayable = State.messages(); 32 | if (!displayable) { 33 | messages.setContent(DEFAULT_CONTENT); 34 | messages.screen.render(); 35 | return; 36 | } 37 | 38 | if (!displayable.messages || !displayable.messages.length) { 39 | if (displayable.loading) { 40 | messages.setContent(format.placehold('loading')); 41 | } else { 42 | messages.setContent(format.placehold('no messages, yet')); 43 | } 44 | messages.screen.render(); 45 | return; 46 | } 47 | 48 | const formatted = []; 49 | for (let msg of displayable.messages) { 50 | formatted.push(await format.message(msg)); 51 | } 52 | 53 | // we've got a thread, and potentially unfetched messages 54 | if (displayable.unfetched > 0) { 55 | const str = displayable.loading 56 | ? 'loading' 57 | : `expand ${displayable.unfetched} more`; 58 | formatted.splice(1, 0, format.placehold(str)); 59 | } 60 | 61 | // for unthreaded rooms/DMs we want to let them know they can load more 62 | if (displayable.hasMore) { 63 | formatted.unshift(format.placehold('load more')); 64 | } 65 | 66 | messages.setContent(formatted.join('\n')); 67 | messages.setScrollPerc(100); 68 | messages.screen.render(); 69 | } 70 | EE.on('messages.scroll.down', () => { 71 | messages.scroll(1); 72 | messages.screen.render(); 73 | }); 74 | EE.on('messages.scroll.bottom', () => { 75 | messages.setScrollPerc(100); 76 | messages.screen.render(); 77 | }); 78 | EE.on('messages.scroll.up', () => { 79 | messages.scroll(-1); 80 | messages.screen.render(); 81 | if (messages.getScroll() === 0) { 82 | EE.emit('messages.at.top'); 83 | } 84 | }); 85 | EE.on('messages.scroll.top', () => { 86 | messages.scrollTo(0); 87 | messages.screen.render(); 88 | EE.emit('messages.at.top'); 89 | }); 90 | EE.on('state.chats.loading', display); 91 | EE.on('state.messages.updated', display); 92 | 93 | module.exports = { 94 | messages, 95 | }; 96 | -------------------------------------------------------------------------------- /constants.js: -------------------------------------------------------------------------------- 1 | const URL_DATA = 'https://chat.google.com/_/DynamiteWebUi/data'; 2 | const URL_MUTATE = 'https://chat.google.com/_/DynamiteWebUi/mutate'; 3 | const URL_EVENTS = 'https://chat.google.com/u/0/webchannel/events'; 4 | const URL_REGISTER = 'https://chat.google.com/u/0/webchannel/register'; 5 | const URL_BATCHEXECUTE = 6 | 'https://chat.google.com/u/0/_/DynamiteWebUi/data/batchexecute'; 7 | const DEFAULT_HEADERS = { 8 | 'content-type': 'application/x-www-form-urlencoded;charset=UTF-8', 9 | 'User-Agent': 'glycerin-tui/0.0.1', 10 | accept: '*/*', 11 | }; 12 | 13 | const ACTIONID_GET_CHATS = '115617451'; 14 | // _GET_CHAT_THREADS seems unnecessary. 15 | // we COULD use _GET_CHAT_MESSAGES here as it returns the same threads + messages 16 | // but with slightly more (as of yet unknown) metadata 17 | // at any rate, we call this to populate the initial threads 18 | // but need to call _MESSAGES to load *more* 19 | // ¯\_(ツ)_/¯ 20 | const ACTIONID_GET_CHAT_THREADS = '120296683'; 21 | const ACTIONID_GET_USERS = '147662381'; 22 | const ACTIONID_GET_THREAD_MESSAGES = '120296731'; 23 | const ACTIONID_SEND_THREAD_MESSAGE = '115099363'; 24 | const ACTIONID_CREATE_THREAD = '120594192'; // yes, the same as SEND_DM_MESSAGE 25 | const ACTIONID_GET_CHAT_MESSAGES = '120296718'; 26 | const ACTIONID_SEND_CHAT_MESSAGE = '120594192'; 27 | const ACTIONID_GET_AVAILABLE_ROOMS = '144362466'; 28 | const ACTIONID_SET_ROOM_MEMBERSHIP = '145360970'; // join or leave a room 29 | const ACTIONID_WHOAMI = '115617453'; 30 | const ACTIONID_HIDE_CHAT = '145597873'; // hide a DM 31 | const ACTIONID_MARK_THREAD_READ = '120296768'; 32 | const ACTIONID_MARK_DM_READ = '163732318'; 33 | // const ACTIONID_CHAT_MEMBERS = '115617454'; // [[[115617454,[{"115617454":[["space/AAAAnDXy3Ws","AAAAnDXy3Ws",2],true]}],null,null,0]]] 34 | const RPCID_GET_SPACE_DETAILS = 'W9QdYe'; 35 | 36 | const COLORS_ACTIVE_ITEM = { 37 | fg: 'white', 38 | }; 39 | const COLORS_ACTIVE_SELECTED = { 40 | fg: 'white', 41 | bg: 'grey', 42 | }; 43 | const COLORS_INACTIVE_ITEM = { 44 | fg: 'grey', 45 | }; 46 | const COLORS_INACTIVE_SELECTED = { 47 | fg: 'black', 48 | bg: 'grey', 49 | }; 50 | 51 | module.exports = { 52 | URL_DATA, 53 | URL_MUTATE, 54 | URL_EVENTS, 55 | URL_REGISTER, 56 | URL_BATCHEXECUTE, 57 | DEFAULT_HEADERS, 58 | ACTIONID_GET_CHATS, 59 | ACTIONID_GET_CHAT_MESSAGES, 60 | ACTIONID_GET_CHAT_THREADS, 61 | ACTIONID_GET_THREAD_MESSAGES, 62 | ACTIONID_GET_USERS, 63 | ACTIONID_SEND_CHAT_MESSAGE, 64 | ACTIONID_SEND_THREAD_MESSAGE, 65 | ACTIONID_CREATE_THREAD, 66 | ACTIONID_GET_AVAILABLE_ROOMS, 67 | ACTIONID_SET_ROOM_MEMBERSHIP, 68 | ACTIONID_MARK_THREAD_READ, 69 | ACTIONID_MARK_DM_READ, 70 | ACTIONID_WHOAMI, 71 | ACTIONID_HIDE_CHAT, 72 | COLORS_ACTIVE_ITEM, 73 | COLORS_ACTIVE_SELECTED, 74 | COLORS_INACTIVE_ITEM, 75 | COLORS_INACTIVE_SELECTED, 76 | RPCID_GET_SPACE_DETAILS, 77 | }; 78 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Glycerin 2 | 3 | ## A Google Chat Terminal User Interface 4 | 5 | ## This is a WIP. Maybe don't use this for your daily driver chat app. 6 | 7 | # Usage 8 | 9 | ## Install 10 | 11 | - install: `yarn bootstrap` [^1] 12 | 13 | ## Running 14 | 15 | - run: `yarn start` 16 | - watch events go by: `yarn ev` 17 | 18 | ## Helpful One-offs 19 | 20 | - Quickly leave a bunch of rooms and/or dms: 21 | - `yarn leave` 22 | 23 | # Key Bindings 24 | 25 | ### Global 26 | 27 | - `ctrl+d` - exit 28 | - `ctrl+n` - go to the newest unread message 29 | - `ctrl+f f` - find rooms to join 30 | - `ctrl+f /` - find rooms/dms already joined 31 | 32 | ### Chats 33 | 34 | - `enter` - select chat 35 | - `j`/`k`/`up`/`down`/`g`/`shift+g` - navigate 36 | - `e` - expand/collapse section 37 | - `ctrl+r l` - leave room 38 | - (todo) `ctrl+r s` - star/unstar 39 | - (todo) `ctrl+r m` - mute/unmute 40 | - (todo) `ctrl+r a` - add user/bot 41 | 42 | ### Threads 43 | 44 | - `enter` - select thread 45 | - `escape` - exit to chats 46 | - `j`/`k`/`up`/`down`/`g`/`shift+g` - navigate 47 | - `ctrl+t n` - new thread 48 | 49 | ### Input 50 | 51 | - `escape` - exit to threads or chats (in case of dm) 52 | - `ctrl+p` - toggle chat history 53 | 54 | ### Messages 55 | 56 | - `ctrl+j`/`ctrl+k`/`ctrl+g`/`ctrl+l` - navitage (todo: update ctrl+l to ctrl+shift+g) 57 | - `ctrl+e` - expand 58 | 59 | # Status 60 | 61 | ## Working 62 | 63 | - View Rooms, DMs 64 | - Send messages 65 | - Unread callouts 66 | - manually refresh rooms/threads/messages with `C-r` 67 | 68 | ## Almost working 69 | 70 | - Event subscription (`api.js#events` and `unpack/events.js`) 71 | - Room/DM search for exsting chats (`screens/search.js`) 72 | - Create thread in a room (`api.js#newThread`) 73 | 74 | ## Missing 75 | 76 | - Fetch more threads/messages 77 | - scrolling messages 78 | - User/Room search for new/non-joined (unknown) 79 | - Mark As Read when joining a room/dm (probably either `/log` or `/events` endpoints) 80 | 81 | ## New Features I'd Like To Add 82 | 83 | - C-escape to mark all chats read 84 | - C-tab to switch between MRU chats 85 | - basic configuration customization 86 | - upgrade/improved rendering for neo-blessed. it's honestly kind crap. 87 | 88 | # How It Works 89 | 90 | - auth, grab cookies (`src/lib/api/auth.js#init`) 91 | - register to listen to events (`src/lib/api/events.js`) 92 | - bootstrap screen (`index.js`, `src/screen.js`) 93 | - fetch all chats (`index.js`, `src/lib/model/chats.js#getAll`, `src/lib/api/get-chats.js`) 94 | - when chat selected either: 95 | - (`isDm`) fetch chat messages (`src/screens/messages.js`, `src/lib/api/get-chat-messages.js`) 96 | - (`!isDm`) fetch chat threads (`src/screens/threads.js`, `src/lib/api/get-chat-threads.js`) 97 | - listen for user input (`src/screens/input.js`) 98 | 99 | users are fetched/cached in `lib/model/user.js` 100 | 101 | [^1]: we can't use chrome because https://support.google.com/accounts/thread/22873505?msgid=24501976 102 | -------------------------------------------------------------------------------- /src/screens/input.js: -------------------------------------------------------------------------------- 1 | const blessed = require('neo-blessed'); 2 | const EE = require('../lib/eventemitter'); 3 | const State = require('../lib/state'); 4 | const sendChatMessage = require('../lib/api/send-chat-message'); 5 | const sendThreadMessage = require('../lib/api/send-thread-message'); 6 | const createThread = require('../lib/api/create-thread'); 7 | const Chat = require('../lib/model/chat'); 8 | const config = require('../lib/config'); 9 | 10 | const input = blessed.textbox({ 11 | label: 'Input', 12 | height: '10%+1', 13 | width: '75%', 14 | top: '90%', 15 | left: '25%', 16 | border: { 17 | type: 'line', 18 | }, 19 | cursor: { 20 | artificial: true, 21 | shape: 'underline', 22 | blink: true, 23 | }, 24 | }); 25 | input._data = {}; 26 | 27 | function history() { 28 | return config.get(`history.${input._data.chat.id}`, false); 29 | } 30 | 31 | input.key('C-k', () => EE.emit('messages.scroll.up')); 32 | input.key('linefeed', () => EE.emit('messages.scroll.down')); 33 | input.key('C-g', () => EE.emit('messages.scroll.top')); 34 | input.key('C-l', () => EE.emit('messages.scroll.bottom')); 35 | input.key('C-e', () => EE.emit('messages.expand')); 36 | input.key('C-u', () => EE.emit('input.threads.up')); 37 | input.key('C-r', () => EE.emit('input.threads.down')); 38 | // input.key('C-p', () => { 39 | // if (input._data.chat.isDm) { 40 | // const curr = history(); 41 | // config.set(`history.${input._data.chat.id}`, !curr); 42 | // input.setLabel(`Input (history: ${!curr ? 'on' : 'off'})`); 43 | // input.screen.render(); 44 | // } 45 | // }); 46 | 47 | // these listeners need to be duplicated from screen 48 | // as input captures keys and doesn't bubble them 49 | // input.key('C-t /', () => EE.emit('search.local')); 50 | // input.key('C-t f', () => EE.emit('search.remote')); 51 | input.key('C-n', async () => EE.emit('unread.next')); 52 | input.key('C-d', () => process.exit(0)); 53 | input.key('escape', () => { 54 | EE.emit('input.blur'); 55 | input._data = false; 56 | }); 57 | input.key('C-t c', () => { 58 | EE.emit('input.blur', true); 59 | input._data = false; 60 | }); 61 | input.key('C-t l', () => EE.emit('state.pop')); 62 | 63 | input.on('focus', () => { 64 | const chat = State.chat(); 65 | // if (!chat.isThreaded) { 66 | // input.setLabel(`Input (history: ${history() ? 'on' : 'off'})`); 67 | // } 68 | 69 | input.readInput((_, value) => { 70 | input.clearValue(); 71 | if (value !== null) { 72 | if (value.length) { 73 | // fire and forget these 74 | // we get an event when the message is sent 75 | if (chat.isDm) { 76 | // sendChatMessage(value, chat, history()); 77 | // TODO figure out where in the protocol is history stored 78 | sendChatMessage(value, chat); 79 | } else if (chat.isThreaded) { 80 | sendThreadMessage(value, State.thread()); 81 | } else { 82 | createThread(value, chat); 83 | } 84 | } 85 | 86 | // input gets a little assume-y on submit 87 | // so let's give ourselves focus again 88 | input.focus(); 89 | } 90 | 91 | input.screen.render(); 92 | }); 93 | }); 94 | input.on('blur', () => { 95 | input.setLabel(`Input`); 96 | }); 97 | 98 | EE.on('state.messages.updated', () => { 99 | const c = State.chat(); 100 | if (c && (c.isDm || !c.isThreaded || State.thread())) { 101 | input.focus(); 102 | input.screen.render(); 103 | } 104 | }); 105 | 106 | module.exports = { 107 | input, 108 | }; 109 | -------------------------------------------------------------------------------- /src/screens/chats.js: -------------------------------------------------------------------------------- 1 | const blessed = require('neo-blessed'); 2 | const State = require('../lib/state'); 3 | const Chat = require('../lib/model/chat'); 4 | const EE = require('../lib/eventemitter'); 5 | const format = require('../lib/format'); 6 | const working = require('./working'); 7 | const confirm = require('./confirm'); 8 | const { 9 | COLORS_ACTIVE_ITEM, 10 | COLORS_ACTIVE_SELECTED, 11 | COLORS_INACTIVE_ITEM, 12 | COLORS_INACTIVE_SELECTED, 13 | } = require('../../constants'); 14 | 15 | const chats = blessed.list({ 16 | label: 'Rooms', 17 | width: '25%', 18 | height: '100%', 19 | tags: true, 20 | border: { 21 | type: 'line', 22 | }, 23 | style: { 24 | item: COLORS_ACTIVE_ITEM, 25 | selected: COLORS_ACTIVE_SELECTED, 26 | }, 27 | items: [format.placehold()], 28 | }); 29 | chats._data = { 30 | visible: [], 31 | }; 32 | chats.chat = function () { 33 | return chats._data.visible[chats.selected]; 34 | }; 35 | 36 | /** 37 | * Keybindings 38 | */ 39 | chats.key('/', () => EE.emit('search.local')); 40 | chats.key('f', () => EE.emit('search.remote')); 41 | chats.key('C-r l', () => leave(chats.chat())); 42 | // chats.key('e', toggleExpand); 43 | chats.key(['j', 'down'], () => { 44 | chats.down(); 45 | chats.screen.render(); 46 | }); 47 | chats.key(['k', 'up'], () => { 48 | chats.up(); 49 | chats.screen.render(); 50 | }); 51 | chats.key(['g'], () => { 52 | chats.select(0); 53 | chats.screen.render(); 54 | }); 55 | chats.key(['S-g'], () => { 56 | chats.select(chats._data.visible.length - 1); 57 | chats.screen.render(); 58 | }); 59 | chats.key('enter', () => { 60 | EE.emit('chats.select', chats.chat()); 61 | }); 62 | 63 | chats.on('focus', () => { 64 | chats.style.item = COLORS_ACTIVE_ITEM; 65 | chats.style.selected = COLORS_ACTIVE_SELECTED; 66 | chats.screen.render(); 67 | }); 68 | chats.on('blur', () => { 69 | chats.style.item = COLORS_INACTIVE_ITEM; 70 | chats.style.selected = COLORS_INACTIVE_SELECTED; 71 | chats.screen.render(); 72 | }); 73 | 74 | /** 75 | * External events 76 | */ 77 | EE.on('state.chats.updated', () => { 78 | const c = State.chat(); 79 | if (!c) { 80 | chats.focus(); 81 | } 82 | display(); 83 | }); 84 | EE.on('input.chats.up', () => { 85 | chats.up(); 86 | EE.emit('chats.select', chats.chat()); 87 | }); 88 | EE.on('input.chats.down', () => { 89 | chats.down(); 90 | EE.emit('chats.select', chats.chat()); 91 | }); 92 | 93 | function display() { 94 | // Object.keys(chats._data.config).forEach(type => { 95 | // // ugh 96 | // const displayable = allChats.filter(chat => chatType(chat) === type); 97 | 98 | // if (chats._data.config[type].expanded) { 99 | // content.push(`{underline}▲ Collapse ${type}{/}`); 100 | // visible.push({ collapse: true, title: true }); 101 | // } else { 102 | // if (displayable.length > chats._data.config[type].collapsedMax) { 103 | // content.push(`{underline}▼ Expand ${type}{/}`); 104 | // visible.push({ expand: true, title: true }); 105 | // } else { 106 | // content.push(`{underline}${type}{/}`); 107 | // visible.push({ title: true }); 108 | // } 109 | // } 110 | 111 | // const toDisplay = displayable.slice( 112 | // 0, 113 | // chats._data.config[type].collapsedMax 114 | // ); 115 | // content = content.concat(toDisplay.map(format.chat)); 116 | // visible = visible.concat(toDisplay); 117 | // }); 118 | 119 | chats._data.visible = State.chats(); 120 | chats.setItems(chats._data.visible.map(format.chat)); 121 | 122 | const c = State.chat(); 123 | if (c) { 124 | const idx = chats._data.visible.findIndex(v => v.uri === c.uri); 125 | chats.select(idx); 126 | } 127 | chats.screen.render(); 128 | } 129 | 130 | function leave(chat) { 131 | return confirm.ask(`Leave ${chat.displayName}?`).then(async ans => { 132 | if (ans) { 133 | EE.emit('chats.leave', chats.chat()); 134 | } 135 | }); 136 | } 137 | 138 | module.exports = { 139 | chats, 140 | }; 141 | -------------------------------------------------------------------------------- /src/screens/search.js: -------------------------------------------------------------------------------- 1 | const blessed = require('neo-blessed'); 2 | const EE = require('../lib/eventemitter'); 3 | const State = require('../lib/state'); 4 | const format = require('../lib/format'); 5 | const { 6 | COLORS_ACTIVE_ITEM, 7 | COLORS_ACTIVE_SELECTED, 8 | } = require('../../constants'); 9 | 10 | const search = blessed.box({ 11 | height: '100%', 12 | width: '25%', 13 | top: 0, 14 | left: 0, 15 | hidden: true, 16 | style: { 17 | fg: 'white', 18 | }, 19 | }); 20 | const input = blessed.textbox({ 21 | label: 'Search', 22 | border: { 23 | type: 'line', 24 | }, 25 | width: '100%', 26 | height: '10%+1', 27 | style: { 28 | fg: 'white', 29 | }, 30 | cursor: { 31 | artificial: true, 32 | shape: 'underline', 33 | blink: true, 34 | }, 35 | }); 36 | const results = blessed.list({ 37 | top: '10%', 38 | height: '90%', 39 | width: '100%', 40 | tags: true, 41 | border: { 42 | type: 'line', 43 | }, 44 | style: { 45 | item: COLORS_ACTIVE_ITEM, 46 | selected: COLORS_ACTIVE_SELECTED, 47 | }, 48 | }); 49 | search.append(input); 50 | search.append(results); 51 | search._data = {}; 52 | input._data = {}; 53 | 54 | function selected() { 55 | return search._data.visible[results.selected]; 56 | } 57 | 58 | function filterResults() { 59 | if (!search._data.available) { 60 | return; 61 | } 62 | 63 | if (input._data.value) { 64 | const by = input._data.value.toLowerCase(); 65 | search._data.visible = search._data.available.filter(r => 66 | r.normalizedName.includes(by) 67 | ); 68 | } else { 69 | search._data.visible = search._data.available; 70 | } 71 | display(); 72 | } 73 | 74 | function display() { 75 | const s = State.search(); 76 | if (s.loading) { 77 | results.setItems([format.placehold()]); 78 | } else { 79 | results.setItems(search._data.visible.map(format.availableRoom)); 80 | } 81 | results.select(0); 82 | search.screen.render(); 83 | } 84 | 85 | input.on('keypress', async (ch, key) => { 86 | switch (key.full) { 87 | case 'return': 88 | return; // :( ... there's a lot of pain here. 89 | case 'down': 90 | case 'linefeed': 91 | results.down(); 92 | results.screen.render(); 93 | return; 94 | case 'up': 95 | case 'C-k': 96 | results.up(); 97 | results.screen.render(); 98 | return; 99 | case 'right': 100 | case 'C-p': 101 | if (State.search().mode === 'local') { 102 | EE.emit('search.select', selected()); 103 | } else { 104 | EE.emit('search.preview', selected()); 105 | } 106 | return; 107 | case 'enter': 108 | EE.emit('search.select', selected()); 109 | return; 110 | case 'escape': 111 | EE.emit('search.close'); 112 | return; 113 | // these listeners need to be duplicated from screen 114 | // as input captures keys and doesn't bubble them 115 | case 'C-d': 116 | process.exit(0); 117 | } 118 | 119 | if (key.name === 'backspace') { 120 | if (input._data.value.length) { 121 | input._data.value = input._data.value.slice(0, -1); 122 | } 123 | // eslint-disable-next-line no-control-regex 124 | } else if (ch && !/^[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f]$/.test(ch)) { 125 | input._data.value += ch; 126 | } 127 | 128 | input.setValue(input._data.value); 129 | filterResults(); 130 | }); 131 | 132 | EE.on('threads.blur', () => { 133 | if (State.search()) { 134 | input.focus(); 135 | } 136 | }); 137 | 138 | EE.on('state.search.updated', () => { 139 | const s = State.search(); 140 | if (s) { 141 | // we're bootstrapping (i.e. not regaining focus from a preview) 142 | if (!s.loading) { 143 | input._data = { value: '' }; 144 | search._data.available = s.available; 145 | search._data.visible = s.available; 146 | input.setValue(''); 147 | } 148 | 149 | if (!search.visible) { 150 | search.show(); 151 | input.focus(); 152 | } 153 | 154 | display(); 155 | } else if (search._data.available) { 156 | search._data = {}; 157 | input._data = {}; 158 | search.hide(); 159 | search.screen.render(); 160 | } 161 | }); 162 | 163 | module.exports = { 164 | search, 165 | }; 166 | -------------------------------------------------------------------------------- /src/lib/api/auth.js: -------------------------------------------------------------------------------- 1 | const pptr = require('puppeteer'); 2 | const axios = require('axios'); 3 | const qs = require('qs'); 4 | const moment = require('moment'); 5 | const parse = require('./parse'); 6 | const randomId = require('../random-id'); 7 | const { URL_EVENTS, URL_REGISTER } = require('../../../constants'); 8 | const config = require('../../lib/config'); 9 | 10 | const CONFIG_PATH = 'auth'; 11 | let AUTH = { 12 | request: {}, 13 | events: {}, 14 | }; 15 | 16 | /** 17 | * go get the cookies needed to make any other requests 18 | * 19 | * This will break at some point in the future, sorry future self 20 | * 21 | * @return {Promise} 22 | */ 23 | function getRequestCookies() { 24 | // eslint-disable-next-line no-async-promise-executor 25 | return new Promise(async res => { 26 | const browser = await pptr.launch({ 27 | // this was brought back, apparently as Chrome works now... 28 | // https://support.google.com/accounts/thread/22873505?msgid=24501976 29 | // product: 'firefox', 30 | headless: false, 31 | }); 32 | const page = await browser.newPage(); 33 | page.on('load', async () => { 34 | if (/^https:\/\/mail\.google\.com\/chat/.test(page.url())) { 35 | await page.waitForSelector('#gtn-roster-iframe-id'); 36 | const chatFrame = page 37 | .frames() 38 | .find(f => /^https:\/\/chat\.google\.com/.test(f.url())); 39 | 40 | const cookie = (await page.cookies('https://chat.google.com')) 41 | .map(c => [c.name, c.value].join('=')) 42 | .join('; '); 43 | // this is some kind of magical one-time value tied to a timestamp 44 | // the first 6 characters seem to be consistent across users 45 | const at = await chatFrame.evaluate(() => 46 | window.IJ_values.find(o => /:[0-9]{13}/.test(o)) 47 | ); 48 | 49 | res({ cookie, at }); 50 | browser.close(); 51 | } 52 | }); 53 | await page.goto('https://chat.google.com'); 54 | }); 55 | } 56 | 57 | /** 58 | * go get and store any auth cookies. 59 | * first try to load from disk, otherwise 60 | * fire up a browser and ~trick~ask the user to log in 61 | */ 62 | async function init(opts) { 63 | AUTH = config.get(CONFIG_PATH); 64 | 65 | // we don't have any auth config saved, or it's >5 days old, or we forced it 66 | if ( 67 | !AUTH || 68 | opts['--auth'] || 69 | moment(AUTH.fetchedAt).isBefore(moment().utc().subtract(5, 'days')) 70 | ) { 71 | AUTH = { 72 | request: await getRequestCookies(), 73 | fetchedAt: moment().utc().valueOf(), 74 | }; 75 | config.set(CONFIG_PATH, { 76 | // jump through hoops lightly here to avoid saving `events` 77 | request: AUTH.request, 78 | fetchedAt: AUTH.fetchedAt, 79 | }); 80 | } 81 | } 82 | 83 | async function register() { 84 | const eventsCookie = await axios({ 85 | method: 'GET', 86 | url: URL_REGISTER, 87 | headers: { 88 | 'content-type': 'application/x-www-form-urlencoded;charset=UTF-8', 89 | 'User-Agent': 'glycerin/0.0.1', 90 | accept: '*/*', 91 | cookie: AUTH.request.cookie, 92 | }, 93 | }) 94 | .then(({ headers }) => headers['set-cookie'][0].split(' ').shift()) 95 | .catch(e => console.log(e)); 96 | const cookie = `${eventsCookie} ${AUTH.request.cookie}`; 97 | 98 | const SID = await axios({ 99 | method: 'POST', 100 | url: URL_EVENTS, 101 | headers: { 102 | accept: '*/*', 103 | 'accept-language': 'en-US,en;q=0.9', 104 | 'content-type': 'application/x-www-form-urlencoded', 105 | 'sec-fetch-dest': 'empty', 106 | 'sec-fetch-mode': 'cors', 107 | 'sec-fetch-site': 'same-origin', 108 | referrer: 'https://chat.google.com/', 109 | referrerPolicy: 'origin', 110 | mode: 'cors', 111 | cookie, 112 | }, 113 | params: { 114 | RID: 0, 115 | VER: 8, 116 | CVER: 22, 117 | t: 1, 118 | zx: randomId(12), 119 | }, 120 | data: qs.stringify({ 121 | /** 122 | * @TODO don't hardcode room 123 | */ 124 | req0_data: '[null,null,null,null,null,null,[],[[[["AAAAasiGtkE"]]]]]', 125 | count: 0, 126 | }), 127 | }) 128 | .then(({ data }) => parse.fromEvents(data)[0][0][1][1]) 129 | .catch(e => console.log(e)); 130 | 131 | return { 132 | cookie, 133 | SID, 134 | }; 135 | } 136 | 137 | function requestData() { 138 | return AUTH.request; 139 | } 140 | 141 | async function eventsData(refresh = false) { 142 | if (refresh || !AUTH.events.cookie.length) { 143 | AUTH.events = await register(); 144 | // we don't persist events cookie as we're not sure when it expires, exactly 145 | } 146 | 147 | return AUTH.events; 148 | } 149 | 150 | module.exports = { init, requestData, eventsData }; 151 | -------------------------------------------------------------------------------- /src/lib/format.js: -------------------------------------------------------------------------------- 1 | const moment = require('moment'); 2 | const chalk = require('chalk'); 3 | const User = require('./model/user'); 4 | 5 | /** 6 | * Display the rooms and dms the user has 7 | * 8 | * @param {unpack.chat} 9 | * 10 | * @return {String} 11 | */ 12 | function chat({ isUnread, displayName }) { 13 | const prefix = isUnread ? '*' : ' '; 14 | return prefix + displayName; 15 | } 16 | 17 | /** 18 | * display the: 19 | * - total count of messages (bold if any unread) 20 | * - time of first message 21 | * - author of first message 22 | * - first line of first message of the thread 23 | * 24 | * @param {unpack.thread} 25 | * 26 | * @return {String} 27 | */ 28 | async function thread({ isUnread, total, messages }) { 29 | const prefix = isUnread ? '*' : ' '; 30 | const affix = !isUnread && total > 99 ? '+' : ' '; 31 | const preview = await message(messages[0], true); 32 | return ( 33 | chalk.grey( 34 | `${prefix}${Math.min(99, total).toString().padStart(2)}${affix}` 35 | ) + preview 36 | ); 37 | } 38 | 39 | /** 40 | * display the: 41 | * - time of message 42 | * - author of message 43 | * - content of message (only first line if truncated) 44 | * 45 | * @param {unpack.message} msg 46 | * @param {Boolean} truncate 47 | */ 48 | async function message(msg, truncate = false) { 49 | const ts = moment(parseInt(msg.createdAt.substring(0, 13), 10)); 50 | const stamp = ts.format('YYYY-MM-DD hh:mma'); 51 | const name = await User.name(msg.user); 52 | const me = await User.whoami(); 53 | 54 | return `${chalk.grey(stamp + '>')} ${chalk.underline(name)}: ${ 55 | truncate ? msg.text.raw.split('\n').shift() : textFromMsg(msg.text, me) 56 | }`; 57 | } 58 | 59 | function placehold(str = 'loading') { 60 | return ` {bold}{underline}... ${str} ...{/}`; 61 | } 62 | 63 | function availableRoom(room) { 64 | const count = room.memberCount 65 | ? chalk.grey(` (${room.memberCount} members)`) 66 | : ''; 67 | return `${room.displayName}${count}`; 68 | } 69 | 70 | function checkbox(item) { 71 | const checked = item.checked ? `[x]` : `[ ]`; 72 | return `${checked} ${item.displayName}`; 73 | } 74 | 75 | /** 76 | * @TODO figure out other kinds of things. potentially use msg.parts 77 | * 78 | * @param {unpack.message} msg 79 | * @param {User} me 80 | */ 81 | function textFromMsg(msg, me) { 82 | const orig = msg.raw.length ? msg.raw : ''; 83 | let text = orig + ''; 84 | // if we insert text that is a different length than part 85 | let offset = 0; 86 | 87 | if (msg.formatting) { 88 | for (const f of msg.formatting) { 89 | let part = orig.substring(f.indexStart, f.indexEnd); 90 | let insert = part; 91 | // 1 === link 92 | // 6 === mention 93 | // 8 === inlineblock text (see textType) 94 | // 11 === google meet link 95 | // 13 === image 96 | switch (f.type) { 97 | case 1: 98 | if (part !== f.link.raw) { 99 | insert = `(${chalk.blue(part)})[${chalk.cyan(f.link.raw)}]`; 100 | } else { 101 | insert = chalk.cyan(part); 102 | } 103 | break; 104 | case 6: 105 | insert = 106 | me.id === f.mention.id 107 | ? chalk.red(part) 108 | : chalk.red('@') + part.substring(1); 109 | break; 110 | case 8: 111 | // 5 == monospace text (inside `) 112 | // 6 == ` 113 | // 7 == block text (inside ```) 114 | switch (f.textType) { 115 | case 5: 116 | insert = chalk.bgGrey(part); 117 | break; 118 | case 6: 119 | insert = ''; 120 | break; 121 | case 7: 122 | const parts = part.split('\n'); 123 | const longestLine = parts.reduce( 124 | (max, line) => (line.length > max ? line.length : max), 125 | 0 126 | ); 127 | insert = `\n${chalk.bgGrey( 128 | parts.map(line => line.padEnd(longestLine)).join('\n') 129 | )}\n`; 130 | break; 131 | } 132 | break; 133 | case 11: 134 | insert = `${chalk.grey(':')} ${chalk.cyan(f.meet.link)} `; 135 | break; 136 | case 13: 137 | insert = `${chalk.grey(':')} ${f.image.title} `; 138 | break; 139 | } 140 | 141 | text = _ins(text, f.indexStart + offset, f.indexEnd + offset, insert); 142 | offset += insert.length - part.length; 143 | } 144 | } 145 | 146 | return text.length ? text : ''; 147 | } 148 | 149 | function _ins(text, start, end, content) { 150 | return text.substring(0, start) + content + text.substring(end); 151 | } 152 | 153 | module.exports = { 154 | chat, 155 | thread, 156 | message, 157 | placehold, 158 | availableRoom, 159 | checkbox, 160 | }; 161 | -------------------------------------------------------------------------------- /src/screens/threads.js: -------------------------------------------------------------------------------- 1 | const blessed = require('neo-blessed'); 2 | const State = require('../lib/state'); 3 | const format = require('../lib/format'); 4 | const EE = require('../lib/eventemitter'); 5 | const { 6 | COLORS_ACTIVE_ITEM, 7 | COLORS_ACTIVE_SELECTED, 8 | COLORS_INACTIVE_ITEM, 9 | COLORS_INACTIVE_SELECTED, 10 | } = require('../../constants'); 11 | 12 | const DEFAULT_CONTENT = ' Select A Room'; 13 | 14 | const threads = blessed.list({ 15 | label: 'Threads', 16 | left: '25%', 17 | height: '25%', 18 | width: '75%', 19 | tags: true, 20 | border: { 21 | type: 'line', 22 | }, 23 | // do not set a default style 24 | content: DEFAULT_CONTENT, 25 | scrollable: true, 26 | scrollbar: { 27 | style: { 28 | fg: 'black', 29 | bg: 'white', 30 | }, 31 | }, 32 | alwaysScroll: true, 33 | // mouse: true, 34 | // keys: true, 35 | // vi: true, 36 | }); 37 | threads._data = { 38 | visible: [], 39 | }; 40 | threads.thread = function () { 41 | return threads._data.visible[threads.selected]; 42 | }; 43 | 44 | async function display() { 45 | const displayable = State.threads(); 46 | if (!displayable) { 47 | threads.setItems([DEFAULT_CONTENT]); 48 | threads.selected = undefined; 49 | threads.screen.render(); 50 | return; 51 | } 52 | 53 | if (!displayable.threads.length) { 54 | threads.setContent( 55 | format.placehold(displayable.loading ? 'loading' : 'no threads, yet') 56 | ); 57 | } else { 58 | threads._data.visible = displayable.threads; 59 | const formatted = []; 60 | for (let thread of displayable.threads) { 61 | formatted.push(await format.thread(thread)); 62 | } 63 | threads.setItems(formatted); 64 | 65 | const t = State.thread(); 66 | if (t) { 67 | threads.select(threads._data.visible.findIndex(v => v.id === t.id)); 68 | } else if (threads._data.lastIdBeforeFetching) { 69 | const idx = threads._data.visible.findIndex( 70 | t => t.id === threads._data.lastIdBeforeFetching 71 | ); 72 | threads._data.lastIdBeforeFetching = false; 73 | // select the oldest unseen thread 74 | threads.select(idx - 1); 75 | } else if (!threads._data.fetchingMore) { 76 | threads.select(formatted.length - 1); 77 | } 78 | } 79 | threads._data.fetchingMore = false; 80 | threads.screen.render(); 81 | } 82 | 83 | /** 84 | * Keybindings 85 | */ 86 | 87 | function _up() { 88 | if (threads._data.fetchingMore) { 89 | return; 90 | } 91 | 92 | if (threads.selected === 0) { 93 | if (!State.chat().hasMoreThreads) { 94 | return; 95 | } 96 | 97 | threads._data.fetchingMore = true; 98 | threads._data.lastIdBeforeFetching = threads.thread().id; 99 | threads.unshiftItem(format.placehold('loading more')); 100 | threads.select(0); 101 | threads.screen.render(); 102 | 103 | EE.emit('threads.fetchMore'); 104 | } else { 105 | threads.up(); 106 | threads.screen.render(); 107 | } 108 | } 109 | 110 | function _down() { 111 | if (threads._data.fetchingMore) { 112 | return; 113 | } 114 | threads.down(); 115 | threads.screen.render(); 116 | } 117 | 118 | threads.key('n', () => { 119 | if (!State.search()) { 120 | EE.emit('threads.new', threads._data.chat); 121 | } 122 | }); 123 | threads.key(['k', 'up'], _up); 124 | EE.on('input.threads.up', () => { 125 | _up(); 126 | EE.emit('threads.select', threads.thread()); 127 | }); 128 | threads.key(['j', 'down'], _down); 129 | EE.on('input.threads.down', () => { 130 | _down(); 131 | EE.emit('threads.select', threads.thread()); 132 | }); 133 | threads.key(['g'], () => { 134 | threads.select(0); 135 | threads.screen.render(); 136 | }); 137 | threads.key(['S-g'], () => { 138 | threads.select(threads.items.length - 1); 139 | threads.screen.render(); 140 | }); 141 | threads.key('enter', () => { 142 | EE.emit('threads.select', threads.thread()); 143 | }); 144 | threads.key(['escape', 'q'], () => { 145 | EE.emit('threads.blur'); 146 | }); 147 | threads.key('C-t l', () => EE.emit('state.pop')); 148 | 149 | /** 150 | * Send events to messages 151 | */ 152 | threads.key('C-k', () => EE.emit('messages.scroll.up')); 153 | threads.key('linefeed', () => EE.emit('messages.scroll.down')); 154 | threads.key('C-g', () => EE.emit('messages.scroll.top')); 155 | threads.key('C-l', () => EE.emit('messages.scroll.bottom')); 156 | threads.key('C-e', () => EE.emit('messages.expand')); 157 | 158 | threads.on('focus', () => { 159 | threads.style.item = COLORS_ACTIVE_ITEM; 160 | threads.style.selected = COLORS_ACTIVE_SELECTED; 161 | threads.screen.render(); 162 | }); 163 | threads.on('blur', () => { 164 | threads.style.item = COLORS_INACTIVE_ITEM; 165 | threads.style.selected = COLORS_INACTIVE_SELECTED; 166 | threads.screen.render(); 167 | }); 168 | /** 169 | * External Events 170 | */ 171 | EE.on('state.chats.loading', display); 172 | EE.on('state.threads.updated', () => { 173 | const c = State.chat(); 174 | if (c && c.isThreaded && !State.thread()) { 175 | threads.focus(); 176 | } 177 | display(); 178 | }); 179 | 180 | module.exports = { 181 | threads, 182 | }; 183 | -------------------------------------------------------------------------------- /src/screens/prune.js: -------------------------------------------------------------------------------- 1 | const blessed = require('neo-blessed'); 2 | const EE = require('../lib/eventemitter'); 3 | const State = require('../lib/state'); 4 | const User = require('../lib/model/user'); 5 | const format = require('../lib/format'); 6 | const setRoomMembership = require('../lib/api/set-room-membership'); 7 | const hideChat = require('../lib/api/hide-chat'); 8 | 9 | const { 10 | COLORS_ACTIVE_ITEM, 11 | COLORS_ACTIVE_SELECTED, 12 | COLORS_INACTIVE_ITEM, 13 | COLORS_INACTIVE_SELECTED, 14 | } = require('../../constants'); 15 | 16 | const screen = blessed.screen({ 17 | smartCSR: true, 18 | fullUnicode: true, 19 | cursor: { 20 | shape: 'line', 21 | blink: true, 22 | }, 23 | }); 24 | const prune = blessed.box({ 25 | label: 26 | '"spacebar" to select, "enter" when done. "h/l" or "left/right arrow" to select dms or rooms.', 27 | height: '100%', 28 | width: '100%', 29 | top: 0, 30 | left: 0, 31 | border: { 32 | type: 'bg', 33 | }, 34 | }); 35 | const rooms = blessed.list({ 36 | top: 0, 37 | left: 0, 38 | height: '100%', 39 | width: '50%', 40 | tags: true, 41 | keys: true, 42 | vi: true, 43 | border: { 44 | type: 'line', 45 | }, 46 | style: { 47 | item: COLORS_ACTIVE_ITEM, 48 | selected: COLORS_ACTIVE_SELECTED, 49 | }, 50 | }); 51 | const dms = blessed.list({ 52 | top: 0, 53 | left: '50%', 54 | height: '100%', 55 | width: '50%', 56 | tags: true, 57 | keys: true, 58 | vi: true, 59 | border: { 60 | type: 'line', 61 | }, 62 | style: { 63 | item: COLORS_INACTIVE_ITEM, 64 | selected: COLORS_INACTIVE_SELECTED, 65 | }, 66 | }); 67 | const confirm = blessed.question({ 68 | top: 'center', 69 | left: 'center', 70 | border: { 71 | type: 'line', 72 | }, 73 | height: '25%', 74 | width: '25%', 75 | shadow: true, 76 | align: 'center', 77 | valign: 'center', 78 | }); 79 | const progress = blessed.progressbar({ 80 | orientation: 'horizontal', 81 | hidden: true, 82 | top: 'center', 83 | left: 'center', 84 | width: '50%', 85 | height: '20%', 86 | style: { 87 | bar: { 88 | bg: 'white', 89 | }, 90 | }, 91 | border: { 92 | type: 'line', 93 | }, 94 | filled: 0, 95 | }); 96 | rooms._data = { rooms: [] }; 97 | dms._data = { dms: [] }; 98 | screen.append(prune); 99 | screen.append(confirm); 100 | screen.append(progress); 101 | prune.append(rooms); 102 | prune.append(dms); 103 | 104 | screen.on('keypress', (ch, key) => { 105 | switch (key.full) { 106 | case 'left': 107 | return rooms.focus(); 108 | case 'right': 109 | return dms.focus(); 110 | case 'enter': 111 | return leave(); 112 | case 'space': 113 | return toggle(); 114 | case 'q': 115 | case 'C-d': 116 | process.exit(0); 117 | } 118 | }); 119 | 120 | rooms.on('blur', () => { 121 | rooms.style.item = COLORS_INACTIVE_ITEM; 122 | rooms.style.selected = COLORS_INACTIVE_SELECTED; 123 | screen.render(); 124 | }); 125 | rooms.on('focus', () => { 126 | rooms.style.item = COLORS_ACTIVE_ITEM; 127 | rooms.style.selected = COLORS_ACTIVE_SELECTED; 128 | screen.render(); 129 | }); 130 | dms.on('blur', () => { 131 | dms.style.item = COLORS_INACTIVE_ITEM; 132 | dms.style.selected = COLORS_INACTIVE_SELECTED; 133 | screen.render(); 134 | }); 135 | dms.on('focus', () => { 136 | dms.style.item = COLORS_ACTIVE_ITEM; 137 | dms.style.selected = COLORS_ACTIVE_SELECTED; 138 | screen.render(); 139 | }); 140 | EE.on('state.chats.updated', () => { 141 | State.chats().forEach(c => { 142 | if (c.isDm) { 143 | dms._data.dms.push(c); 144 | } else { 145 | rooms._data.rooms.push(c); 146 | } 147 | }); 148 | display(); 149 | }); 150 | 151 | function bootstrap() { 152 | rooms.focus(); 153 | rooms.setItems([format.placehold()]); 154 | screen.render(); 155 | 156 | EE.emit('screen.ready'); 157 | } 158 | 159 | function toggle() { 160 | if (screen.focused === rooms) { 161 | rooms._data.rooms[rooms.selected].checked = !rooms._data.rooms[ 162 | rooms.selected 163 | ].checked; 164 | } else { 165 | dms._data.dms[dms.selected].checked = !dms._data.dms[dms.selected].checked; 166 | } 167 | display(); 168 | } 169 | 170 | function display() { 171 | rooms.setItems(rooms._data.rooms.map(format.checkbox)); 172 | dms.setItems(dms._data.dms.map(format.checkbox)); 173 | screen.render(); 174 | } 175 | 176 | async function leave() { 177 | const toLeave = rooms._data.rooms 178 | .filter(r => r.checked) 179 | .concat(dms._data.dms.filter(d => d.checked)); 180 | if (!toLeave.length || screen.focused === confirm) { 181 | return; 182 | } 183 | 184 | confirm.ask(`Leave ${toLeave.length} rooms? [Yn]`, async (err, ans) => { 185 | confirm.hide(); 186 | screen.render(); 187 | 188 | if (ans) { 189 | progress.show(); 190 | screen.render(); 191 | 192 | const user = await User.whoami(); 193 | for (let idx in toLeave) { 194 | if (toLeave[idx].isDm) { 195 | await hideChat(toLeave[idx]); 196 | } else { 197 | await setRoomMembership(toLeave[idx], user, false); 198 | } 199 | progress.setProgress(((idx + 1) / toLeave.length) * 100); 200 | screen.render(); 201 | } 202 | progress.hide(); 203 | 204 | confirm.ask(`Left ${toLeave.length} rooms. Press any key to close.`, () => 205 | process.exit(0) 206 | ); 207 | confirm.focus(); 208 | } 209 | }); 210 | confirm.focus(); 211 | } 212 | 213 | module.exports = { 214 | bootstrap, 215 | }; 216 | -------------------------------------------------------------------------------- /src/lib/model/chat.js: -------------------------------------------------------------------------------- 1 | const timestamp = require('../timestamp'); 2 | const getChats = require('../api/get-chats'); 3 | const getAvailableRooms = require('../api/get-available-rooms'); 4 | const getChatThreads = require('../api/get-chat-threads'); 5 | const getChatMessages = require('../api/get-chat-messages'); 6 | const getThreadMessages = require('../api/get-thread-messages'); 7 | const setRoomMembership = require('../api/set-room-membership'); 8 | const getSpaceDetails = require('../api/get-space-details'); 9 | const markReadAPI = require('../api/mark-read'); 10 | const unpack = require('../api/unpack'); 11 | const User = require('./user'); 12 | 13 | /** 14 | * 15 | * @returns Promise(Array) 16 | */ 17 | function fetchChats() { 18 | return getChats().then(unpack.chats); 19 | } 20 | 21 | function fetchAvailableChats() { 22 | return getAvailableRooms().then(unpack.availableRooms); 23 | } 24 | 25 | function fetchDetails(c) { 26 | return getSpaceDetails(c).then(unpack.chat); 27 | } 28 | 29 | /** 30 | * remove this object from our unread queue, potentially out of order 31 | * and tell the server that it's been read 32 | * 33 | * @param {unpack.chat|unpack.thread} obj 34 | */ 35 | function markRead(obj) { 36 | // fire and forget 37 | markReadAPI(obj); 38 | } 39 | 40 | /** 41 | * threads are fetched based on the most recent message sent to it 42 | * meaning that as time progresses we don't have a "static" pagination 43 | * it's constantly a moving target. 44 | * so we do our best to "merge" things and hope for the best 45 | * i hope you never want to see really old threads in an active chat 46 | * 47 | * @param {unpack.chat} chat 48 | */ 49 | async function moreThreads(chat) { 50 | // you've called this before threads() ಠ_ಠ 51 | if (!cache[chat.uri].threads) { 52 | return threads(chat); 53 | } 54 | 55 | const c = _chat(chat); 56 | const before = timestamp.more(c.threads[0].mostRecentAt); 57 | const more = await fetchThreads(chat, before); 58 | // dedupe threads 59 | // this is going to be slow when number of threads gets large :( 60 | c.threads = more.concat(c.threads).reduce((acc, thread) => { 61 | if (!acc.find(t => t.id === thread.id)) { 62 | acc.push(thread); 63 | } 64 | return acc; 65 | }, []); 66 | 67 | return c.threads; 68 | } 69 | 70 | /** 71 | * fetch threads for a non-dm chat 72 | * 73 | * @param {unpack.chat} chat 74 | * @param {String} before - @see timestamp.now() 75 | * @param {Boolean} preview [default: false] 76 | * 77 | * @return {Array} 78 | */ 79 | function fetchThreads(chat, before, preview = false) { 80 | if (chat.isDm) { 81 | throw new Error('threads called on a dm'); 82 | } 83 | 84 | return getChatThreads(chat, before || timestamp.now(), preview).then(ts => { 85 | const unpacked = unpack.thread(ts); 86 | // we have to let the users cache know about all the users we just saw 87 | // so that when we go to display them we fetch all at once 88 | unpacked.threads.forEach(t => 89 | t.messages.forEach(m => User.prefetch(m.user)) 90 | ); 91 | 92 | return unpacked; 93 | }); 94 | } 95 | 96 | /** 97 | * get messages for a chat or thread. 98 | * 99 | * @param {unpack.chat|unpack.thread} obj 100 | * @param {Boolean} ignoreCache 101 | */ 102 | async function messages(obj, ignoreCache = false) { 103 | // obj is unpack.chat 104 | if (obj.isDm) { 105 | if (!cache[obj.uri].messages || ignoreCache) { 106 | cache[obj.uri].messages = await fetchMessages(obj, timestamp.now()); 107 | } 108 | 109 | return cache[obj.uri].messages; 110 | } 111 | 112 | if ('isDm' in obj) { 113 | throw Error('called messages() on a room'); 114 | } 115 | 116 | // obj is unpack.thread 117 | const thread = _thread(obj.room, obj); 118 | if (!thread.messages || thread.refreshNext || ignoreCache) { 119 | // this mutates cache 120 | thread.refreshNext = false; 121 | thread.messages = await fetchMessages(obj, timestamp.now()); 122 | } 123 | 124 | return thread.messages; 125 | } 126 | 127 | /** 128 | * do our darndest to return a cached thread 129 | * 130 | * @param {unpack.chat} c 131 | * @param {Integer} id 132 | */ 133 | function _thread(c, { id }) { 134 | const chat = _chat(c); 135 | if (!chat) { 136 | // we're previewing a chat + thread, so this is temporary 137 | return newThread(c, id); 138 | } 139 | 140 | // if we haven't called threads() yet, we can fake it 141 | // by instantiating a new thread by ourselves here 142 | // it will have a buuuunch of empty fields, but things that 143 | // rely on that should be resilient enough to handle it 144 | if (!chat.threads) { 145 | chat.threads = [newThread(c, id)]; 146 | chat.refreshNext = true; 147 | } 148 | 149 | const idx = chat.threads.findIndex(t => t.id === id); 150 | if (idx === -1) { 151 | chat.threads.push(newThread(c, id)); 152 | return chat.threads[chat.threads.length - 1]; 153 | } 154 | // i can't be bothered to figure out if chat.threads.find() returns by value or reference 155 | return chat.threads[idx]; 156 | } 157 | 158 | function newThread(chat, id) { 159 | return { 160 | id, 161 | room: { uri: chat.uri, id: chat.id, displayName: chat.displayName }, 162 | messages: [], 163 | total: 0, 164 | refreshNext: true, 165 | }; 166 | } 167 | 168 | /** 169 | * fetch messages for a chat or thread 170 | * 171 | * @param {unpack.thread|unpack.chat} obj 172 | * @param {String} before - @see timestamp.now() 173 | */ 174 | function fetchMessages(obj, before) { 175 | return obj.isDm || obj.isGroup 176 | ? getChatMessages(obj, before).then(rawMessages => { 177 | // empty messages returns null 178 | if (rawMessages) { 179 | // each "message" looks like a single-message thread 180 | // so... let's flatten that 181 | const unpacked = rawMessages.map(m => unpack.message(m[4][0])); 182 | unpacked.forEach(u => User.prefetch(u.user)); 183 | 184 | return unpacked; 185 | } 186 | 187 | return []; 188 | }) 189 | : getThreadMessages(obj, before).then(rawMessages => { 190 | const messages = rawMessages.map(unpack.message); 191 | messages.forEach(m => User.prefetch(m.user)); 192 | 193 | // const thread = _thread(obj.room, obj); 194 | // if (messages.length === thread.total) { 195 | // // this mutates cache 196 | // thread.unfetched = 0; 197 | // } 198 | 199 | return messages; 200 | }); 201 | } 202 | 203 | async function join(chat) { 204 | return setRoomMembership(chat, await User.whoami(), true); 205 | } 206 | 207 | async function leave(chat) { 208 | return setRoomMembership(chat, await User.whoami(), false); 209 | } 210 | 211 | module.exports = { 212 | markRead, 213 | join, 214 | leave, 215 | fetchChats, 216 | fetchDetails, 217 | fetchAvailableChats, 218 | fetchThreads, 219 | fetchMessages, 220 | moreThreads, 221 | }; 222 | -------------------------------------------------------------------------------- /src/lib/api/unpack.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This is the fun/worst part of the project. 3 | * 4 | * Here is the "documentation" about what kinds of objects 5 | * exist in the API that powers the GUI for google chat. 6 | * 7 | * This will likely evolve over time as the understanding of 8 | * objects improves/changes. 9 | */ 10 | 11 | function thread(t) { 12 | return { 13 | hasMore: !t[4], // why is this inverted, gchat? 14 | threads: t[0].map(_thread), 15 | }; 16 | } 17 | 18 | function _thread(t) { 19 | const messages = t[4].map(message); 20 | return { 21 | _raw: t, 22 | id: t[0][0], 23 | room: roomMeta(t[0][2]), 24 | mostRecentAt: t[1], 25 | mostRecentReadAt: t[2], // can be 0 if none read 26 | messages, 27 | isUnread: t[1] > t[2], 28 | // isUnread: messages.filter(m => m.isUnread).length, 29 | unfetched: t[6], 30 | total: t[4].length + t[6], 31 | // t[12] is some kind of timestamp 32 | // t[14] is some kind of timestamp 33 | // t[16] is some kind of timestamp, but not in string format 34 | isMembershipUpdate: t[23], // we ignore these kinds of threads 35 | // t[26] is something related to thread count 36 | // possibly something about "initially loaded" count? 37 | // for single-message threads, it's 0 38 | // initiallyLoaded: t[26] 39 | }; 40 | } 41 | 42 | function message(msg) { 43 | return { 44 | _raw: msg, 45 | id: msg[1], 46 | user: user(msg[4], msg[2]), 47 | text: { 48 | raw: msg[5], 49 | // msg[8] is some kind of metadata about mentions / urls 50 | formatting: msg[8] ? msg[8].map(_msg_formatting) : null, 51 | // msg[9] is the parts of the message broken up into "regular text" and "links/mentions" 52 | // parts: msg[9], 53 | }, 54 | createdAt: msg[11], 55 | isUnread: msg[15], 56 | // isUnread: msg[14], // incorrect. 57 | // in int format 58 | // createdAt: msg[16] 59 | // threadish: msg[17] seems to be thread+room again... 60 | isSecret: msg[24], 61 | reactions: msg[39], // may be undefined 62 | }; 63 | } 64 | 65 | function user(u, r) { 66 | return { 67 | id: u[0], 68 | uri: u[9][1], 69 | name: u[1], 70 | icon: u[2], 71 | email: u[3], 72 | firstName: u[6], 73 | room: r ? roomMeta(r) : null, 74 | }; 75 | } 76 | 77 | function roomUsers(c) { 78 | // normal room 79 | if (c[17]) { 80 | return c[17].map(u => user(u, c[0])); 81 | } 82 | 83 | // group 84 | if (c[55]) { 85 | return c[55].map(u => user(u, c[0])); 86 | } 87 | 88 | return []; 89 | } 90 | 91 | function chat(c) { 92 | return { 93 | _raw: c, 94 | ...roomMeta(c[0]), 95 | isUnread: c[8] > c[9], 96 | isGroup: !!c[55], 97 | isThreaded: c[26] === null, 98 | // 0 === all messages 99 | // 1 === @'s + followed threads + new threads 100 | // 2 === @'s + followed threads 101 | // 3 === @'s 102 | nofityLevel: c[54], 103 | ...chatName(c), 104 | mostRecentAt: c[8], 105 | mostRecentReadAt: c[9], 106 | // seems to be duplicated in c[43] 107 | users: roomUsers(c), 108 | }; 109 | } 110 | 111 | function fave(c) { 112 | return { ...chat(c), isFave: true }; 113 | } 114 | // Groups look like DMs, but the "name" is a concatination 115 | // of the members of the room 116 | function chatName(c) { 117 | if (c[2]) { 118 | return { 119 | displayName: c[2], 120 | normalizedName: c[2].toLowerCase(), 121 | }; 122 | } 123 | 124 | if (c[55]) { 125 | const names = c[55] 126 | .map(user) 127 | .map(u => u.firstName) 128 | .sort() 129 | .join(', '); //.substring(0, 15); 130 | return { 131 | displayName: names, 132 | normalizedName: names.toLowerCase(), 133 | }; 134 | } 135 | 136 | return { 137 | displayName: 'Group Chat', 138 | normalizedName: 'group chat', 139 | }; 140 | } 141 | 142 | function roomMeta(m) { 143 | return { 144 | uri: m[0], 145 | id: m[1], 146 | isDm: m[2] === 5, // 2 == room, 5 == dm 147 | }; 148 | } 149 | 150 | function chats(cs) { 151 | return [].concat( 152 | (cs[2] || []).map(fave), // favorites 153 | cs[7].map(chat), // dms 154 | cs[8].map(chat), // rooms 155 | (cs[9] || []).map(chat).map(c => { 156 | c.isBot = true; 157 | return c; 158 | }) // bots 159 | ); 160 | } 161 | 162 | /** 163 | * response from a /mutate endpoint 164 | */ 165 | function mutate(m) { 166 | return { 167 | ...message(m[2]), 168 | room: roomMeta(m[2][17][2][2]), 169 | thread: { 170 | id: m[2][17][2][0], 171 | }, 172 | }; 173 | } 174 | 175 | /** 176 | * Events seem to follow a whole different logic than the other endpoints. 177 | * It's not super clear how to tell nested events objects from each other 178 | * and especially the _withUUID thing seems... wrong, but it "works" 179 | */ 180 | 181 | function event(evt) { 182 | const base = { 183 | AID: evt[0], 184 | isNoop: false, 185 | }; 186 | 187 | // [[1,["noop"]]] 188 | if (typeof evt[1][0] === 'string') { 189 | return { 190 | ...base, 191 | isNoop: true, 192 | str: evt[1][0], 193 | }; 194 | } 195 | 196 | const mainData = evt[1][0][0][7][0]; 197 | const type = mainData[11]; 198 | // @TODO figure out why some events have a UUID in this position 199 | const hasUUID = typeof evt[1][0][1] === 'string'; 200 | base._raw = mainData; 201 | base._type = type; 202 | base._hasUUID = hasUUID; 203 | 204 | let unpacked; 205 | try { 206 | unpacked = hasUUID 207 | ? _withUUID(base, type, mainData) 208 | : _roomEvent(base, type, mainData); 209 | } catch (e) { 210 | console.log(e); 211 | JSON.stringify(mainData, null, 2); 212 | unpacked = { 213 | error: true, 214 | ...base, 215 | }; 216 | } 217 | 218 | return ( 219 | unpacked || { 220 | // default, but really we don't know/care what this is 221 | ...base, 222 | isNoop: true, 223 | isUnknown: true, 224 | } 225 | ); 226 | } 227 | 228 | function _roomEvent(base, type, data) { 229 | return { ...base, unknown: true, _data: data }; 230 | } 231 | 232 | function _withUUID(base, type, data) { 233 | switch (type) { 234 | // marked as read? 235 | case 3: 236 | return { 237 | ...base, 238 | _data: data[2], 239 | at: data[2][1], 240 | }; 241 | // marked as read 242 | case 4: 243 | return { 244 | ...base, 245 | ..._event_thread(data[3][0]), 246 | }; 247 | // new message 248 | case 6: 249 | return { 250 | ...base, 251 | ..._event_msg(data[5][0]), 252 | ..._event_thread(data[5][0][0]), 253 | }; 254 | // message edit 255 | case 7: 256 | return { 257 | ...base, 258 | ..._event_msg(data[5][0]), 259 | ..._event_thread(data[5][0][0]), 260 | }; 261 | // ??? 262 | case 9: 263 | return { 264 | ...base, 265 | _data: data[6], 266 | }; 267 | // star 268 | case 11: 269 | return { 270 | ...base, 271 | starred: data[8][1] === 1, 272 | _data: data[8], 273 | }; 274 | // more complete message event, includes user/bot name? 275 | // does not appear for rooms, only DMS (confirm: only bots?) 276 | // new theory: "mobile alert set" as it appears simultaneously 277 | case 12: 278 | return { 279 | ...base, 280 | ..._event_msg(data[9][0]), 281 | thread: { 282 | id: data[9][0][0][0][1], 283 | }, 284 | user: { 285 | name: data[9][0][1], 286 | // avatar: data[9][0][2], 287 | // ?: data[9][0][4], -> 1 // is bot? 288 | }, 289 | }; 290 | // number of unread in this room 291 | case 13: 292 | return { 293 | ...base, 294 | unreadCount: parseInt(data[10][0], 10), 295 | // another weird room shape 296 | ..._mark_read_room(data[10]), 297 | }; 298 | case 16: 299 | return { 300 | ...base, 301 | something: data[14], 302 | }; 303 | // toggle notifications 304 | case 18: 305 | return { 306 | ...base, 307 | notify: data[16][1][0] === 2, 308 | // another weird room shape 309 | room: { 310 | uri: `space/${data[16][0][0][0]}`, 311 | id: data[16][0][0][0], 312 | }, 313 | }; 314 | // emoji added to a message 315 | case 24: 316 | return { 317 | ...base, 318 | ..._event_thread(data[21][0][0]), 319 | emoji: data[21][1], 320 | message: { 321 | id: data[21][0][1], 322 | }, 323 | // _data: data[21], 324 | }; 325 | // shrug. seems to contain an array of some message contents, but no message ids 326 | case 28: 327 | return { 328 | ...base, 329 | _data: data[24], 330 | }; 331 | // connection started / new session started 332 | case 33: 333 | return base; 334 | // ????? (maybe someone came online/offline?) 335 | case 45: 336 | return base; 337 | } 338 | } 339 | 340 | function _event_msg(msg) { 341 | return { 342 | // lastRead?: msg[2], 343 | mostRecent: msg[3], 344 | mostRecentAt: msg[3], 345 | createdAt: msg[3], 346 | message: { 347 | id: msg[13], 348 | }, 349 | text: { 350 | raw: msg[9], 351 | formatting: msg[10] ? msg[10].map(_msg_formatting) : [], 352 | }, 353 | user: { 354 | name: typeof msg[1] === 'string' ? msg[1] : undefined, 355 | id: msg[1][0][0], 356 | }, 357 | }; 358 | } 359 | 360 | function _event_thread(data) { 361 | // try to figure out the shape of things 362 | if (typeof data[1] === 'string') { 363 | if (!data[0]) { 364 | return { 365 | room: { 366 | uri: `space/${data[2][0][0]}`, 367 | id: data[2][0][0], 368 | }, 369 | thread: { 370 | id: data[1], 371 | }, 372 | }; 373 | } 374 | 375 | // either a DM or a new thread 376 | if (data[1] === data[0][3][1]) { 377 | if (data[0][3][2].length === 1) { 378 | return { 379 | room: { 380 | uri: `space/${data[0][3][2][0][0]}`, 381 | id: data[0][3][2][0][0], 382 | }, 383 | thread: { 384 | id: data[0][3][1], 385 | }, 386 | }; 387 | } 388 | 389 | return { 390 | room: { 391 | uri: `dm/${data[0][3][2][2][0]}`, 392 | id: data[0][3][2][2][0], 393 | }, 394 | }; 395 | } 396 | 397 | return { 398 | room: { 399 | uri: `space/${data[0][3][2][0][0]}`, 400 | id: data[0][3][2][0][0], 401 | }, 402 | thread: { 403 | id: data[0][3][1], 404 | }, 405 | }; 406 | } 407 | 408 | if (data[3]) { 409 | return { 410 | room: { 411 | uri: `space/${data[3][2][0][0]}`, 412 | id: data[3][2][0][0], 413 | }, 414 | thread: { 415 | id: data[3][1], 416 | }, 417 | }; 418 | } 419 | 420 | // events that are room-centric not thread-centric 421 | return { 422 | room: { 423 | uri: `space/${data[2][0][0]}`, 424 | id: data[2][0][0], 425 | }, 426 | }; 427 | } 428 | 429 | function _mark_read_room(obj) { 430 | if (obj[2][2]) { 431 | return { 432 | room: { 433 | uri: `dm/${obj[2][2][0]}`, 434 | id: obj[2][2][0], 435 | }, 436 | }; 437 | } 438 | 439 | return { 440 | room: { 441 | uri: `space/${obj[2][0][0]}`, 442 | id: obj[2][0][0], 443 | }, 444 | }; 445 | } 446 | 447 | function _msg_formatting(msg) { 448 | return { 449 | // 1 === link 450 | // 6 === mention 451 | // 8 === inlineblock text (see textType) 452 | // 11 === google meet link 453 | // 13 === image 454 | type: msg[0], 455 | unknownValue19: msg[19], 456 | indexStart: msg[1], 457 | indexEnd: msg[1] + msg[2], 458 | // 5 == monospace text (inside `) 459 | // 6 == ` 460 | // 7 == block text (inside ```) 461 | textType: msg[7] ? msg[7][0] : null, 462 | mention: msg[4] ? { id: msg[4][0][0] } : null, 463 | link: msg[6] 464 | ? { 465 | prefetchTitle: msg[6][0], 466 | prefetchDescription: msg[6][1], 467 | raw: msg[6][6][2], 468 | domain: msg[6][7], 469 | } 470 | : null, 471 | image: msg[9] 472 | ? { 473 | data: msg[9][0], 474 | title: msg[9][2], 475 | format: msg[9][3], 476 | dimensions: msg[9][4], 477 | } 478 | : null, 479 | meet: msg[11] 480 | ? { 481 | link: msg[11][0][2], 482 | } 483 | : null, 484 | }; 485 | } 486 | 487 | function availableRooms(rooms) { 488 | return rooms.map(room => ({ 489 | _raw: room, 490 | ...roomMeta(room[0]), 491 | displayName: room[1], 492 | normalizedName: room[1].toLowerCase(), 493 | memberCount: room[2], 494 | createdAt: room[3], 495 | searchPreview: true, 496 | // ?: room[4] // Boolean. has unread? 497 | })); 498 | } 499 | 500 | module.exports = { 501 | chats, 502 | chat, 503 | thread, 504 | user, 505 | message, 506 | event, 507 | mutate, 508 | availableRooms, 509 | }; 510 | -------------------------------------------------------------------------------- /src/lib/state.js: -------------------------------------------------------------------------------- 1 | const EE = require('./eventemitter'); 2 | const Chat = require('./model/chat'); 3 | const User = require('./model/user'); 4 | const timestamp = require('./timestamp'); 5 | 6 | /** 7 | * State 8 | */ 9 | const _chats = {}; 10 | const _threads = {}; 11 | let _active = { 12 | chat: false, 13 | thread: false, 14 | search: false, 15 | }; 16 | const _search = { 17 | mode: false, 18 | available: [], 19 | }; 20 | let _unread = []; 21 | let _last = []; 22 | 23 | function _fetchChats() { 24 | return Chat.fetchChats().then(cs => { 25 | cs.forEach(chat => { 26 | _chats[chat.uri] = chat; 27 | if (chat.isUnread) { 28 | markUnread(chat); 29 | } 30 | }); 31 | }); 32 | } 33 | 34 | function _fetchThreads(chat, tsp) { 35 | _chats[chat.uri].loading = true; 36 | 37 | return Chat.fetchThreads(chat, tsp).then(ts => { 38 | _chats[chat.uri].loading = false; 39 | _chats[chat.uri].hasMoreThreads = ts.hasMore; 40 | if (!_threads[chat.uri]) { 41 | _threads[chat.uri] = {}; 42 | } 43 | ts.threads.forEach(t => { 44 | _threads[chat.uri][t.id] = t; 45 | }); 46 | // for when we need to fetch more 47 | _chats[chat.uri].moreTimestamp = timestamp.more(ts.threads[0].mostRecentAt); 48 | }); 49 | } 50 | 51 | async function _selectChat(chat) { 52 | if (!_chats[chat.uri]) { 53 | await Chat.fetchDetails(chat).then(deets => { 54 | _chats[chat.uri] = deets; 55 | }); 56 | } 57 | 58 | _active.chat = chat.uri; 59 | _chats[chat.uri].loading = true; 60 | EE.emit('state.chats.loading'); 61 | 62 | if (chat.isDm) { 63 | _active.thread = false; 64 | return Chat.fetchMessages(chat, timestamp.now()).then(msgs => { 65 | _chats[chat.uri].loading = false; 66 | _chats[chat.uri].messages = msgs; 67 | markRead(); 68 | }); 69 | } else { 70 | return _fetchThreads(chat, timestamp.now()) 71 | .then(markRead) 72 | .then(() => { 73 | ts = threads(); 74 | // always load the bottom one 75 | if (ts && ts.threads.length) { 76 | _selectThread(ts.threads[ts.threads.length - 1]); 77 | } 78 | }); 79 | } 80 | } 81 | 82 | function _selectThread(t) { 83 | _active.thread = t.id; 84 | markRead(); 85 | } 86 | 87 | /** 88 | * let the server know we've seen this chat and/or thread 89 | */ 90 | function markRead() { 91 | const c = chat(); 92 | if (!c) { 93 | return; 94 | } 95 | 96 | _chats[c.uri].isUnread = false; 97 | const idx = _unread.findIndex(u => u.uri === c.uri); 98 | // this chat is not unread, bail 99 | if (idx === -1) { 100 | return; 101 | } 102 | 103 | if (c.isDm) { 104 | Chat.markRead(c); 105 | } else { 106 | const t = thread(); 107 | // we don't have a thread selected yet, nothing to "read" 108 | if (!t) { 109 | return; 110 | } 111 | _threads[c.uri][t.id].isUnread = false; 112 | Chat.markRead(t); 113 | 114 | // this causes the chats to jump around too much 115 | // // if there's any other threads in this chat that are unread 116 | // // put this chat back into unread at the proper place 117 | // const unreadThreads = threads().threads.filter(t => t.isUnread); 118 | // if (unreadThreads.length > 1) { 119 | // markUnread(c, unreadThreads[1].mostRecentAt); 120 | // } 121 | } 122 | 123 | // TODO this is a naive removement of the first instance of chat 124 | // from _unread, but that is probably the incorrect behavior 125 | _unread.splice(idx, 1); 126 | } 127 | 128 | /** 129 | * Something about this chat is unread 130 | * we'll sort out exactly what when we render it 131 | * 132 | * @param {unpack.chat} chat 133 | * @param {timestamp} mostRecentAt 134 | */ 135 | function markUnread(chat, mostRecentAt = false) { 136 | _chats[chat.uri].isUnread = true; 137 | if (mostRecentAt) { 138 | // TODO this is the wrong behavior for sorting chats by .mostRecentAt 139 | // we need a .mostRecentUnreadAt 140 | _chats[chat.uri].mostRecentAt = mostRecentAt; 141 | } 142 | 143 | _unread.push(chat); 144 | // TODO it might be more efficient to sort when we're looking or the next unread 145 | _unread.sort((a, b) => (a.mostRecentAt > b.mostRecentAt ? -1 : 1)); 146 | } 147 | 148 | /** 149 | * Event Callbacks 150 | */ 151 | 152 | EE.on('screen.ready', () => { 153 | // fetch only once, rely on events incomming to update any state 154 | _fetchChats().then(() => { 155 | EE.emit('state.chats.updated'); 156 | EE.emit('state.fetched'); 157 | }); 158 | }); 159 | 160 | EE.on('search.local', () => { 161 | _search.available = chats(); 162 | _active.search = true; 163 | _search.mode = 'local'; 164 | 165 | EE.emit('state.search.updated'); 166 | }); 167 | EE.on('search.remote', () => { 168 | _active.search = true; 169 | _search.mode = 'remote'; 170 | _search.loading = true; 171 | EE.emit('state.search.updated'); 172 | 173 | Chat.fetchAvailableChats().then(available => { 174 | _search.available = available; 175 | _search.loading = false; 176 | EE.emit('state.search.updated'); 177 | }); 178 | }); 179 | EE.on('search.preview', chat => { 180 | _selectChat(chat).then(() => { 181 | EE.emit('state.threads.updated'); 182 | }); 183 | }); 184 | EE.on('search.close', () => { 185 | _active.search = false; 186 | _active.chat = false; 187 | _active.thread = false; 188 | EE.emit('state.search.updated'); 189 | EE.emit('state.chats.updated'); 190 | }); 191 | EE.on('search.select', chat => { 192 | _active.search = false; 193 | 194 | const p = 195 | _search.mode === 'local' 196 | ? _selectChat(chat) 197 | : Chat.join(chat).then(() => _selectChat(chat)); 198 | 199 | p.then(() => { 200 | EE.emit('state.search.updated'); 201 | EE.emit('state.chats.updated'); 202 | EE.emit('state.threads.updated'); 203 | }); 204 | }); 205 | EE.on('chats.select', chat => { 206 | _selectChat(chat).then(() => { 207 | EE.emit('state.chats.updated'); 208 | EE.emit('state.messages.updated'); 209 | EE.emit('state.threads.updated'); 210 | }); 211 | }); 212 | EE.on('chats.leave', chat => { 213 | Chat.leave(chat).then(() => { 214 | delete _chats[chat.uri]; 215 | EE.emit('state.chats.updated'); 216 | }); 217 | }); 218 | EE.on('threads.select', t => { 219 | _selectThread(t); 220 | EE.emit('state.threads.updated'); 221 | EE.emit('state.messages.updated'); 222 | }); 223 | EE.on('threads.blur', () => { 224 | _active.thread = false; 225 | _active.chat = false; 226 | 227 | EE.emit('state.search.updated'); 228 | EE.emit('state.chats.updated'); 229 | EE.emit('state.threads.updated'); 230 | }); 231 | EE.on('threads.fetchMore', () => { 232 | const c = chat(); 233 | _fetchThreads(c, c.moreTimestamp).then(() => { 234 | if (c.isThreaded) { 235 | EE.emit('state.threads.updated'); 236 | } else { 237 | EE.emit('state.messages.updated'); 238 | } 239 | }); 240 | }); 241 | EE.on('state.pop', () => { 242 | if (_last.length) { 243 | _last.push(_active); 244 | _active = { ..._last.shift() }; 245 | EE.emit('state.chats.updated'); 246 | EE.emit('state.threads.updated'); 247 | EE.emit('state.messages.updated'); 248 | } 249 | }); 250 | EE.on('input.blur', (toChats = false) => { 251 | const c = chat(); 252 | // not sure how this is possible yet, but seen it once 253 | if (!c) { 254 | return; 255 | } 256 | 257 | if (c.isDm || !c.isThreaded || toChats) { 258 | _active.thread = false; 259 | _active.chat = false; 260 | } else { 261 | _active.thread = false; 262 | } 263 | 264 | EE.emit('state.threads.updated'); 265 | EE.emit('state.messages.updated'); 266 | EE.emit('state.chats.updated'); 267 | }); 268 | EE.on('messages.expand', () => { 269 | const t = thread(); 270 | if (t && t.unfetched > 0) { 271 | _threads[t.room.uri][t.id].loading = true; 272 | EE.emit('state.messages.updated'); 273 | 274 | Chat.fetchMessages(t, timestamp.now()).then(msgs => { 275 | _threads[t.room.uri][t.id].loading = false; 276 | _threads[t.room.uri][t.id].messages = msgs; 277 | _threads[t.room.uri][t.id].unfetched = thread().total - msgs.length; 278 | EE.emit('state.messages.updated'); 279 | }); 280 | } 281 | }); 282 | EE.on('unread.next', () => { 283 | if (_unread.length) { 284 | // TODO config to read from start or end of _unread 285 | const next = _unread[0]; 286 | _selectChat(next).then(() => { 287 | if (next.isThreaded) { 288 | const ts = threads().threads; 289 | const unreadThreads = ts.filter(t => t.isUnread); 290 | // edge case while i was testing with two windows open: 291 | // we might have a new message but no unread threads 292 | // so assume the "newest" thread 293 | if (!unreadThreads.length) { 294 | _active.thread = ts.pop().id; 295 | } else { 296 | _active.thread = unreadThreads[0].id; 297 | } 298 | markRead(next); 299 | 300 | // if this chat has a different thread that is also unread 301 | // put it back into the correct place in _unread 302 | if (unreadThreads.length > 1) { 303 | markUnread(next, unreadThreads[1].mostRecentAt); 304 | } 305 | } 306 | 307 | EE.emit('state.chats.updated'); 308 | EE.emit('state.threads.updated'); 309 | EE.emit('state.messages.updated'); 310 | }); 311 | } 312 | }); 313 | 314 | EE.once('state.fetched', () => { 315 | // new message 316 | EE.on('events.6', async evt => { 317 | const c = _chats[evt.room.uri]; 318 | if (!c) { 319 | // if we don't have this room, bail bail bail 320 | // and let the "redraw the world" process take over 321 | // which... i mean... we could always do, but this is 322 | // an expensive call so we should try not to do it a lot 323 | await _fetchChats(); 324 | // no need to mark anythign here, _fetchChats handles that 325 | } else { 326 | if (c.isDm) { 327 | if (c.messages) { 328 | c.messages.push(evt); 329 | } 330 | 331 | if (c.uri !== _active.chat) { 332 | markUnread(c, evt.mostRecentAt); 333 | } else { 334 | _chats[c.uri].mostRecentAt = evt.mostRecentAt; 335 | } 336 | } else { 337 | if (_threads[c.uri]) { 338 | if (c.isThreaded) { 339 | if (!_threads[c.uri][evt.thread.id]) { 340 | // if this is a new thread, or someone resurrecting an old one 341 | // take the slightly nuclear option 342 | await _fetchThreads(c); 343 | } else { 344 | // let the User be preloaded if we haven't seen it yet 345 | evt.user.room = evt.room; 346 | User.prefetch(evt.user); 347 | 348 | _threads[c.uri][evt.thread.id] = { 349 | ..._threads[c.uri][evt.thread.id], 350 | messages: _threads[c.uri][evt.thread.id].messages.concat(evt), 351 | mostRecentAt: evt.mostRecentAt, 352 | isUnread: _active.thread != evt.thread.id, 353 | total: _threads[c.uri][evt.thread.id].total + 1, 354 | }; 355 | } 356 | } else { 357 | // let the User be preloaded if we haven't seen it yet 358 | evt.user.room = evt.room; 359 | User.prefetch(evt.user); 360 | 361 | // unthreaded spaces look like threads but each thread only has one message 362 | _threads[c.uri][evt.thread.id] = { 363 | messages: [evt], 364 | mostRecentAt: evt.mostRecentAt, 365 | isUnread: _active.thread != evt.thread.id, 366 | }; 367 | } 368 | } 369 | 370 | if (c.isThreaded && evt.thread && evt.thread.id !== _active.thread) { 371 | markUnread(c, evt.mostRecentAt); 372 | } else { 373 | _chats[c.uri].mostRecentAt = evt.mostRecentAt; 374 | } 375 | } 376 | } 377 | 378 | // TODO i wonder if this will cause issues when in the middle of typing a message? 379 | EE.emit('state.chats.updated'); 380 | EE.emit('state.threads.updated'); 381 | EE.emit('state.messages.updated'); 382 | }); 383 | }); 384 | 385 | /** 386 | * State Accessors 387 | */ 388 | 389 | function chats() { 390 | if (_active.search && _search.mode === 'remote') { 391 | return _search.available; 392 | } 393 | 394 | return Object.entries(_chats) 395 | .map(([_, val]) => val) 396 | .sort((a, b) => 397 | a.isFave || b.isFave ? 0 : a.mostRecentAt > b.mostRecentAt ? -1 : 1 398 | ); 399 | } 400 | 401 | function chat() { 402 | return _chats[_active.chat]; 403 | } 404 | 405 | function threads() { 406 | const c = chat(); 407 | if (!c || !c.isThreaded) { 408 | return false; 409 | } 410 | 411 | if (c.loading) { 412 | return { loading: true, threads: [] }; 413 | } 414 | 415 | return { 416 | loading: false, 417 | threads: Object.entries(_threads[_active.chat]) 418 | .map(([_, val]) => val) 419 | .sort((a, b) => (a.mostRecentAt > b.mostRecentAt ? 1 : -1)) 420 | .filter(t => !t.isMembershipUpdate), 421 | }; 422 | } 423 | 424 | function thread() { 425 | return _active.chat && 426 | _active.thread && 427 | _threads[_active.chat] && 428 | _threads[_active.chat][_active.thread] 429 | ? _threads[_active.chat][_active.thread] 430 | : false; 431 | } 432 | 433 | function messages() { 434 | if (!_active.chat) { 435 | return false; 436 | } 437 | 438 | const c = chat(); 439 | if (c.isDm) { 440 | return c; 441 | } else if (!c.isThreaded) { 442 | if (!_threads[_active.chat]) { 443 | return false; 444 | } 445 | 446 | if (c.isLoading) { 447 | return c; 448 | } 449 | 450 | // groups look like rooms, but each message is a new thread 451 | return Object.entries(_threads[_active.chat]).reduce( 452 | (acc, [_, t]) => { 453 | if (!acc.messages) { 454 | acc.messages = t.messages; 455 | } else { 456 | acc.messages = acc.messages.concat(t.messages); 457 | } 458 | return acc; 459 | }, 460 | { 461 | hasMore: !c.isThreaded && c.hasMoreThreads, 462 | } 463 | ); 464 | } else { 465 | return thread(); 466 | } 467 | } 468 | 469 | function search() { 470 | return _active.search && _search; 471 | } 472 | 473 | module.exports = { 474 | chat, 475 | chats, 476 | thread, 477 | threads, 478 | messages, 479 | search, 480 | }; 481 | --------------------------------------------------------------------------------