├── .npmrc ├── .env.example ├── .gitignore ├── .travis.yml ├── .prettierrc ├── src ├── settings.ts ├── util │ ├── transformers.ts │ ├── logger.ts │ └── helpers.ts ├── store │ ├── threads.ts │ └── users.ts ├── api.ts ├── types │ └── facebook-chat-api.d.ts └── messen.ts ├── tsconfig.json ├── README.md ├── LICENSE ├── test ├── acceptance │ └── messen.test.ts ├── unit │ └── store │ │ ├── threads.test.ts │ │ └── users.test.ts └── messen.mock.ts ├── package.json └── tslint.json /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | FACEBOOK_EMAIL=haha@gmail.com 2 | FACEBOOK_PASSWORD=testPassword123 3 | NODE_ENV=test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | *debug.log 3 | .env 4 | 5 | playground.js 6 | config/test.json 7 | 8 | # misc 9 | node_modules 10 | .DS_Store 11 | .vscode 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - 'stable' 5 | 6 | install: 7 | - npm install 8 | 9 | cache: 10 | directories: 11 | - node_modules 12 | 13 | script: 14 | - npm run test-unit 15 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "overrides": [ 5 | { 6 | "files": "*.ts", 7 | "options": { 8 | "parser": "typescript" 9 | } 10 | } 11 | ] 12 | } -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { homedir } from 'os'; 3 | 4 | require('dotenv').config(); 5 | 6 | export const ENVIRONMENT = process.env.NODE_ENV || 'production'; 7 | 8 | export const MESSEN_PATH = path.resolve(homedir(), '.messen'); -------------------------------------------------------------------------------- /src/util/transformers.ts: -------------------------------------------------------------------------------- 1 | import facebook from 'facebook-chat-api' 2 | 3 | export function facebookFriendToUser(friend: facebook.FacebookFriend): facebook.FacebookUser { 4 | return { 5 | id: friend.userID, 6 | name: friend.fullName, 7 | isBirthday: friend.isBirthday, 8 | vanity: friend.vanity, 9 | isFriend: friend.isFriend, 10 | type: friend.type, 11 | firstName: friend.firstName, 12 | thumbSrc: friend.profilePicture, 13 | gender: friend.gender, 14 | profileUrl: friend.profileUrl 15 | } 16 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "esModuleInterop": true, 5 | "target": "es5", 6 | "noImplicitAny": true, 7 | "moduleResolution": "node", 8 | "declaration": true, 9 | "removeComments": true, 10 | "sourceMap": false, 11 | "outDir": "dist", 12 | "baseUrl": ".", 13 | "resolveJsonModule": true, 14 | "paths": { 15 | "*": ["node_modules/*", "src/types/*"] 16 | }, 17 | "alwaysStrict": true, 18 | "strict": true, 19 | "noUnusedLocals": true 20 | }, 21 | "include": ["src/**/*"], 22 | "exclude": [ 23 | "node_modules", 24 | "dist" 25 | ], 26 | "compileOnSave": true 27 | } 28 | -------------------------------------------------------------------------------- /src/util/logger.ts: -------------------------------------------------------------------------------- 1 | import { createLogger, format, transports } from 'winston'; 2 | import { ENVIRONMENT } from '../settings'; 3 | 4 | const getlogger = (name: string) => 5 | createLogger({ 6 | transports: [ 7 | new transports.Console({ 8 | level: ENVIRONMENT === 'production' ? 'error' : 'debug', 9 | format: format.combine( 10 | format.timestamp(), 11 | format.colorize(), 12 | format.printf( 13 | (info: any) => 14 | `${name} (${info.timestamp} ${info.level}): ${info.message}`, 15 | ), 16 | ), 17 | }), 18 | // new transports.File({ filename: `${name}.debug.log`, level: 'debug' }), 19 | ], 20 | }); 21 | 22 | export default getlogger; 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # messen 2 | 3 | A lightweight framework for building Facebook Messenger apps 4 | 5 | ## Installation 6 | 7 | ```bash 8 | $ npm install messen 9 | ``` 10 | 11 | ## Getting started 12 | 13 | Messen handles the boring stuff for you, and exposes a number of callback methods that you must define. These are: 14 | 15 | - `getMfaCode` 16 | - `promptCredentials` 17 | - `onMessage` 18 | - `onThreadEvent` 19 | 20 | Have a look at the type definitions for how they should be implemented. 21 | 22 | ### Example usage 23 | 24 | ```js 25 | const messen = new Messen(); 26 | 27 | messen.onMessage = ev => { 28 | console.log(ev); 29 | }; 30 | 31 | // login to messen 32 | messen.login({ email: 'test@mailnator.com', password: 'P4ssw0rd' }).then(() => { 33 | // start listening to events, like messages, reactions, etc. 34 | messen.listen(); 35 | }); 36 | ``` 37 | 38 | ## Projects using `messen` 39 | 40 | * [Messer](https://github.com/mjkaufer/Messer) - a CLI chat application for Facebook Messenger 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Tom Quirk 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 | -------------------------------------------------------------------------------- /test/acceptance/messen.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import readline from 'readline'; 3 | 4 | import { Messen } from '../../src/messen'; 5 | 6 | function promptCode(): Promise { 7 | const rl = readline.createInterface({ 8 | input: process.stdin, 9 | output: process.stdout, 10 | }); 11 | 12 | return new Promise(resolve => { 13 | console.log('Enter code > '); 14 | return rl.on('line', line => { 15 | resolve(line); 16 | rl.close(); 17 | }); 18 | }); 19 | } 20 | 21 | describe('Messen', function () { 22 | let messen: Messen; 23 | before(() => { 24 | messen = new Messen(); 25 | messen.getMfaCode = () => { 26 | return promptCode(); 27 | }; 28 | }); 29 | 30 | it('should be able to log in to a real Facebook account', async function () { 31 | this.timeout(60 * 1000); // 60s timeout 32 | if (!process.env.FACEBOOK_EMAIL || !process.env.FACEBOOK_PASSWORD) throw new Error() 33 | await messen 34 | .login({ 35 | email: process.env.FACEBOOK_EMAIL, 36 | password: process.env.FACEBOOK_PASSWORD, 37 | }, false); 38 | expect(messen.state.authenticated).to.be.true; 39 | }); 40 | 41 | it('should be able to log out', async function () { 42 | await messen.logout(); 43 | expect(messen.state.authenticated).to.be.false; 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "messen", 3 | "version": "1.0.0-beta.10", 4 | "description": "A lightweight framework for building Facebook Messenger apps", 5 | "main": "dist/messen.js", 6 | "types": "dist/messen.d.ts", 7 | "directories": { 8 | "test": "test" 9 | }, 10 | "scripts": { 11 | "start": "node index.js", 12 | "build-ts": "tsc", 13 | "publish-ts": "npm run build-ts && npm publish", 14 | "dev": "npm run build-ts && npm start", 15 | "test": "npm run build-ts && mocha -r ts-node/register test/**/*.test.ts", 16 | "test-unit": "npm run build-ts && mocha -r ts-node/register test/unit/**/*.test.ts" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/tomquirk/messen.git" 21 | }, 22 | "author": "Tom Quirk ", 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/tomquirk/messen/issues" 26 | }, 27 | "homepage": "https://github.com/tomquirk/messen#readme", 28 | "dependencies": { 29 | "facebook-chat-api": "github:Schmavery/facebook-chat-api", 30 | "mkdirp": "^0.5.1", 31 | "prompt": "^1.0.0", 32 | "readline": "^1.3.0", 33 | "winston": "^3.1.0", 34 | "dotenv": "^6.2.0" 35 | }, 36 | "devDependencies": { 37 | "@types/chai": "^4.1.7", 38 | "@types/dotenv": "^6.1.0", 39 | "@types/mkdirp": "^0.5.2", 40 | "@types/mocha": "^5.2.5", 41 | "@types/node": "^10.12.9", 42 | "chai": "^4.2.0", 43 | "mocha": "^5.2.0", 44 | "prettier": "^1.14.3", 45 | "ts-lint": "^4.5.1", 46 | "ts-node": "^7.0.1", 47 | "typescript": "^3.1.6" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /test/unit/store/threads.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import { getThreadStore } from '../../messen.mock'; 4 | import { ThreadStore } from '../../../src/store/threads' 5 | 6 | describe('Thread Store', function () { 7 | let threadStore: ThreadStore; 8 | before(() => { 9 | threadStore = getThreadStore() 10 | }); 11 | 12 | it('should be able to get a thread by id', async function () { 13 | await threadStore.getThread({ id: '100003961877411' }).then(thread => { 14 | expect(thread).to.exist; 15 | if (!thread) throw new Error() 16 | expect(thread.threadID).to.equal('100003961877411') 17 | }) 18 | }); 19 | 20 | it('should be able to get a thread by name', async function () { 21 | await threadStore.getThread({ name: 'tom quirk' }).then(thread => { 22 | expect(thread).to.exist; 23 | if (!thread) throw new Error() 24 | expect(thread.threadID).to.equal('100003961877411') 25 | }) 26 | }); 27 | 28 | it('should be able to get a thread by fuzzy thread name', async function () { 29 | await threadStore.getThread({ name: 'to' }).then(thread => { 30 | expect(thread).to.exist; 31 | if (!thread) throw new Error() 32 | expect(thread.threadID).to.equal('100003961877411') 33 | }) 34 | }); 35 | 36 | it('should be able to get a thread by id when query contains both name and id', async function () { 37 | await threadStore.getThread({ id: '100003961877411', name: 'ahhaha' }).then(thread => { 38 | expect(thread).to.exist; 39 | if (!thread) throw new Error() 40 | expect(thread.threadID).to.equal('100003961877411') 41 | }) 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "class-name": true, 4 | "comment-format": [ 5 | true, 6 | "check-space" 7 | ], 8 | "indent": [ 9 | true, 10 | "spaces" 11 | ], 12 | "one-line": [ 13 | true, 14 | "check-open-brace", 15 | "check-whitespace" 16 | ], 17 | "no-var-keyword": true, 18 | "quotemark": [ 19 | true, 20 | "double", 21 | "avoid-escape" 22 | ], 23 | "semicolon": [ 24 | true, 25 | "always", 26 | "ignore-bound-class-methods" 27 | ], 28 | "whitespace": [ 29 | true, 30 | "check-branch", 31 | "check-decl", 32 | "check-operator", 33 | "check-module", 34 | "check-separator", 35 | "check-type" 36 | ], 37 | "typedef-whitespace": [ 38 | true, 39 | { 40 | "call-signature": "nospace", 41 | "index-signature": "nospace", 42 | "parameter": "nospace", 43 | "property-declaration": "nospace", 44 | "variable-declaration": "nospace" 45 | }, 46 | { 47 | "call-signature": "onespace", 48 | "index-signature": "onespace", 49 | "parameter": "onespace", 50 | "property-declaration": "onespace", 51 | "variable-declaration": "onespace" 52 | } 53 | ], 54 | "no-internal-module": true, 55 | "no-trailing-whitespace": true, 56 | "no-null-keyword": true, 57 | "prefer-const": true, 58 | "jsdoc-format": true 59 | } 60 | } -------------------------------------------------------------------------------- /src/util/helpers.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import mkdirp from 'mkdirp'; 4 | import facebook from 'facebook-chat-api'; 5 | 6 | /** 7 | * Dumps the state of the Facebook API to a file 8 | * @param {Object} appstate object generated from fbApi.getAppState() method 9 | * @param {string} filepath file to save appstate to 10 | * @return {Promise} 11 | */ 12 | export function saveAppState( 13 | appstate: facebook.AppState, 14 | filepath: string, 15 | ): Promise { 16 | return new Promise((resolve, reject) => 17 | mkdirp(path.dirname(filepath), mkdirpErr => { 18 | if (mkdirpErr) return reject(mkdirpErr); 19 | 20 | // ...then write the file 21 | return fs.writeFile(filepath, JSON.stringify(appstate), writeErr => { 22 | if (writeErr) return reject(writeErr); 23 | 24 | return resolve(appstate); 25 | }); 26 | }), 27 | ); 28 | } 29 | 30 | export function loadAppState(filepath: string): Promise { 31 | return new Promise((resolve, reject) => { 32 | fs.readFile(filepath, (appStateErr, rawAppState: Buffer) => { 33 | if (appStateErr) { 34 | return reject(appStateErr); 35 | } 36 | 37 | const appState = JSON.parse(rawAppState.toString()); 38 | 39 | if (!appState) { 40 | return reject(Error('App state is empty')); 41 | } 42 | 43 | return resolve(appState); 44 | }); 45 | }); 46 | } 47 | 48 | export function clearAppState(filepath: string, 49 | ): Promise { 50 | return new Promise((resolve, reject) => { 51 | fs.unlink(filepath, (err) => { 52 | // if (err) return reject(err) 53 | return resolve(); 54 | }); 55 | }); 56 | } 57 | 58 | export function sortObjects(arr: Array, key: string, order: 'asc' | 'desc'): Array { 59 | return arr.sort((a, b) => { 60 | if (a[key] < b[key]) return order === 'asc' ? -1 : 1; 61 | if (a[key] > b[key]) return order === 'asc' ? 1 : -1; 62 | return 0; 63 | }); 64 | }; 65 | -------------------------------------------------------------------------------- /test/unit/store/users.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import { getUserStore } from '../../messen.mock'; 4 | import { UserStore } from '../../../src/store/users' 5 | 6 | describe('User Store', function () { 7 | let userStore: UserStore; 8 | before(() => { 9 | userStore = getUserStore() 10 | }); 11 | 12 | it('should be able to get a user by id', async function () { 13 | await userStore.getUser({ id: '100003961877411' }).then(user => { 14 | expect(user).to.exist; 15 | if (!user) throw new Error() 16 | expect(user.id).to.equal('100003961877411') 17 | }) 18 | }); 19 | 20 | it('should be able to get a user by name', async function () { 21 | await userStore.getUser({ name: 'tom quirk' }).then(user => { 22 | expect(user).to.exist; 23 | if (!user) throw new Error() 24 | expect(user.id).to.equal('100003961877411') 25 | }) 26 | }); 27 | 28 | it('should be able to get a user by fuzzy user name', async function () { 29 | await userStore.getUser({ name: 'to' }).then(user => { 30 | expect(user).to.exist; 31 | if (!user) throw new Error() 32 | expect(user.id).to.equal('100003961877411') 33 | }) 34 | }); 35 | 36 | it('should be able to get a user by id when query contains both name and id', async function () { 37 | await userStore.getUser({ id: '100003961877411', name: 'ahhaha' }).then(user => { 38 | expect(user).to.exist; 39 | if (!user) throw new Error() 40 | expect(user.id).to.equal('100003961877411') 41 | }) 42 | }); 43 | 44 | it('should be able to get a list of users from a list of user ids', async function () { 45 | await userStore.getUsers(['100003961877411', '100035969370185']).then(users => { 46 | expect(users).to.exist; 47 | expect(users.length).to.equal(2); 48 | const userA = users[0] 49 | const userB = users[1] 50 | if (!userA || !userB) throw new Error() 51 | expect(userA.id).to.equal('100003961877411') 52 | expect(userB.id).to.equal('100035969370185') 53 | }) 54 | }); 55 | 56 | it('should be able to get a list of users, the first being null', async function () { 57 | await userStore.getUsers(['bad user id', '100003961877411', '100035969370185']).then(users => { 58 | expect(users).to.exist; 59 | expect(users.length).to.equal(3); 60 | expect(users[0]).to.equal(undefined) 61 | const userA = users[1] 62 | const userB = users[2] 63 | if (!userA || !userB) throw new Error() 64 | expect(userA.id).to.equal('100003961877411') 65 | expect(userB.id).to.equal('100035969370185') 66 | }) 67 | }); 68 | 69 | 70 | it('should be able to get the currently logged in user (me user)', function () { 71 | expect(userStore.me.user).to.exist 72 | expect(userStore.me.user.id).to.equal('100035969370185') 73 | expect(userStore.me.friends.length).to.be.above(0) 74 | expect(userStore.me.friends[0].id).to.equal('100003961877411') 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /src/store/threads.ts: -------------------------------------------------------------------------------- 1 | import facebook from 'facebook-chat-api'; 2 | import api from '../api'; 3 | import { sortObjects } from '../util/helpers' 4 | 5 | type ThreadQuery = { 6 | id?: string 7 | name?: string 8 | } 9 | 10 | const THREAD_QUERY_COUNT = 20 11 | 12 | export class ThreadStore { 13 | _threads: { 14 | [id: string]: facebook.FacebookThread 15 | } 16 | _threadNameToId: { 17 | [name: string]: string 18 | } 19 | _api: facebook.API 20 | 21 | constructor(api: facebook.API 22 | ) { 23 | this._api = api; 24 | this._threads = {} 25 | this._threadNameToId = {} 26 | } 27 | 28 | _cleanThread(thread: facebook.FacebookThread): facebook.FacebookThread { 29 | if (thread.isGroup && thread.name === null) { 30 | const participantNames = (thread.participants || []).filter(user => user.id !== this._api.getCurrentUserID()).map(user => user.name).join(', ') 31 | thread.name = `(group) ${participantNames || thread.threadID}` 32 | } 33 | return thread 34 | } 35 | 36 | _upsertThread(thread: facebook.FacebookThread): void { 37 | const cleanThread = this._cleanThread(thread) 38 | this._threads[cleanThread.threadID] = cleanThread 39 | this._threadNameToId[cleanThread.name] = cleanThread.threadID 40 | } 41 | 42 | _getThreadById(id: string): facebook.FacebookThread | undefined { 43 | return this._threads[id] 44 | } 45 | 46 | _getThreadByName(nameQuery: string): facebook.FacebookThread | null { 47 | let threadId = this._threadNameToId[nameQuery] 48 | if (!threadId) { 49 | const threadName = Object.keys(this._threadNameToId).find(name => 50 | name.toLowerCase().startsWith(nameQuery.toLowerCase()), 51 | ); 52 | if (!threadName) return null 53 | 54 | threadId = this._threadNameToId[threadName] 55 | } 56 | 57 | if (!threadId) return null 58 | 59 | return this._threads[threadId] 60 | } 61 | 62 | async _refreshThread(id: string): Promise { 63 | const thread = await api.fetchThreadInfo(this._api, id); 64 | // add thread to store 65 | this._upsertThread(thread); 66 | return thread; 67 | } 68 | 69 | async refresh() { 70 | const threads = await api.fetchThreads(this._api, THREAD_QUERY_COUNT); 71 | threads.forEach(thread => { 72 | this._upsertThread(thread); 73 | }); 74 | } 75 | 76 | async getThread(query: ThreadQuery): Promise { 77 | let thread = undefined; 78 | const { name, id } = query 79 | // look for ID, then for name 80 | if (id) { 81 | thread = this._getThreadById(id) 82 | } else if (name) { 83 | thread = this._getThreadByName(name) 84 | } 85 | 86 | if (thread) return Promise.resolve(thread) 87 | 88 | if (id) { 89 | return await this._refreshThread(id) 90 | } 91 | 92 | return Promise.resolve(null) 93 | } 94 | 95 | getThreadList(limit?: number, order: 'asc' | 'desc' = 'desc'): Array { 96 | return sortObjects(Object.values(this._threads), "lastMessageTimestamp", order).slice(0, limit) 97 | } 98 | } -------------------------------------------------------------------------------- /src/api.ts: -------------------------------------------------------------------------------- 1 | import facebook, { FacebookError } from 'facebook-chat-api'; 2 | import * as settings from './settings'; 3 | 4 | import getLogger from './util/logger'; 5 | 6 | const logger = getLogger('api'); 7 | 8 | if (settings.ENVIRONMENT !== 'production') { 9 | logger.info('Logging initialized at debug level'); 10 | } 11 | 12 | function fetchUserInfo( 13 | api: facebook.API, 14 | userId: string, 15 | ): Promise { 16 | return new Promise((resolve, reject) => { 17 | return api.getUserInfo( 18 | userId, 19 | (err, data: { [key: string]: facebook.FacebookUser }) => { 20 | if (err) return reject(Error(err.error)); 21 | 22 | const user = data[userId]; 23 | user.id = userId; 24 | 25 | return resolve(user); 26 | }, 27 | ); 28 | }); 29 | } 30 | 31 | function fetchUserInfoBatch( 32 | api: facebook.API, 33 | userIds: Array, 34 | ): Promise> { 35 | return new Promise((resolve, reject) => { 36 | return api.getUserInfo( 37 | userIds, 38 | (err, data: { [key: string]: facebook.FacebookUser }) => { 39 | if (err) return reject(Error(err.error)); 40 | 41 | const users = Object.keys(data).map(k => { 42 | return Object.assign(data[k], { id: k }) 43 | }) 44 | 45 | return resolve(users); 46 | }, 47 | ); 48 | }); 49 | } 50 | 51 | function fetchApiUserFriends( 52 | api: facebook.API, 53 | ): Promise> { 54 | return new Promise((resolve, reject) => { 55 | return api.getFriendsList((err, data: any) => { 56 | if (err) return reject(Error(err.error)); 57 | 58 | return resolve(data); 59 | }); 60 | }); 61 | } 62 | 63 | function fetchThreadInfo( 64 | api: facebook.API, 65 | threadId: string 66 | ): Promise { 67 | return new Promise((resolve, reject) => { 68 | return api.getThreadInfo(threadId, (err, data: any) => { 69 | if (err) return reject(Error(err.error)); 70 | 71 | return resolve(data); 72 | }); 73 | }); 74 | } 75 | 76 | function fetchThreads( 77 | api: facebook.API, 78 | limit: number, 79 | timestamp?: string, 80 | tags?: facebook.ThreadListTagQuery 81 | ): Promise> { 82 | return new Promise((resolve, reject) => { 83 | return api.getThreadList(limit, timestamp || null, tags || [], (err: FacebookError | undefined, data: any) => { 84 | if (err) return reject(Error(err.error)); 85 | return resolve(data); 86 | }); 87 | }); 88 | } 89 | 90 | function logout( 91 | api: facebook.API 92 | ): Promise { 93 | return new Promise((resolve, reject) => { 94 | return api.logout((err) => { 95 | if (err) return reject(Error(err.error)); 96 | 97 | return resolve(); 98 | }); 99 | }) 100 | } 101 | 102 | function getApi( 103 | payload: facebook.Credentials | { appState: facebook.AppState }, 104 | config: facebook.APIconfig, 105 | getMfaCode: () => Promise, 106 | ): Promise { 107 | return new Promise((resolve, reject) => { 108 | return facebook(payload, config, (err, api) => { 109 | if (err) { 110 | switch (err.error) { 111 | case 'login-approval': 112 | return getMfaCode().then((code: string) => { 113 | logger.debug('MFA code: ' + code); 114 | if (err.continue) { 115 | return err.continue(code); 116 | } 117 | 118 | return reject(Error(`Failed to login: we couldnt send your MFA code`)) 119 | }); 120 | default: 121 | return reject(Error(`Failed to login: ${err.error}`)); 122 | } 123 | } 124 | 125 | logger.debug('Successfully logged in'); 126 | 127 | if (!api) { 128 | return reject(Error('api failed to load')); 129 | } 130 | 131 | return resolve(api); 132 | }); 133 | }); 134 | } 135 | 136 | export default { 137 | getApi, 138 | fetchApiUserFriends, 139 | fetchUserInfo, 140 | fetchUserInfoBatch, 141 | fetchThreadInfo, 142 | fetchThreads, 143 | logout 144 | }; 145 | -------------------------------------------------------------------------------- /src/store/users.ts: -------------------------------------------------------------------------------- 1 | import facebook from 'facebook-chat-api'; 2 | import api from '../api'; 3 | import { facebookFriendToUser } from '../util/transformers'; 4 | 5 | type UserQuery = { 6 | id?: string 7 | name?: string 8 | } 9 | 10 | function notUndefined(x: T | undefined): x is T { 11 | return x !== undefined; 12 | } 13 | 14 | export class UserStore { 15 | _users: { 16 | [id: string]: facebook.FacebookUser 17 | } 18 | _userNameToId: { 19 | [name: string]: string 20 | } 21 | _api: facebook.API 22 | me!: { 23 | user: facebook.FacebookUser, 24 | friends: Array 25 | } 26 | 27 | constructor(api: facebook.API 28 | ) { 29 | this._api = api; 30 | this._users = {} 31 | this._userNameToId = {} 32 | } 33 | 34 | _upsertUser(user: facebook.FacebookUser): void { 35 | this._users[user.id] = user 36 | this._userNameToId[user.name] = user.id 37 | } 38 | 39 | _getUserById(id: string): facebook.FacebookUser | undefined { 40 | return this._users[id] 41 | } 42 | 43 | _getUserByName(nameQuery: string): facebook.FacebookUser | null { 44 | let userId = this._userNameToId[nameQuery] 45 | if (!userId) { 46 | const userName = Object.keys(this._userNameToId).find(name => 47 | name.toLowerCase().startsWith(nameQuery.toLowerCase()), 48 | ); 49 | if (!userName) return null 50 | 51 | userId = this._userNameToId[userName] 52 | } 53 | 54 | if (!userId) return null 55 | 56 | return this._users[userId] 57 | } 58 | 59 | async _refreshUser(id: string): Promise { 60 | const user = await api.fetchUserInfo(this._api, id); 61 | // add user to store 62 | this._upsertUser(user); 63 | return user; 64 | } 65 | 66 | async _refreshMeFriends(): Promise> { 67 | return api.fetchApiUserFriends(this._api).then(friendsRaw => { 68 | const friends = friendsRaw.map(f => { 69 | const user = facebookFriendToUser(f) 70 | this._upsertUser(user) 71 | return user 72 | }) 73 | 74 | return friends 75 | }) 76 | } 77 | 78 | async _refreshMeUser(): Promise { 79 | return api.fetchUserInfo(this._api, this._api.getCurrentUserID()).then(user => { 80 | this._upsertUser(user) 81 | return user 82 | }) 83 | } 84 | 85 | async refresh() { 86 | return Promise.all([ 87 | this._refreshMeFriends(), 88 | this._refreshMeUser() 89 | ]).then(([friends, user]) => { 90 | this.me = { 91 | user, 92 | friends 93 | } 94 | }) 95 | } 96 | 97 | async getUser(query: UserQuery): Promise { 98 | let user = undefined; 99 | const { name, id } = query 100 | // look for ID, then for name 101 | if (id) { 102 | user = this._getUserById(id) 103 | } else if (name) { 104 | user = this._getUserByName(name) 105 | } 106 | 107 | if (user) return Promise.resolve(user) 108 | 109 | if (id) { 110 | return await this._refreshUser(id) 111 | } 112 | 113 | return Promise.resolve(null) 114 | } 115 | 116 | async getUsers(userIds: Array): Promise> { 117 | const cachedUsers = userIds.map((id) => this._getUserById(id)).filter(x => notUndefined(x)) as facebook.FacebookUser[] 118 | const missingUserIds = cachedUsers.map((val, i) => { 119 | if (val) return undefined 120 | 121 | return userIds[i] 122 | }).filter(x => notUndefined(x)) as string[] 123 | 124 | let fetchedUsers: Array = [] 125 | 126 | if (missingUserIds.length > 0) { 127 | // fetch any users we dont have cached 128 | const fetchedUsers = await api.fetchUserInfoBatch(this._api, missingUserIds) 129 | fetchedUsers.forEach((user: facebook.FacebookUser) => { 130 | this._upsertUser(user) 131 | }) 132 | } 133 | 134 | const allUsers = [...cachedUsers.filter(Boolean), ...fetchedUsers] 135 | 136 | // return in order asked for 137 | return userIds.map(id => allUsers.find(user => user.id === id)) 138 | } 139 | } -------------------------------------------------------------------------------- /src/types/facebook-chat-api.d.ts: -------------------------------------------------------------------------------- 1 | export = Facebook; 2 | 3 | declare function Facebook( 4 | payload: Facebook.Credentials | { appState: Facebook.AppState }, 5 | options: { 6 | forceLogin: boolean; 7 | logLevel: string; 8 | selfListen: boolean; 9 | listenEvents: boolean; 10 | }, 11 | callback: (err: Facebook.FacebookError | undefined, api: any) => any, 12 | ): string; 13 | 14 | declare namespace Facebook { 15 | export type Credentials = { 16 | email: string; 17 | password: string; 18 | }; 19 | 20 | export type AppState = Array; 21 | 22 | export interface FacebookError { 23 | error: string; 24 | continue?: (val: string) => any; 25 | } 26 | 27 | type FacebookBaseUser = { 28 | isBirthday: boolean; 29 | vanity: string; 30 | isFriend: boolean; 31 | type: string; // friend | ... 32 | firstName: string; 33 | }; 34 | 35 | export type FacebookUser = { 36 | id: string; 37 | name: string; 38 | thumbSrc: string; 39 | profileUrl: string; 40 | gender: string | number; // TODO fix this... 41 | } & FacebookBaseUser; 42 | 43 | // avoid this type (transform it to a FacebookUser) 44 | export type FacebookFriend = { 45 | alternateName: string; 46 | gender: string; 47 | userID: string; 48 | fullName: string; 49 | profilePicture: string; 50 | profileUrl: string; 51 | } & FacebookBaseUser; 52 | 53 | export type BaseFacebookThread = { 54 | threadID: string 55 | name: string, // name of thread (usually name of user) 56 | } 57 | 58 | export type FacebookThread = BaseFacebookThread & { 59 | participantIDs: Array, 60 | nicknames: Array, 61 | unreadCount: number, 62 | messageCount: number, 63 | imageSrc: string | null, 64 | timestamp: string, 65 | muteUntil: string | null, 66 | isGroup: boolean, 67 | isSubscribed: boolean, 68 | folder: "INBOX" | "ARCHIVE", 69 | isArchived: boolean, 70 | cannotReplyReason: string | null, 71 | lastReadTimestamp: string | null, 72 | emoji: { 73 | emoji: string 74 | } | null, 75 | color: string | null, 76 | adminIDs: Array, 77 | participants: Array | undefined, 78 | customizationEnabled: boolean, 79 | participantAddMode: string | null, 80 | montageThread: any, 81 | reactionsMuteMode: 'REACTIONS_NOT_MUTED', 82 | mentionsMuteMode: 'MENTIONS_NOT_MUTED', 83 | snippet: string, 84 | snippetAttachments: Array | null, 85 | snippetSender: string, 86 | lastMessageTimestamp: string, 87 | threadType: number 88 | } 89 | 90 | export interface APIconfig { 91 | forceLogin: boolean; 92 | logLevel: string; 93 | selfListen: boolean; 94 | listenEvents: boolean; 95 | } 96 | 97 | export type ThreadListTagQuery = [] | ["INBOX"] | ["ARCHIVED"] | ["PENDING"] | ["OTHER"] | 98 | ["INBOX", "unread"] | ["ARCHIVED", "unread"] | ["PENDING", "unread"] | ["OTHER", "unread"] 99 | 100 | export class API { 101 | listenMqtt( 102 | callback: (err: Facebook.FacebookError | undefined, event: Facebook.APIEvent) => void, 103 | ): void; 104 | getCurrentUserID(): string; 105 | getAppState(): AppState; 106 | getUserInfo( 107 | userId: string | Array, 108 | callback: (err: Facebook.FacebookError | undefined, data: any) => void, 109 | ): void; 110 | getFriendsList( 111 | callback: (err: Facebook.FacebookError | undefined, data: any) => void, 112 | ): void; 113 | getThreadInfo( 114 | threadId: string, 115 | callback: (err: Facebook.FacebookError | undefined, data: any) => void, 116 | ): void; 117 | getThreadList( 118 | limit: number, 119 | timestamp: string | null, 120 | tags: ThreadListTagQuery, 121 | callback: (err: Facebook.FacebookError | undefined, data: any) => void 122 | ): void; 123 | logout( 124 | callback: (err: Facebook.FacebookError | undefined) => void 125 | ): void 126 | } 127 | 128 | export type APIEvent = MessageEvent | EventEvent 129 | 130 | export type MessageEvent = { 131 | attachments: Array, 132 | body: string, 133 | isGroup: boolean, 134 | mentions: { 135 | [id: string]: string 136 | }, 137 | messageID: string, 138 | senderID: string, 139 | threadID: string, 140 | isUnread: boolean, 141 | type: "message" 142 | } 143 | 144 | export type EventEvent = { 145 | author: string, 146 | logMessageBody: string, 147 | logMessageData: string, 148 | logMessageType: "log:subscribe" | "log:unsubscribe" | "log:thread-name" | "log:thread-color" | "log:thread-icon" | "log:user-nickname", 149 | threadID: string, 150 | type: "event" 151 | } 152 | 153 | // TODO Implement other event types 154 | } 155 | -------------------------------------------------------------------------------- /src/messen.ts: -------------------------------------------------------------------------------- 1 | import facebook from 'facebook-chat-api'; 2 | 3 | import * as settings from './settings'; 4 | 5 | import { ThreadStore } from './store/threads' 6 | import { UserStore } from './store/users'; 7 | 8 | import * as helpers from './util/helpers'; 9 | import getLogger from './util/logger'; 10 | import api from './api'; 11 | 12 | type MessenOptionsRequest = { 13 | dir?: string; 14 | appstateFilePath?: string 15 | debug?: boolean 16 | } 17 | 18 | type MessenOptions = { 19 | dir: string; 20 | appstateFilePath: string 21 | debug?: boolean 22 | } 23 | 24 | 25 | const logger = getLogger('messen'); 26 | if (settings.ENVIRONMENT !== 'production') { 27 | logger.info('Logging initialized at debug level'); 28 | } 29 | 30 | const getAuth = async ( 31 | appstateFilePath: string, 32 | promptCredentialsFn: () => Promise, 33 | credentials?: facebook.Credentials, 34 | useCache?: boolean, 35 | ): Promise => { 36 | const useCredentials = () => { 37 | if (credentials) { 38 | return Promise.resolve(credentials); 39 | } 40 | return promptCredentialsFn(); 41 | }; 42 | 43 | if (!useCache) { 44 | return useCredentials(); 45 | } 46 | 47 | try { 48 | const appState = await helpers 49 | .loadAppState(appstateFilePath); 50 | logger.debug('Appstate loaded successfully'); 51 | return { appState }; 52 | } 53 | catch (e) { 54 | logger.debug('Appstate not found. Falling back to provided credentials'); 55 | return useCredentials(); 56 | } 57 | }; 58 | 59 | const DEFAULT_OPTIONS = { 60 | dir: settings.MESSEN_PATH, 61 | debug: false 62 | } 63 | 64 | export class Messen { 65 | api!: facebook.API; 66 | state: { 67 | authenticated: boolean; 68 | }; 69 | store!: { 70 | threads: ThreadStore, 71 | users: UserStore, 72 | } 73 | options: MessenOptions 74 | 75 | constructor(optionsRequest: MessenOptionsRequest = {}) { 76 | // correct any user-defined backslash 77 | if (optionsRequest.dir && optionsRequest.dir[optionsRequest.dir.length - 1] === '/') { 78 | optionsRequest.dir = optionsRequest.dir.slice(0, optionsRequest.dir.length - 1) 79 | } 80 | 81 | if (!optionsRequest.dir) { 82 | optionsRequest.dir = DEFAULT_OPTIONS.dir 83 | } 84 | 85 | this.options = { 86 | dir: optionsRequest.dir || DEFAULT_OPTIONS.dir, 87 | appstateFilePath: `${optionsRequest.dir}/tmp/appstate.json` 88 | } 89 | 90 | this.state = { 91 | authenticated: false, 92 | }; 93 | } 94 | 95 | getMfaCode(): Promise { 96 | return Promise.reject(Error('getMfaCode not implemented')); 97 | } 98 | 99 | promptCredentials(): Promise { 100 | return Promise.reject(Error('promptCredentials not implemented')); 101 | } 102 | 103 | async login( 104 | credentials?: facebook.Credentials, 105 | useCache: boolean = true, 106 | ): Promise { 107 | const apiConfig = { 108 | forceLogin: true, 109 | logLevel: this.options.debug ? 'info' : 'silent', 110 | selfListen: true, 111 | listenEvents: true, 112 | }; 113 | const authPayload = await getAuth(this.options.appstateFilePath, this.promptCredentials, credentials, useCache); 114 | this.api = await api.getApi(authPayload, apiConfig, this.getMfaCode); 115 | await helpers.saveAppState(this.api.getAppState(), this.options.appstateFilePath); 116 | logger.debug('App state saved'); 117 | this.state.authenticated = true; 118 | 119 | this.store = { 120 | threads: new ThreadStore(this.api), 121 | users: new UserStore(this.api) 122 | } 123 | 124 | await Promise.all([ 125 | this.store.threads.refresh(), 126 | this.store.users.refresh() 127 | ]); 128 | } 129 | 130 | onMessage(ev: facebook.MessageEvent): void | Error { 131 | return Error('onMessage not implemented'); 132 | } 133 | 134 | onThreadEvent(ev: facebook.EventEvent): void | Error { 135 | return Error('onThreadEvent not implemented'); 136 | } 137 | 138 | listen(): void { 139 | this.api.listenMqtt((err, ev) => { 140 | if (err) { 141 | return logger.info(err.error); 142 | } 143 | 144 | // inject thread data in to event 145 | return this.store.threads.getThread({ id: ev.threadID }).then(thread => { 146 | const messenEvent = Object.assign(ev, { 147 | thread 148 | }) 149 | 150 | switch (messenEvent.type) { 151 | case 'message': 152 | return this.onMessage(messenEvent); 153 | case 'event': 154 | return this.onThreadEvent(messenEvent); 155 | } 156 | }) 157 | }); 158 | } 159 | 160 | async logout(): Promise { 161 | await Promise.all([ 162 | this.api ? api.logout(this.api) : Promise.resolve(), 163 | helpers.clearAppState(this.options.appstateFilePath) 164 | ]) 165 | 166 | this.state.authenticated = false; 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /test/messen.mock.ts: -------------------------------------------------------------------------------- 1 | import facebook, { FacebookUser } from 'facebook-chat-api' 2 | import { ThreadStore } from '../src/store/threads' 3 | import { UserStore } from '../src/store/users' 4 | import { facebookFriendToUser } from '../src/util/transformers' 5 | 6 | const threads: Array = [ 7 | { 8 | threadID: '100003961877411', 9 | name: 'Tom Quirk', 10 | unreadCount: 2, 11 | messageCount: 2, 12 | imageSrc: null, 13 | emoji: null, 14 | color: null, 15 | nicknames: [], 16 | muteUntil: null, 17 | participants: [{ id: '100003961877411' }, { id: '100035969370185' }], // TODO(tom) there is actually full user objects here 18 | adminIDs: [], 19 | folder: 'INBOX', 20 | isGroup: false, 21 | customizationEnabled: true, 22 | participantAddMode: null, 23 | montageThread: null, 24 | reactionsMuteMode: 'REACTIONS_NOT_MUTED', 25 | mentionsMuteMode: 'MENTIONS_NOT_MUTED', 26 | isArchived: false, 27 | isSubscribed: true, 28 | timestamp: '1555626604953', 29 | snippet: 'hey man, wassup! 🏑', 30 | snippetAttachments: null, 31 | snippetSender: '100003961877411', 32 | lastMessageTimestamp: '1555626604953', 33 | lastReadTimestamp: null, 34 | cannotReplyReason: null, 35 | participantIDs: ['100003961877411', '100035969370185'], 36 | threadType: 1, 37 | } 38 | ] 39 | 40 | const friends: Array = [ 41 | { 42 | alternateName: '', 43 | firstName: 'Tom', 44 | gender: 'male_singular', 45 | userID: '100003961877411', 46 | isFriend: true, 47 | fullName: 'Tom Quirk', 48 | profilePicture: 49 | 'https://scontent.fbne3-1.fna.fbcdn.net/v/t1.0-1/p32x32/26001103_1029478707194182_5247344156421403634_n.jpg?_nc_cat=109&_nc_ht=scontent.fbne3-1.fna&oh=66e2c4298663c85b02af770dd6f4f09e&oe=5D46DF72', 50 | type: 'friend', 51 | profileUrl: 'https://www.facebook.com/tom.quirk.100', 52 | vanity: 'tom.quirk.100', 53 | isBirthday: false, 54 | }, 55 | { 56 | alternateName: '', 57 | firstName: 'Test', 58 | gender: 'male_singular', 59 | userID: '12345', 60 | isFriend: true, 61 | fullName: 'Test Friend', 62 | profilePicture: 63 | 'https://scontent.fbne3-1.fna.fbcdn.net/v/t1.0-1/p32x32/26001103_1029478707194182_5247344156421403634_n.jpg?_nc_cat=109&_nc_ht=scontent.fbne3-1.fna&oh=66e2c4298663c85b02af770dd6f4f09e&oe=5D46DF72', 64 | type: 'friend', 65 | profileUrl: 'https://www.facebook.com/tom.quirk.100', 66 | vanity: 'tom.quirk.100', 67 | isBirthday: false, 68 | } 69 | ]; 70 | 71 | const meUser: facebook.FacebookUser = { 72 | name: 'Tom Tester', 73 | firstName: 'Tom', 74 | vanity: 'tom.tester.9843499', 75 | thumbSrc: 76 | 'https://scontent.fbne3-1.fna.fbcdn.net/v/t1.0-1/p32x32/57485489_100153267860319_454875373524484096_n.jpg?_nc_cat=102&_nc_ht=scontent.fbne3-1.fna&oh=e9ef43da96de57e9e30d095b01cbdd01&oe=5D395DC7', 77 | profileUrl: 'https://www.facebook.com/tom.tester.9843499', 78 | gender: 2, 79 | type: 'user', 80 | isFriend: false, 81 | isBirthday: false, 82 | id: '100035969370185', 83 | }; 84 | 85 | const _users = friends.map(facebookFriendToUser).reduce((a: { [_: string]: facebook.FacebookUser }, b: facebook.FacebookUser) => { 86 | a[b.id] = b 87 | return a 88 | }, {}) 89 | 90 | const users: { [_: string]: facebook.FacebookUser } = { 91 | [meUser.id]: meUser, 92 | ..._users 93 | }; 94 | 95 | export function getApi(): facebook.API { 96 | return { 97 | listen(cb) { }, 98 | getCurrentUserID() { 99 | return meUser.id; 100 | }, 101 | getAppState() { 102 | return [] 103 | }, 104 | logout(cb) { 105 | return cb(undefined); 106 | }, 107 | getThreadInfo(threadId: string, cb) { 108 | return cb(undefined, threads.find(t => t.threadID === threadId)); 109 | }, 110 | getThreadList(limit, timestamp, tags, cb) { 111 | return cb(undefined, threads); 112 | }, 113 | getFriendsList(cb) { 114 | return cb(undefined, friends); 115 | }, 116 | getUserInfo(userId: string | Array, cb) { 117 | if (Array.isArray(userId)) { 118 | return cb(undefined, Object.keys(users).reduce((a: { [_: string]: FacebookUser }, id) => { 119 | a[id] = users[id] 120 | return a 121 | }, {})) 122 | } 123 | return cb(undefined, { [userId]: users[userId] }); 124 | }, 125 | }; 126 | } 127 | 128 | export function getThreadStore() { 129 | const threadStore = new ThreadStore(getApi()) 130 | threadStore.refresh() 131 | return threadStore; 132 | } 133 | 134 | export function getUserStore() { 135 | const userStore = new UserStore(getApi()) 136 | userStore.refresh() 137 | return userStore; 138 | } 139 | 140 | // TODO(tom) make a Messen interface 141 | export function getMessen(): any { 142 | class MockMesser { 143 | api: facebook.API; 144 | state: { 145 | authenticated: boolean; 146 | }; 147 | store: { 148 | users: UserStore; 149 | threads: ThreadStore 150 | } 151 | options: any; 152 | constructor() { 153 | this.api = getApi(); 154 | this.state = { 155 | authenticated: false 156 | }; 157 | 158 | this.store = { 159 | users: new UserStore(this.api), 160 | threads: new ThreadStore(this.api) 161 | } 162 | } 163 | 164 | async login() { 165 | this.state.authenticated = true 166 | 167 | return await Promise.all([ 168 | this.store.users.refresh(), 169 | this.store.threads.refresh() 170 | ]) 171 | } 172 | } 173 | 174 | return new MockMesser() 175 | } --------------------------------------------------------------------------------