├── .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 |
2 |
3 | [](https://www.npmjs.com/package/hackbot) [](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 |
24 |
25 |
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 |
--------------------------------------------------------------------------------