├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── old ├── ama.js ├── quiet.js ├── sentiment.js ├── slow.js └── tweet.js ├── package.json ├── resources ├── logo.png ├── logo.sketch ├── prepublish.sh ├── publish_actions.png └── user_managed_groups.png └── src ├── ChangeEmitter.js ├── FBClient.js ├── cli.js ├── db.js ├── log.js ├── record.js ├── refresher.js └── scripts ├── close.js └── delete.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | log 3 | logs 4 | *.log 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # Compiled binary addons (http://nodejs.org/api/addons.html) 21 | build/Release 22 | 23 | # Dependency directory 24 | # Commenting this out is preferred by some people, see 25 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 26 | node_modules 27 | 28 | # Users Environment Variables 29 | .lock-wscript 30 | 31 | # Docs 32 | !doc/screenshots/* 33 | doc 34 | 35 | # Hackbot 36 | hackbot.db 37 | record 38 | dist 39 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | log 3 | logs 4 | *.log 5 | npm-debug.log* 6 | 7 | # Runtime data 8 | pids 9 | *.pid 10 | *.seed 11 | 12 | # Directory for instrumented libs generated by jscoverage/JSCover 13 | lib-cov 14 | 15 | # Coverage directory used by tools like istanbul 16 | coverage 17 | 18 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 19 | .grunt 20 | 21 | # node-waf configuration 22 | .lock-wscript 23 | 24 | # Compiled binary addons (http://nodejs.org/api/addons.html) 25 | build/Release 26 | 27 | # Dependency directory 28 | # https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git 29 | node_modules 30 | 31 | # OSX 32 | .DS_Store 33 | .DS_Store? 34 | ._* 35 | .Spotlight-V100 36 | .Trashes 37 | ehthumbs.db 38 | Thumbs.db 39 | .AppleDouble 40 | .LSOverride 41 | Icon 42 | 43 | # Java 44 | *.class 45 | 46 | # Hackbot 47 | src 48 | old 49 | resources 50 | record 51 | hackbot.db 52 | !reosurces/prepublish.sh 53 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015, Alex Kern 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | 8 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 9 | 10 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 11 | 12 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | hackbot 2 | 3 | [![npm shield](https://img.shields.io/npm/v/hackbot.svg)](https://www.npmjs.com/package/hackbot) [![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat)](http://standardjs.com/) 4 | 5 | *Hackbot adds features to Facebook Groups through automation.* 6 | 7 | An instance of Hackbot is running on [Hackathon Hackers](https://facebook.com/groups/hackathonhackers). 8 | 9 | ## Installation 10 | 11 | ### 1. Install Hackbot 12 | 13 | $ npm install -g hackbot 14 | 15 | ### 2. Get your long-lived Facebook access token 16 | 17 | *Note:* This process is annoying. Please consider [implementing this through a web-based OAuth flow][oauth-issue]. 18 | 19 | You'll need to set a few configuration options before using Hackbot: your Facebook Group ID, the refresh rate in milliseconds (5s is a good number), and the IDs of the group's moderators to the configuration file. 20 | 21 | To generate an access token, open up the [Facebook Graph API Explorer][explorer] and make sure you're using a custom application. Click "Get Access Token" and make sure the `user_managed_groups` and `publish_actions` permissions are ticked. 22 | 23 | user_managed_groups permission 24 | 25 | publish_actions permission 26 | 27 | Click the blue "Get Access Token" in the modal. Copy the short-lived access token and navigate in your browser to the following URL: 28 | 29 | https://graph.facebook.com/oauth/access_token? 30 | client_id=APP_ID& 31 | client_secret=APP_SECRET& 32 | grant_type=fb_exchange_token& 33 | fb_exchange_token=SHORT_LIVED_ACCESS_TOKEN 34 | 35 | Replace `APP_ID`, `APP_SECRET`, and `SHORT_LIVED_ACCESS_TOKEN` with the proper values. Take the long-lived (60 day) access token in the body and save it somewhere for safe-keeping. You'll need it when you run Hackbot below. 36 | 37 | [explorer]: https://developers.facebook.com/tools/explorer/ 38 | [oauth-issue]: https://github.com/kern/hackbot/issues/6 39 | 40 | ### 3. Collect the Graph IDs of your group's moderators 41 | 42 | You can use the [Graph API Explorer][explorer] to find the numeric Graph API IDs of your group's moderators. 43 | 44 | Keep in mind that Facebook's user IDs are unique to each application, so you'll have to get creative. Try digging through your friends list at `/me/friends?limit=1000`. 45 | 46 | [explorer]: https://developers.facebook.com/tools/explorer/ 47 | 48 | ## Usage 49 | 50 | There will be *much* better usage documentation coming soon, but here's how it works: 51 | 52 | $ hackbot GROUP_ID ACCESS_TOKEN -m MOD_ID1,MOD_ID2,MOD_ID3 -s close,delete --interval 5 53 | 54 | ## Development 55 | 56 | Hackbot uses [JavaScript Standard Style](https://github.com/feross/standard) and [Babel](https://babeljs.io/) for ES6+ support. 57 | 58 | $ git clone git@github.com:kern/hackbot.git 59 | $ npm install 60 | $ npm run dev -- [see usage above] 61 | 62 | Lint before committing: 63 | 64 | $ npm run lint 65 | 66 | ## License & Acknowledgements 67 | 68 | Hackbot is released under the [BSD 3-Clause][license] license. The initial prototype was made with caffeine at [MHacks V][mhacks] by [Alex Kern][kern-twitter] and [Eva Zheng][eva-twitter]. 69 | 70 | [license]: https://github.com/kern/hackbot/blob/master/LICENSE 71 | [mhacks]: http://mhacks.org 72 | [kern-twitter]: https://twitter.com/KernCanCode 73 | [eva-twitter]: https://twitter.com/evadoraz 74 | -------------------------------------------------------------------------------- /old/ama.js: -------------------------------------------------------------------------------- 1 | /** @module */ 2 | 3 | var log = require('../log') 4 | 5 | var AMA_COMMANDS = ['ama', 'only'] 6 | 7 | /** 8 | * Allows moderators to lock the thread so only the tagged users may post. 9 | * Useful for AMA answer threads. 10 | * @implements module:feed~Filter 11 | */ 12 | module.exports = function(group, thread) { 13 | 14 | var amaIDs = [] 15 | var promises = [] 16 | var amaThread = false 17 | 18 | for (var post of thread) { 19 | var isMod = group.isMod(post.from) 20 | var canPost = isMod || amaIDs.indexOf(post.from) !== -1 21 | 22 | if (isMod && post.hasCommand(AMA_COMMANDS)) { 23 | amaThread = true 24 | for (var tag of post.tags) { 25 | amaIDs.push(tag.id) 26 | } 27 | } else if (amaThread && !canPost) { 28 | (function (post) { 29 | var p = group.client.del(post.id).then(function () { 30 | log.info({ post: post }, '/ama deleted post with ID: %s', post.id) 31 | }) 32 | 33 | promises.push(p) 34 | })(post) 35 | } 36 | } 37 | 38 | return Promise.all(promises) 39 | 40 | } 41 | -------------------------------------------------------------------------------- /old/quiet.js: -------------------------------------------------------------------------------- 1 | /** @module */ 2 | 3 | var log = require('../log') 4 | 5 | /** 6 | * Allows moderators to quiet-ban members by automatically deleting any post 7 | * that the member makes. 8 | * @implements module:feed~Filter 9 | */ 10 | module.exports = function(group, thread) { 11 | 12 | var promises = [] 13 | 14 | for (var post of thread) { 15 | if (group.isQuieted(post.from, post.created)) { 16 | (function (post) { 17 | var p = group.client.del(post.id).then(function () { 18 | log.info({ post: post }, '/quiet deleted post with ID: %s', post.id) 19 | }) 20 | 21 | promises.push(p) 22 | })(post) 23 | } 24 | } 25 | 26 | return Promise.all(promises) 27 | 28 | } 29 | -------------------------------------------------------------------------------- /old/sentiment.js: -------------------------------------------------------------------------------- 1 | /** @module */ 2 | 3 | var db = require('../db') 4 | var log = require('../log') 5 | var request = require('request') 6 | var sentiment = require('sentiment') 7 | 8 | var METAMIND_URL = 'https://www.metamind.io/language/classify' 9 | var NEG_THRESHOLD = -0.8 10 | var ADMIN_GROUP = '1461229807499165' 11 | 12 | var checked = {} 13 | var reportedRef = db.child('reported') 14 | var reported = {} 15 | 16 | reportedRef.on('value', function (snapshot) { 17 | reported = snapshot.val() || {} 18 | }) 19 | 20 | // An unfortunate hack due to the limitations of the MetaMind free tier, which 21 | // appears to be the only tier available at the moment. 22 | var lastPromise = Promise.resolve() 23 | 24 | function getSentiment(post) { 25 | if (process.env.METAMIND_KEY) { 26 | 27 | var apiKey = process.env.METAMIND_KEY 28 | var options = { 29 | method: 'POST', 30 | headers: { 31 | Authorization: 'Basic ' + apiKey 32 | }, 33 | json: { 34 | classifier_id: 155, 35 | value: post.message 36 | } 37 | } 38 | 39 | lastPromise = lastPromise.then(function () { 40 | return new Promise(function (resolve, reject) { 41 | request(METAMIND_URL, options, function (err, res, body) { 42 | if (err) return reject(err) 43 | 44 | if (res.statusCode == 200) { 45 | for (var c of body.predictions) { 46 | if (c.class_id === 1) { 47 | return resolve(-c.prob) 48 | } 49 | } 50 | } 51 | 52 | reject(res) 53 | }) 54 | }) 55 | }) 56 | 57 | return lastPromise 58 | 59 | } else { 60 | 61 | var s = sentiment(post.message) 62 | return Promise.resolve(s.comparative) 63 | 64 | } 65 | } 66 | 67 | module.exports = function(group, thread) { 68 | 69 | var promises = [] 70 | 71 | for (var post of thread) { 72 | var checksum = post.getChecksum() 73 | 74 | if (checksum in checked || 75 | post.id in reported || 76 | post.message == null) continue 77 | 78 | checked[checksum] = true 79 | 80 | !(function (post, checksum) { 81 | 82 | var p = getSentiment(post).then(function (s) { 83 | 84 | if (s < NEG_THRESHOLD) { 85 | 86 | reportedRef.child(post.id).set(new Date().getTime()) 87 | 88 | var params = { 89 | name: '⚠ ' + post.fromName + ' // ' + post.id, 90 | description: '(Score: ' + s + ') ' + post.message, 91 | link: 'http://facebook.com/' + post.id 92 | } 93 | 94 | return group.client.post(ADMIN_GROUP + '/feed', params).then(function () { 95 | log.info({ post: post }, 'reported post with ID: %s (Score: %s)', post.id, s) 96 | }) 97 | 98 | } 99 | 100 | }, function (err) { 101 | log.error(err, 'error classifying post with ID: %s. Retrying...', post.id) 102 | delete checked[checksum] 103 | }) 104 | 105 | promises.push(p) 106 | 107 | })(post, checksum) 108 | } 109 | 110 | return Promise.all(promises) 111 | 112 | } 113 | -------------------------------------------------------------------------------- /old/slow.js: -------------------------------------------------------------------------------- 1 | /** @module */ 2 | 3 | var Promise = require('es6-promise').Promise 4 | var log = require('../log') 5 | var moment = require('moment') 6 | 7 | var RATE_LIMIT_SECONDS = 60 8 | 9 | /** 10 | * Slows the thread, only allowing one post per person per 60 seconds. 11 | * @implements module:feed~Filter 12 | */ 13 | module.exports = function(group, thread) { 14 | 15 | var promises = [] 16 | var slowedThread = false 17 | var posters = {} 18 | 19 | for (var post of thread) { 20 | var isMod = group.isMod(post.from) 21 | 22 | if (isMod && post.hasCommand('slow')) { 23 | slowedThread = true 24 | } else if (slowedThread && !isMod) { 25 | 26 | var created = moment(post.created) 27 | 28 | if (post.from in posters) { 29 | var diff = moment.duration(created.diff(posters[post.from])) 30 | if (diff.asSeconds() < RATE_LIMIT_SECONDS) { 31 | (function (post, diff) { 32 | var p = group.client.del(post.id).then(function () { 33 | log.info({ 34 | post: post, 35 | diff: diff.asSeconds() 36 | }, '/slow deleted post with ID: %s', post.id) 37 | }) 38 | 39 | promises.push(p) 40 | })(post, diff) 41 | } 42 | } 43 | 44 | posters[post.from] = created 45 | 46 | } 47 | } 48 | 49 | return Promise.all(promises) 50 | 51 | } 52 | -------------------------------------------------------------------------------- /old/tweet.js: -------------------------------------------------------------------------------- 1 | /** @module */ 2 | 3 | var _ = require('lodash') 4 | var db = require('../db') 5 | var log = require('../log') 6 | var Twit = require('twit') 7 | 8 | var TCO_LENGTH = 23 9 | var MAX_TWEET_LENGTH = 140 - TCO_LENGTH - 1 10 | 11 | var twitter = null 12 | db.child('twitter_credentials').on('value', function (snapshot) { 13 | twitter = new Twit(snapshot.val()) 14 | }) 15 | 16 | /** 17 | * Allows moderators to tweet a link to the thread. 18 | * @implements module:feed~Filter 19 | */ 20 | module.exports = function(group, thread) { 21 | 22 | var tweetedRef = db.child('tweeted') 23 | 24 | var tweetPosts = _.filter(thread, function (post) { 25 | return post.hasCommand('tweet') && 26 | post.getArgs() !== '' && 27 | group.isMod(post.from) && 28 | !group.hasTweeted(post) 29 | }) 30 | 31 | return Promise.all(_.map(tweetPosts, function (post) { 32 | 33 | tweetedRef.child(post.id).set(new Date().getTime()) 34 | var postText = post.getArgs().substr(0, MAX_TWEET_LENGTH).trim() 35 | var postURL = 'fb.com/' + post.id 36 | var params = { status: postText + ' ' + postURL } 37 | 38 | return new Promise(function (resolve, reject) { 39 | twitter.post('statuses/update', params, function (err, data, response) { 40 | if (err) return reject(err) 41 | resolve(data) 42 | }) 43 | }) 44 | 45 | })) 46 | 47 | } 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hackbot", 3 | "version": "0.1.3", 4 | "description": "Hackbot adds moderative features to Facebook Groups through automation.", 5 | "preferGlobal": true, 6 | "bin": "dist/cli.js", 7 | "scripts": { 8 | "start": "babel-node src/cli.js", 9 | "dev": "nodemon --exec babel-node -- src/cli.js", 10 | "build": "babel src --ignore __tests__ --out-dir dist", 11 | "lint": "standard", 12 | "prepublish": "./resources/prepublish.sh" 13 | }, 14 | "contributors": [ 15 | "Alex Kern ", 16 | "Eva Zheng ", 17 | "JB Rubinovitz ", 18 | "Rodney Folz " 19 | ], 20 | "license": "BSD-3-Clause", 21 | "dependencies": { 22 | "debug": "^2.2.0", 23 | "express": "^4.11.1", 24 | "jsdoc": "^3.3.0-beta2", 25 | "lodash": "^3.10.1", 26 | "moment": "^2.10.6", 27 | "nedb": "^1.2.0", 28 | "qs": "^2.3.3", 29 | "request": "^2.53.0", 30 | "standard": "^5.1.1" 31 | }, 32 | "repository": { 33 | "type": "git", 34 | "url": "https://github.com/kern/hackbot.git" 35 | }, 36 | "engines": { 37 | "node": "5.1.x" 38 | }, 39 | "babel": { 40 | "presets": [ 41 | "es2015" 42 | ] 43 | }, 44 | "standard": { 45 | "ignore": [ 46 | "dist", 47 | "old" 48 | ] 49 | }, 50 | "devDependencies": { 51 | "babel-cli": "^6.3.17", 52 | "babel-preset-es2015": "^6.3.13" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /resources/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kern/hackbot/a6c6242cc3d8e618c9e5b9a21405f8c78344aaa9/resources/logo.png -------------------------------------------------------------------------------- /resources/logo.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kern/hackbot/a6c6242cc3d8e618c9e5b9a21405f8c78344aaa9/resources/logo.sketch -------------------------------------------------------------------------------- /resources/prepublish.sh: -------------------------------------------------------------------------------- 1 | # Based on https://github.com/graphql/graphql-js/blob/master/resources/prepublish.sh 2 | 3 | # Because of a long-running npm issue (https://github.com/npm/npm/issues/3059) 4 | # prepublish runs after `npm install` and `npm pack`. 5 | # In order to only run prepublish before `npm publish`, we have to check argv. 6 | if node -e "process.exit(($npm_config_argv).original[0].indexOf('pu') === 0)"; then 7 | exit 0; 8 | fi 9 | 10 | ./node_modules/.bin/babel src --ignore __tests__ --out-dir dist; 11 | -------------------------------------------------------------------------------- /resources/publish_actions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kern/hackbot/a6c6242cc3d8e618c9e5b9a21405f8c78344aaa9/resources/publish_actions.png -------------------------------------------------------------------------------- /resources/user_managed_groups.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kern/hackbot/a6c6242cc3d8e618c9e5b9a21405f8c78344aaa9/resources/user_managed_groups.png -------------------------------------------------------------------------------- /src/ChangeEmitter.js: -------------------------------------------------------------------------------- 1 | import * as log from './log' 2 | import DB from './db' 3 | import FBClient from './FBClient' 4 | import _ from 'lodash' 5 | import crypto from 'crypto' 6 | import refresher from './refresher' 7 | import { EventEmitter } from 'events' 8 | 9 | /** 10 | * The query parameters used to poll a group. 11 | * @constant {Object} 12 | */ 13 | const REFRESH_PARAMS = { 14 | 15 | fields: [ 16 | 'from', 'message', 'type', 17 | 'created_time', 'place', 'message_tags', 18 | 'link', 'caption', 'description', 'picture', 19 | 'comments.limit(1000){' + [ 20 | 'from', 'message', 'created_time', 'message_tags', 21 | 'comments.limit(1000){' + [ 22 | 'from', 'message', 'created_time', 'message_tags' 23 | ].join() + '}' 24 | ].join() + '}' 25 | ].join(), 26 | 27 | limit: 10 28 | 29 | } 30 | 31 | function versionHash (post) { 32 | var s = post.id + '|' + post.from + '|' + (post.message || '') 33 | return crypto.createHash('sha1').update(s).digest('hex') 34 | } 35 | 36 | function messageCommand (message) { 37 | if (message == null) { return null } 38 | message = message.trim() 39 | if (message.indexOf('/') === 0) { 40 | return message.split(/\s/g)[0].substring(1) 41 | } else { 42 | return null 43 | } 44 | } 45 | 46 | function normalizeResponse (raw, group) { 47 | 48 | function normalizePost (raw) { 49 | 50 | let post = { 51 | id: raw.id, 52 | level: 0, 53 | group: group, 54 | parent: group, 55 | from: raw.from == null ? '' : raw.from.id, 56 | fromName: raw.from == null ? '' : raw.from.name, 57 | type: raw.type || null, 58 | message: raw.message || '', 59 | created: new Date(raw.created_time), 60 | place: raw.place || null, 61 | tags: raw.message_tags || [], 62 | link: raw.link == null ? null : { 63 | url: raw.link, 64 | caption: raw.caption, 65 | description: raw.description, 66 | picture: raw.picture 67 | }, 68 | comments: [], 69 | isMod: group.mods.indexOf(raw.from == null ? '' : raw.from.id) !== -1, 70 | command: messageCommand(raw.message) 71 | } 72 | 73 | post.version = versionHash(post) 74 | 75 | // Recurse over the post's comments 76 | if (raw.comments != null) { 77 | post.comments = raw.comments.data.map(raw => { 78 | let c = normalizePost(raw) 79 | c.type = 'comment' 80 | c.parent = post 81 | c.level = post.level + 1 82 | return c 83 | }) 84 | } 85 | 86 | return post 87 | 88 | } 89 | 90 | return raw.data.map(normalizePost) 91 | 92 | } 93 | 94 | function walkThread (thread, fn) { 95 | 96 | function walk (post) { 97 | let s = [fn(post)] 98 | let r = (post.comments || []).map(walk) 99 | return s.concat(...r) 100 | } 101 | 102 | return walk(thread) 103 | 104 | } 105 | 106 | export default class ChangeEmitter extends EventEmitter { 107 | 108 | constructor (opts) { 109 | 110 | super() 111 | 112 | this.db = new DB(opts.dbFilename) 113 | this.client = new FBClient(opts.accessToken) 114 | this.feedPath = opts.groupID + '/feed' 115 | this.group = { 116 | id: opts.groupID, 117 | type: 'group', 118 | mods: opts.mods 119 | } 120 | 121 | this.stop = refresher(opts.interval, () => { 122 | return this.client.get(this.feedPath, REFRESH_PARAMS).then(data => { 123 | 124 | const threads = normalizeResponse(data, this.group) 125 | return Promise.all(_.flatten(threads.map(thread => { 126 | return walkThread(thread, post => { 127 | return this.db.touchVersion(post).then(change => { 128 | if (change) { 129 | const state = this.db.threadStateObj(post) 130 | return [change, post, state] 131 | } else { 132 | return null 133 | } 134 | }) 135 | }) 136 | }))).then(p => { 137 | for (let e of _.compact(p)) { 138 | this.emit(...e) 139 | } 140 | }) 141 | 142 | }) 143 | }, log.error) 144 | 145 | } 146 | 147 | } 148 | -------------------------------------------------------------------------------- /src/FBClient.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A thin wrapper around Facebook's Graph API that uses ES6 Promises. 3 | * @module 4 | */ 5 | 6 | import Qs from 'qs' 7 | import request from 'request' 8 | 9 | /** 10 | * The base URL for all Graph API requests. 11 | * @constant {string} 12 | */ 13 | const BASE_URL = 'https://graph.facebook.com/v2.3/' 14 | 15 | /** 16 | * Whether or not dangerous Graph API requests will actually be 17 | * executed. If true, POST, PUT, and DELETE will be noops. 18 | * @constant {boolean} 19 | */ 20 | const SIMULATE = false 21 | 22 | /** 23 | * Returns the Graph API URL for a query. 24 | * @param {string} path 25 | * @param {Object=} params 26 | * @return {string} 27 | */ 28 | function urlFor (path, params) { 29 | const queryString = Qs.stringify(params || {}) 30 | return BASE_URL + path + '?' + queryString 31 | } 32 | 33 | /** 34 | * Makes a request to the Graph API. 35 | * @param {string} accessToken 36 | * @param {string} method 37 | * @param {string} path 38 | * @param {Object=} params 39 | * @return {Promise} 40 | */ 41 | function graphRequest (accessToken, method, path, params = {}) { 42 | params = Object.assign({}, params, { access_token: accessToken }) 43 | 44 | return new Promise((resolve, reject) => { 45 | request({ 46 | method: method, 47 | uri: urlFor(path, params), 48 | json: true 49 | }, (err, res, body) => { 50 | if (err) { return reject(err) } 51 | if (body.error) { return reject(new Error(body.error.message)) } 52 | resolve(body) 53 | }) 54 | }) 55 | } 56 | 57 | export default class FBClient { 58 | 59 | /** 60 | * A Facebook Graph API client. 61 | * @class 62 | * @param {string} accessToken 63 | */ 64 | constructor (accessToken) { 65 | this.accessToken = accessToken 66 | } 67 | 68 | /** 69 | * Makes a GET request to the Graph API. 70 | * @param {string} path 71 | * @param {Object=} params 72 | * @return {Promise} 73 | */ 74 | get (id, params) { 75 | return graphRequest(this.accessToken, 'GET', id, params) 76 | } 77 | 78 | /** 79 | * Makes a PUT request to the Graph API. 80 | * @param {string} path 81 | * @param {Object=} params 82 | * @return {Promise} 83 | */ 84 | put (id, params) { 85 | if (SIMULATE) { return this.noop() } 86 | return graphRequest(this.accessToken, 'PUT', id, params) 87 | } 88 | 89 | /** 90 | * Makes a DELETE request to the Graph API. 91 | * @param {string} path 92 | * @param {Object=} params 93 | * @return {Promise} 94 | */ 95 | del (id, params) { 96 | if (SIMULATE) { return this.noop() } 97 | return graphRequest(this.accessToken, 'DELETE', id, params).catch(x => x) 98 | } 99 | 100 | /** 101 | * Makes a POST request to the Graph API. 102 | * @param {string} path 103 | * @param {Object=} params 104 | * @return {Promise} 105 | */ 106 | post (id, params) { 107 | if (SIMULATE) { return this.noop() } 108 | return graphRequest(this.accessToken, 'POST', id, params) 109 | } 110 | 111 | /** 112 | * Returns a promise that does nothing and resolves to an empty 113 | * object. Useful for testing. 114 | * @return {Promise} 115 | */ 116 | noop () { 117 | return Promise.resolve({}) 118 | } 119 | 120 | } 121 | -------------------------------------------------------------------------------- /src/cli.js: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | 3 | import log from './log' 4 | import ChangeEmitter from './ChangeEmitter' 5 | import record from './record' 6 | 7 | process.on('uncaughtException', ex => { 8 | console.error(ex) 9 | console.log('Exiting due to uncaught exception...') 10 | process.exit(1) 11 | }) 12 | 13 | process.on('unhandledRejection', (reason, p) => { 14 | p.catch(err => { 15 | console.error(err.stack) 16 | console.log('Exiting due to unhandled rejection...') 17 | process.exit(1) 18 | }) 19 | }) 20 | 21 | process.on('SIGINT', () => { 22 | console.log('Exiting...') 23 | process.exit(0) 24 | }) 25 | 26 | class CLIArgs { 27 | 28 | constructor (args) { 29 | this.obj = {} 30 | let nextPos = 0 31 | let flag = null 32 | for (let a of args) { 33 | if (a.indexOf('-') === 0) { 34 | this.obj[a] = this.obj[a] || [] 35 | flag = a 36 | } else if (flag == null) { 37 | this.obj[nextPos] = (this.obj[nextPos] || []).concat([a]) 38 | nextPos++ 39 | } else { 40 | this.obj[flag] = this.obj[flag].concat([a]) 41 | flag = null 42 | } 43 | } 44 | } 45 | 46 | asRaw (...flags) { 47 | const allArgs = flags.map(f => this.obj[f.toString()] || []) 48 | return [].concat(...allArgs) 49 | } 50 | 51 | asString (...flags) { 52 | return this.asRaw(...flags).join('') 53 | } 54 | 55 | asInteger (...flags) { 56 | const ints = this.asRaw(...flags) 57 | if (ints.length === 0) { 58 | return Number.NaN 59 | } else { 60 | return parseInt(ints[0], 10) 61 | } 62 | } 63 | 64 | asStringArray (...flags) { 65 | const res = this.asRaw(...flags).join(',').split(',') 66 | return (res[0] === '' ? [] : res) 67 | } 68 | 69 | } 70 | 71 | export default class CLI { 72 | 73 | constructor (args) { 74 | 75 | if (args.length === 0 || 76 | args.indexOf('-h') !== -1 || 77 | args.indexOf('--help') !== -1) { 78 | 79 | this.mode = this.help 80 | 81 | } else { 82 | 83 | this.mode = this.watch 84 | this.opts = { 85 | interval: 5000, 86 | accessToken: null, 87 | groupID: null, 88 | mods: [], 89 | scripts: [], 90 | dbFilename: 'hackbot.db', 91 | recordDir: 'record' 92 | } 93 | 94 | this.processArgs(args) 95 | } 96 | } 97 | 98 | processArgs (args) { 99 | 100 | const cliArgs = new CLIArgs(args) 101 | 102 | this.opts.groupID = cliArgs.asString(0) 103 | if (this.opts.groupID === '') { 104 | log.error('you must provide a Facebook Group ID') 105 | process.exit(1) 106 | } 107 | 108 | this.opts.accessToken = cliArgs.asString(1) 109 | if (this.opts.accessToken === '') { 110 | log.error('you must provide a Facebook access token') 111 | process.exit(1) 112 | } 113 | 114 | const i = cliArgs.asInteger('-i', '--interval') 115 | if (!isNaN(i) && i > 0) { this.opts.interval = i } 116 | 117 | this.opts.mods = cliArgs.asStringArray('-m', '--mod', '--mods') 118 | if (this.opts.mods.length === 0) { 119 | log.error('you must provide at least one moderator ID using -m') 120 | process.exit(1) 121 | } 122 | 123 | this.opts.scripts = cliArgs.asStringArray('-s', '--script', '--scripts') 124 | if (this.opts.scripts.length === 0) { 125 | log.error('you must provide at least one script using -s') 126 | process.exit(1) 127 | } 128 | 129 | const d = cliArgs.asString('-d', '--database') 130 | if (d !== '') { this.opts.dbFilename = d } 131 | 132 | const r = cliArgs.asString('-r', '--record') 133 | if (r !== '') { this.opts.recordDir = r } 134 | 135 | } 136 | 137 | run () { 138 | this.mode.apply(this) 139 | } 140 | 141 | help () { 142 | log([ 143 | 'usage: hackbot [...]' 144 | ].join('\n')) 145 | } 146 | 147 | watch () { 148 | const emitter = new ChangeEmitter(this.opts) 149 | log('started with database', 150 | { filename: this.opts.dbFilename }) 151 | 152 | for (let script of this.opts.scripts) { 153 | try { 154 | log('loading script', { name: script }) 155 | const { attach } = require(`./scripts/${script}`) 156 | attach(emitter, record(this.opts.recordDir)) 157 | } catch (ex) { 158 | log.error('no such script', { name: script }) 159 | process.exit(1) 160 | } 161 | } 162 | } 163 | 164 | } 165 | 166 | const cli = new CLI(process.argv.slice(2)) 167 | cli.run() 168 | -------------------------------------------------------------------------------- /src/db.js: -------------------------------------------------------------------------------- 1 | import NeDB from 'nedb' 2 | import * as log from './log' 3 | 4 | function threadID (post) { 5 | while (post.level > 0) { 6 | post = post.parent 7 | } 8 | return post.id 9 | } 10 | 11 | export default class DB { 12 | 13 | constructor (filename) { 14 | this.db = new NeDB({ 15 | filename: filename, 16 | autoload: true 17 | }) 18 | } 19 | 20 | touchVersion (post) { 21 | return new Promise((resolve, reject) => { 22 | let query = { graphID: post.id } 23 | 24 | this.db.findOne(query, (err, doc) => { 25 | if (err) { 26 | reject(err) 27 | } else if (doc != null && post.version === doc.version) { 28 | resolve(false) 29 | } else { 30 | this.db.update(query, 31 | { graphID: post.id, version: post.version }, 32 | { upsert: true }, (err, _, newDoc) => { 33 | if (err) { 34 | reject(err) 35 | } else { 36 | resolve(newDoc == null ? 'edit' : 'new') 37 | } 38 | }) 39 | } 40 | }) 41 | }) 42 | } 43 | 44 | threadStateObj (post) { 45 | const thread = threadID(post) 46 | return { 47 | get: () => { return this.getThreadState(thread) }, 48 | set: (s) => { return this.setThreadState(thread, s) }, 49 | update: (s) => { return this.updateThreadState(thread, s) } 50 | } 51 | } 52 | 53 | getThreadState (thread) { 54 | return new Promise((resolve, reject) => { 55 | this.db.findOne({ thread: thread }, (err, doc) => { 56 | if (err) { 57 | reject(err) 58 | } else if (doc == null) { 59 | log.debug('get thread state', { thread, state: {} }) 60 | resolve({}) 61 | } else { 62 | log.debug('get thread state', { thread, state: doc.state }) 63 | resolve(doc.state) 64 | } 65 | }) 66 | }) 67 | } 68 | 69 | setThreadState (thread, state) { 70 | return new Promise((resolve, reject) => { 71 | this.db.update({ thread: thread }, 72 | { thread: thread, state: state }, 73 | { upsert: true }, (err, _, newDoc) => { 74 | if (err) { 75 | reject(err) 76 | } else { 77 | log.debug('set thread state', thread, state) 78 | resolve() 79 | } 80 | }) 81 | }) 82 | } 83 | 84 | updateThreadState (thread, updates) { 85 | return this.getThreadState(thread).then(s => { 86 | const newState = Object.assign({}, s, updates) 87 | return this.setThreadState(thread, newState) 88 | }) 89 | } 90 | 91 | } 92 | -------------------------------------------------------------------------------- /src/log.js: -------------------------------------------------------------------------------- 1 | import debug from 'debug' 2 | 3 | debug.enable('hackbot') 4 | const log = debug('hackbot') 5 | 6 | debug.enable('hackbot:error') 7 | log.error = debug('hackbot:error') 8 | 9 | // debug.enable('hackbot:debug') 10 | log.debug = debug('hackbot:debug') 11 | 12 | module.exports = log 13 | -------------------------------------------------------------------------------- /src/record.js: -------------------------------------------------------------------------------- 1 | import childProcess from 'child_process' 2 | import fs from 'fs' 3 | import log from './log' 4 | import moment from 'moment' 5 | import path from 'path' 6 | 7 | function mkdirP (path) { 8 | return new Promise((resolve, reject) => { 9 | childProcess.exec(`mkdir -p ${path}`, (err, stdout, stderr) => { 10 | if (err) { 11 | reject(err) 12 | } else { 13 | resolve() 14 | } 15 | }) 16 | }) 17 | } 18 | 19 | function echo (path, str) { 20 | return new Promise((resolve, reject) => { 21 | fs.writeFile(path, str, err => { 22 | if (err) { 23 | reject(err) 24 | } else { 25 | resolve() 26 | } 27 | }) 28 | }) 29 | } 30 | 31 | function addCommitPush (recordDir, filename) { 32 | return new Promise((resolve, reject) => { 33 | childProcess.exec(`cd ${recordDir} && git add ${filename} && git commit -m '${filename}' && git push origin master`, (err, stdout, stderr) => { 34 | if (err) { 35 | reject(err) 36 | } else { 37 | resolve() 38 | } 39 | }) 40 | }) 41 | } 42 | 43 | function dirExists (filename) { 44 | return new Promise((resolve, reject) => { 45 | fs.stat(filename, (err, stat) => { 46 | if (err == null) { 47 | resolve(true) 48 | } else if (err.code === 'ENOENT') { 49 | resolve(false) 50 | } else { 51 | reject(err) 52 | } 53 | }) 54 | }) 55 | } 56 | 57 | module.exports = function (recordDir) { 58 | return function (type, data, occurred = moment()) { 59 | const dateStr = occurred.format('YYYY-MM-DD-hh-mm-ss') 60 | const filename = `${dateStr}-${type}.json` 61 | const fullPath = path.resolve(recordDir, filename) 62 | log(`recording '${type}' action to ${fullPath}`) 63 | 64 | return mkdirP(recordDir).then(() => { 65 | return echo(fullPath, JSON.stringify(data, function (k, v) { 66 | if (k === 'parent' || k === 'group') { 67 | return v.id 68 | } else if (k === 'fromName' && typeof this === 'object' && !this.isMod) { 69 | return 'Anonymous' 70 | } else { 71 | return v 72 | } 73 | }, 2)) 74 | }).then(() => { 75 | return dirExists(path.resolve(recordDir, '.git')) 76 | }).then(isGit => { 77 | if (isGit) { 78 | return addCommitPush(recordDir, filename) 79 | } 80 | }) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/refresher.js: -------------------------------------------------------------------------------- 1 | /** @module */ 2 | 3 | /** 4 | * Creates a refresher that calls a function periodically as long as the 5 | * previous call has finished. The function is called immediately after the 6 | * refresher is created. You can cancel a refresher using `clearInterval`. 7 | */ 8 | export default function (interval, fn, onError) { 9 | 10 | onError = onError ? onError : () => {} 11 | 12 | let ready = true 13 | const refresh = () => { 14 | 15 | if (!ready) { return } 16 | ready = false 17 | 18 | try { 19 | 20 | const p = fn() 21 | if (p == null) { 22 | ready = true 23 | } else { 24 | p.then(() => { 25 | ready = true 26 | }).catch(err => { 27 | onError(err) 28 | ready = true 29 | }) 30 | } 31 | 32 | } catch (err) { 33 | onError(err) 34 | ready = true 35 | } 36 | 37 | } 38 | 39 | refresh() 40 | return setInterval(() => refresh(), interval) 41 | 42 | } 43 | -------------------------------------------------------------------------------- /src/scripts/close.js: -------------------------------------------------------------------------------- 1 | /** @module */ 2 | 3 | const CLOSE_COMMANDS = ['close', 'lock', 'thread'] 4 | 5 | function isClosePost (post) { 6 | return post.command != null && CLOSE_COMMANDS.indexOf(post.command) !== -1 7 | } 8 | 9 | /** 10 | * Allows moderators to close comment threads. Looks for the first comment by a 11 | * moderator to issue the "/thread" command, and automatically deletes all 12 | * non-moderator posts after it. 13 | */ 14 | export function attach (emitter, record) { 15 | 16 | emitter.on('new', (post, state) => { 17 | 18 | if (!post.isMod) { 19 | state.get().then(s => { 20 | if (s.closed) { 21 | emitter.client.del(post.id).then(() => { 22 | record('close-delete', { post }) 23 | }) 24 | } 25 | }) 26 | } else if (isClosePost(post)) { 27 | record('close', { post }) 28 | state.update({ closed: true }) 29 | } 30 | 31 | }) 32 | 33 | } 34 | -------------------------------------------------------------------------------- /src/scripts/delete.js: -------------------------------------------------------------------------------- 1 | /** @module */ 2 | 3 | const DELETE_COMMANDS = ['delete', 'mod', 'moderate'] 4 | 5 | function isDeletePost (post) { 6 | return post.command != null && DELETE_COMMANDS.indexOf(post.command) !== -1 7 | } 8 | 9 | /** 10 | * Allows moderators to close comment threads. Looks for the first comment by a 11 | * moderator to issue the "/delete" command, and automatically deletes all 12 | * non-moderator posts after it. 13 | */ 14 | export function attach (emitter, record) { 15 | 16 | emitter.on('new', (post, state) => { 17 | if (post.isMod && isDeletePost(post) && post.level > 0) { 18 | const parent = post.parent 19 | emitter.client.del(parent.id).then(() => { 20 | record('delete', { post: parent }) 21 | }) 22 | } 23 | }) 24 | 25 | } 26 | --------------------------------------------------------------------------------