├── .gitignore ├── lib ├── auth │ ├── index.js │ ├── basic.js │ ├── bearer.js │ └── oauth.js ├── index.js ├── util.js ├── tweet.js ├── dm.js └── webhooks.js ├── Dockerfile ├── .dockerignore ├── .eslintrc ├── local.json-dist ├── examples ├── dm-and-destroy.js ├── tweet-and-destory.js └── subscribe-webhook.js ├── package.json ├── routes ├── validate-webhook.js └── handle-webhook.js ├── .github └── workflows │ ├── nodejs.yml │ └── codeql-analysis.yml ├── test ├── test-vadlidate-webhook.js └── test-server.js ├── server.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | local.json 4 | deploy.sh 5 | .nyc_output/ 6 | .env 7 | -------------------------------------------------------------------------------- /lib/auth/index.js: -------------------------------------------------------------------------------- 1 | const bearer = require('bearer'); 2 | const oauth = require('oauth'); 3 | 4 | module.exports = { 5 | bearer, oauth 6 | }; 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-slim 2 | WORKDIR /usr/src/app 3 | COPY package*.json ./ 4 | RUN npm ci --production 5 | COPY . ./ 6 | CMD ["node", "server.js"] 7 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .eslintrc 3 | .gitignore 4 | Dockerfile 5 | local.json 6 | local.json-dist 7 | npm-debug.log 8 | node_modules 9 | README.md 10 | test/ 11 | deploy.sh 12 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | const dm = require('./dm'); 2 | const tweet = require('./tweet'); 3 | const webhooks = require('./webhooks'); 4 | 5 | module.exports = { 6 | dm, 7 | tweet, 8 | webhooks 9 | }; 10 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "es6": true 5 | }, 6 | "rules": { 7 | "quotes": [2, "single"], 8 | "camelcase": 0 9 | }, 10 | "parserOptions": { 11 | "ecmaVersion": 2020 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /lib/util.js: -------------------------------------------------------------------------------- 1 | // Taken from https://github.com/LautaroJayat/twitter_bot 2 | // MIT 3 | function encode(str) { 4 | str = encodeURIComponent(str); 5 | str = str.replace(/[!'()]/g, escape).replace(/\*/g, '%2A'); 6 | return str; 7 | } 8 | 9 | module.exports = { 10 | encode 11 | }; 12 | -------------------------------------------------------------------------------- /lib/auth/basic.js: -------------------------------------------------------------------------------- 1 | const { Headers } = require('node-fetch'); 2 | 3 | function createBasicHeader(auth) { 4 | return new Headers({ 5 | Authorization: 'Basic ' 6 | + Buffer.from(`${auth.consumer_key}:${auth.consumer_secret}`, 'utf8').toString('base64') 7 | }); 8 | } 9 | 10 | module.exports = { 11 | createBasicHeader 12 | }; 13 | -------------------------------------------------------------------------------- /local.json-dist: -------------------------------------------------------------------------------- 1 | { 2 | "moderator_id": "ID ", 3 | "bot_id": "3182135887", 4 | "consumer_key": "consumer key from twitter api", 5 | "consumer_secret": "consumer secret from twitter api", 6 | "access_token": "access token from twitter api", 7 | "access_token_secret": "access token secret from twitter api", 8 | "env": "environment in developer.twitter.com to account activity api", 9 | "webhook_url": "the url to be called by twitter.com" 10 | } 11 | -------------------------------------------------------------------------------- /examples/dm-and-destroy.js: -------------------------------------------------------------------------------- 1 | const config = require('../local.json'); 2 | 3 | const { send, destroy } = require('../lib/dm'); 4 | 5 | const auth = { 6 | access_token, 7 | access_token_secret, 8 | consumer_key, 9 | consumer_secret 10 | } = config; 11 | 12 | async function main () { 13 | const { id } = await send(auth, config.moderator_id, 'Oh hi!'); 14 | const success = await destroy(auth, id); 15 | return 'Succesffully sent + deleted a dm'; 16 | } 17 | 18 | main() 19 | .then(result => console.log(result)) 20 | .catch(error => console.error(error)); 21 | -------------------------------------------------------------------------------- /examples/tweet-and-destory.js: -------------------------------------------------------------------------------- 1 | const { update, destroy } = require('../lib/tweet'); 2 | 3 | const config = require('../local.json'); 4 | 5 | const auth = { 6 | access_token, 7 | access_token_secret, 8 | consumer_key, 9 | consumer_secret 10 | } = config; 11 | 12 | const msg = `Your lucky number is ${Math.floor(Math.random() * 1000)}`; 13 | 14 | async function main () { 15 | const id = await update(auth, msg); 16 | const success = await destroy(auth, id); 17 | return 'We successfully created and deleted a toot'; 18 | } 19 | 20 | main() 21 | .then(result => console.log(result)) 22 | .catch(error => console.error(error)); 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "talkpay-bot", 3 | "version": "2.0.3", 4 | "description": "DM me and I'll tweet your #talkpay", 5 | "main": "server.js", 6 | "scripts": { 7 | "test": "npm run lint && npm run tap", 8 | "tap": "tap --no-coverage test/test-*.js", 9 | "lint": "eslint server.js lib/ test/ routes/", 10 | "start": "node server.js" 11 | }, 12 | "author": "Myles Borins", 13 | "license": "MIT", 14 | "repository": "https://github.com/MylesBorins/talkpay-bot", 15 | "dependencies": { 16 | "bl": "^4.0.3", 17 | "node-fetch": "^2.6.7", 18 | "oauth-1.0a": "^2.2.6" 19 | }, 20 | "devDependencies": { 21 | "eslint": "^8.31.0", 22 | "tap": "^16.3.2" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /routes/validate-webhook.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | 3 | function validateWebhook(auth, token, res) { 4 | console.log('Validating Webhook'); 5 | try { 6 | const responseToken = crypto 7 | .createHmac('sha256', auth.consumer_secret) 8 | .update(token) 9 | .digest('base64'); 10 | res.writeHead(200, {'content-type': 'application/json'}); 11 | res.write(JSON.stringify({ 12 | response_token: `sha256=${responseToken}` 13 | })) 14 | res.end(); 15 | } 16 | catch (e) { 17 | console.error(e); 18 | res.writeHead(500); 19 | res.write('500: Something went seriously wrong.') 20 | res.end(); 21 | } 22 | } 23 | 24 | module.exports = validateWebhook; 25 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | types: [opened, synchronize, reopened, ready_for_review] 9 | 10 | jobs: 11 | run-tests: 12 | strategy: 13 | matrix: 14 | node-version: ['19', '18', '16', '14'] 15 | os: [ubuntu-latest, macos-latest, windows-latest] 16 | 17 | runs-on: ${{ matrix.os }} 18 | 19 | steps: 20 | - uses: actions/checkout@v3 21 | 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v3 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | cache: 'npm' 27 | 28 | - name: Install Dependencies 29 | run: npm ci 30 | 31 | - name: Test 32 | run: npm run test 33 | -------------------------------------------------------------------------------- /lib/auth/bearer.js: -------------------------------------------------------------------------------- 1 | const fetch = require('node-fetch'); 2 | const Headers = fetch.Headers; 3 | 4 | const {createBasicHeader} = require('./basic'); 5 | 6 | const bearerUrl = 'https://api.twitter.com/oauth2/token'; 7 | 8 | const params = new URLSearchParams(); 9 | params.append('grant_type', 'client_credentials'); 10 | 11 | let _token; 12 | 13 | async function getBearerToken(auth) { 14 | if (_token) return _token; 15 | const res = await fetch(bearerUrl, { 16 | method: 'post', 17 | body: params, 18 | headers: createBasicHeader(auth) 19 | }); 20 | const { 21 | token_type, 22 | access_token 23 | } = await res.json(); 24 | if (token_type !== 'bearer') throw new Error(`wrong token type: ${token_type}`); 25 | return access_token; 26 | } 27 | 28 | async function createBearerHeader(auth) { 29 | const token = await getBearerToken(auth); 30 | return new Headers({ 31 | Authorization: `Bearer ${token}` 32 | }); 33 | } 34 | 35 | module.exports = { 36 | getBearerToken, 37 | createBearerHeader 38 | }; 39 | -------------------------------------------------------------------------------- /test/test-vadlidate-webhook.js: -------------------------------------------------------------------------------- 1 | const { test } = require('tap'); 2 | 3 | const validateWebhook = require('../routes/validate-webhook'); 4 | 5 | test('we get expected resuls', t => { 6 | const res = { 7 | writeHead: (code, options) => { 8 | t.equals(code, 200); 9 | t.deepEquals(options, { 10 | 'content-type': 'application/json' 11 | }); 12 | }, 13 | write: val => { 14 | t.equals( 15 | val, 16 | '{"response_token":"sha256=rCq4c3Kw116JJ4rIxl0aR3lhY8UZ0ZsQsv6IBQY6YFE="}' 17 | ); 18 | }, 19 | end: t.end 20 | } 21 | validateWebhook({ 22 | consumer_secret: '12345' 23 | }, 'I-am-a-token', res); 24 | }); 25 | 26 | test('no auth', t => { 27 | const res = { 28 | writeHead: (code) => { 29 | t.equals(code, 500); 30 | }, 31 | write: val => { 32 | t.equals(val, '500: Something went seriously wrong.'); 33 | }, 34 | end: t.end 35 | } 36 | validateWebhook({ 37 | consumer_secret: undefined 38 | }, 'I-am-a-token', res); 39 | }); 40 | -------------------------------------------------------------------------------- /lib/auth/oauth.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | const OAuth = require('oauth-1.0a') 3 | const fetch = require('node-fetch'); 4 | const Headers = fetch.Headers; 5 | 6 | function createTokens(auth) { 7 | const consumerToken = { 8 | key: auth.consumer_key.trim(), 9 | secret: auth.consumer_secret.trim() 10 | }; 11 | 12 | const accessToken = { 13 | key: auth.access_token.trim(), 14 | secret: auth.access_token_secret.trim() 15 | }; 16 | 17 | return { 18 | accessToken, 19 | consumerToken 20 | }; 21 | } 22 | 23 | function createOAuthHeader(auth, requestData) { 24 | const { 25 | accessToken, 26 | consumerToken 27 | } = createTokens(auth); 28 | 29 | const oauth = OAuth({ 30 | consumer: consumerToken, 31 | signature_method: 'HMAC-SHA1', 32 | hash_function(base_string, key) { 33 | return crypto 34 | .createHmac('sha1', key) 35 | .update(base_string) 36 | .digest('base64'); 37 | } 38 | }); 39 | 40 | const header = oauth.toHeader(oauth.authorize(requestData, accessToken)); 41 | return new Headers(header); 42 | } 43 | 44 | module.exports = { 45 | createOAuthHeader, 46 | createTokens 47 | }; 48 | -------------------------------------------------------------------------------- /lib/tweet.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const fetch = require('node-fetch'); 3 | 4 | const { createOAuthHeader } = require('./auth/oauth'); 5 | 6 | const { encode } = require('./util'); 7 | 8 | const POST_URL = 'https://api.twitter.com/1.1/statuses/update.json'; 9 | const DELETE_URL = 'https://api.twitter.com/1.1/statuses/destroy/'; 10 | 11 | async function update(auth, msg) { 12 | const requestData = { 13 | url: `${POST_URL}?status=${encode(msg)}`, 14 | method: 'POST' 15 | }; 16 | 17 | const res = await fetch(requestData.url, { 18 | headers: createOAuthHeader(auth, requestData), 19 | method: requestData.method 20 | }); 21 | if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); 22 | const json = await res.json(); 23 | return json.id_str; 24 | } 25 | 26 | async function destroy(auth, id) { 27 | const requestData = { 28 | url: `${DELETE_URL}${id}.json`, 29 | method: 'POST' 30 | }; 31 | 32 | const res = await fetch(requestData.url, { 33 | headers: createOAuthHeader(auth, requestData), 34 | method: requestData.method 35 | }); 36 | 37 | if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); 38 | return true; 39 | } 40 | 41 | module.exports = { 42 | update, 43 | destroy 44 | }; 45 | -------------------------------------------------------------------------------- /lib/dm.js: -------------------------------------------------------------------------------- 1 | const fetch = require('node-fetch'); 2 | 3 | const { createOAuthHeader } = require('./auth/oauth'); 4 | 5 | const POST_URL = 'https://api.twitter.com/1.1/direct_messages/events/new.json'; 6 | const DELETE_URL = 'https://api.twitter.com/1.1/direct_messages/events/destroy.json'; 7 | 8 | function createDmEvent(recipient_id, text) { 9 | return '{"event":{"type": "message_create", "message_create": {"target": ' + 10 | `{"recipient_id": "${recipient_id}"}, "message_data": {"text": "${text}"}}}}`; 11 | } 12 | 13 | async function send(auth, recipient_id, text) { 14 | const requestData = { 15 | url: POST_URL, 16 | method: 'POST' 17 | }; 18 | 19 | const headers = createOAuthHeader(auth, requestData); 20 | headers.append('Content-type', 'application/json'); 21 | 22 | const body = createDmEvent(recipient_id, text); 23 | 24 | const res = await fetch(requestData.url, { 25 | headers, 26 | method: requestData.method, 27 | body 28 | }); 29 | if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); 30 | const json = await res.json(); 31 | return json.event; 32 | } 33 | 34 | async function destroy(auth, id) { 35 | const requestData = { 36 | url: `${DELETE_URL}?id=${id}`, 37 | method: 'DELETE' 38 | }; 39 | 40 | const res = await fetch(requestData.url, { 41 | headers: createOAuthHeader(auth, requestData), 42 | method: requestData.method 43 | }); 44 | 45 | if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); 46 | return true; 47 | } 48 | 49 | module.exports = { 50 | send, 51 | destroy 52 | }; 53 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | // Inspired by twitter-autohook 2 | // https://github.com/twitterdev/autohook 3 | // MIT 4 | 'use strict'; 5 | const http = require('http'); 6 | const url = require('url'); 7 | 8 | const validateWebhook = require('./routes/validate-webhook'); 9 | const handleWebhook = require('./routes/handle-webhook'); 10 | 11 | const port = process.env.PORT || 8000; 12 | 13 | const auth = { 14 | consumer_key: process.env.CONSUMER_KEY, 15 | consumer_secret: process.env.CONSUMER_SECRET, 16 | access_token: process.env.ACCESS_TOKEN, 17 | access_token_secret: process.env.ACCESS_TOKEN_SECRET 18 | }; 19 | 20 | const server = http.createServer(async (req, res) => { 21 | console.log(`request received for: ${req.url}`); 22 | 23 | const route = url.parse(req.url, true); 24 | if (route.query.crc_token) { 25 | validateWebhook(auth, route.query.crc_token, res); 26 | return; 27 | } 28 | 29 | if (req.method === 'POST' && req.headers['content-type'] === 'application/json') { 30 | try { 31 | await handleWebhook(auth, req); 32 | res.writeHead(200) 33 | } 34 | catch (e) { 35 | console.error(e); 36 | res.writeHead(500); 37 | } 38 | finally { 39 | res.end(); 40 | } 41 | return; 42 | } 43 | 44 | if (req.url !== '/') { 45 | res.writeHead(404); 46 | res.write('404\'d'); 47 | res.end(); 48 | return; 49 | } 50 | 51 | res.writeHead(200); 52 | res.write('Oh hi'); 53 | res.end(); 54 | }); 55 | 56 | module.exports = { 57 | server 58 | }; 59 | 60 | if (require.main === module) { 61 | server.listen(port, (err) => { 62 | if (err) { 63 | console.error(err); 64 | process.exit(1); 65 | } 66 | console.log(`Lisenting on port ${port}`); 67 | }); 68 | } 69 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # talkpay-bot 2 | 3 | DM @talkpayBot and your message will be anonymously tweeted. 4 | 5 | ## Getting started 6 | 7 | ### getting credentials 8 | 9 | [Apply for an access](https://developer.twitter.com/en/apply-for-access) on https://developer.twitter.com. 10 | 11 | To get Consumer + Access token you will need to [create an app](https://developer.twitter.com/en/apps/create) 12 | 13 | To subscribe to the stream you will need to [create a dev environment](https://developer.twitter.com/en/account/environments). 14 | 15 | More details can be found [in the developer.twitter.com docs](https://developer.twitter.com/en/docs/basics/getting-started). 16 | 17 | ### local testing 18 | 19 | Only the examples found in `./examples` can be run on a local system. 20 | 21 | They all expect to find config data in a `./local.json` file. 22 | Please refer to `local.json-dist` for required fields. 23 | 24 | ### deploying to cloud 25 | 26 | The following environment variable must be set when running in the cloud 27 | ``` 28 | CONSUMER_KEY="consumer key from twitter api" 29 | CONSUMER_SECRET="consumer secret from twitter api" 30 | ACCESS_TOKEN="access token from twitter api" 31 | ACCESS_TOKEN_SECRET="access token secret from twitter api" 32 | MODERATOR_ID="twitter ID of account that can mdoerate" 33 | BOT_ID="twitter ID of the bot itself" 34 | ``` 35 | 36 | The provided Dockerfile is compatible with [Google Cloud Run](https://cloud.google.com/run) 37 | 38 | ```bash 39 | gcloud builds submit --tag gcr.io/$PROJECT\_NAME/talkpay-bot 40 | gcloud run deploy talkpay-bot --image gcr.io/$PROJECT\_NAME/talkpay-bot --platform managed 41 | 42 | ``` 43 | 44 | ### registering webhook 45 | 46 | 47 | ```bash 48 | node ./experiments/subscribe.js 49 | ``` 50 | 51 | ## OMG cloud run 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '44 5 * * 1' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | 37 | steps: 38 | - name: Checkout repository 39 | uses: actions/checkout@v2 40 | 41 | # Initializes the CodeQL tools for scanning. 42 | - name: Initialize CodeQL 43 | uses: github/codeql-action/init@v1 44 | with: 45 | languages: ${{ matrix.language }} 46 | # If you wish to specify custom queries, you can do so here or in a config file. 47 | # By default, queries listed here will override any specified in a config file. 48 | # Prefix the list here with "+" to use these queries and those in the config file. 49 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 50 | - name: Perform CodeQL Analysis 51 | uses: github/codeql-action/analyze@v1 52 | -------------------------------------------------------------------------------- /lib/webhooks.js: -------------------------------------------------------------------------------- 1 | const fetch = require('node-fetch'); 2 | 3 | const { createOAuthHeader } = require('./auth/oauth'); 4 | const { createBearerHeader } = require('./auth/bearer'); 5 | 6 | async function deleteWebhook(auth, env, id) { 7 | const deleteUrl = `https://api.twitter.com/1.1/account_activity/all/${env}/webhooks/${id}.json` 8 | const res = await fetch(deleteUrl, { 9 | method: 'delete', 10 | headers: await createBearerHeader(auth) 11 | }); 12 | return res; 13 | } 14 | 15 | async function getWebhooks(auth, env) { 16 | const getUrl = `https://api.twitter.com/1.1/account_activity/all/${env}/webhooks.json`; 17 | const res = await fetch(getUrl, { 18 | headers: await createBearerHeader(auth) 19 | }); 20 | const json = await res.json(); 21 | return json; 22 | } 23 | 24 | async function registerWebhook(auth, env, url) { 25 | const endpoint = new URL(`https://api.twitter.com/1.1/account_activity/all/${env}/webhooks.json`); 26 | endpoint.searchParams.append('url', url); 27 | const requestData = { 28 | url: endpoint.toString(), 29 | method: 'POST' 30 | }; 31 | const res = await fetch(requestData.url, { 32 | headers: createOAuthHeader(auth, requestData), 33 | method: requestData.method 34 | }); 35 | if (!res.ok) throw new Error(res.statusText); 36 | const json = await res.json(); 37 | return json; 38 | } 39 | 40 | async function subscribe(auth, env) { 41 | const requestData = { 42 | url: `https://api.twitter.com/1.1/account_activity/all/${env}/subscriptions.json`, 43 | method: 'POST' 44 | }; 45 | const res = await fetch(requestData.url, { 46 | headers: createOAuthHeader(auth, requestData), 47 | method: requestData.method 48 | }); 49 | if (!res.ok) throw new Error(res.statusText); 50 | return true; 51 | } 52 | 53 | module.exports = { 54 | deleteWebhook, 55 | getWebhooks, 56 | registerWebhook, 57 | subscribe 58 | }; 59 | -------------------------------------------------------------------------------- /test/test-server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const fetch = require('node-fetch'); 3 | const { 4 | beforeEach, 5 | afterEach, 6 | test 7 | } = require('tap'); 8 | const { server } = require('../'); 9 | 10 | beforeEach((done) => { 11 | server.listen(8080, done); 12 | }); 13 | 14 | afterEach((done) => { 15 | server.close(done); 16 | }); 17 | 18 | test('request to /', async (t) => { 19 | const res = await fetch('http://localhost:8080/'); 20 | t.ok(res.ok, 'the response should be good!'); 21 | t.equal(res.status, 200, 'status should be 200'); 22 | 23 | const txt = await res.text(); 24 | t.equal(txt, 'Oh hi', 'should get expected response'); 25 | }); 26 | 27 | test('generic request to /webook', async (t) => { 28 | const res = await fetch('http://localhost:8080/webhook'); 29 | t.notOk(res.ok, 'the response should not be good!'); 30 | t.equal(res.status, 404, 'status should be 404'); 31 | 32 | const txt = await res.text(); 33 | t.equal(txt, '404\'d', 'should get expected response'); 34 | }); 35 | 36 | // Need to figure out how to set process values in test. 37 | // maybe in beforeEach? 38 | // test('crc_token request to /webook', async (t) => { 39 | // const res = await fetch('http://localhost:8080/webhook?crc_token=12345', { 40 | // method: 'POST' 41 | // }); 42 | // t.ok(res.ok, 'the response should be good!'); 43 | // t.equal(res.status, 200, 'status should be 404'); 44 | // 45 | // const { response_token } = await res.json(); 46 | // t.equal(txt, '404\'d', 'should get expected response'); 47 | // }); 48 | 49 | test('ignored POST to /webhook', async t => { 50 | const res = await fetch('http://localhost:8080/webhook', { 51 | method: 'POST', 52 | headers: { 53 | 'content-type': 'application/json' 54 | }, 55 | body: '{}' 56 | }); 57 | t.ok(res.ok, 'the response should be good!'); 58 | t.equal(res.status, 200, 'status should be 200'); 59 | }); 60 | -------------------------------------------------------------------------------- /examples/subscribe-webhook.js: -------------------------------------------------------------------------------- 1 | const { 2 | access_token, 3 | access_token_secret, 4 | consumer_key, 5 | consumer_secret, 6 | env, 7 | webhook_url 8 | } = require('../local.json'); 9 | 10 | const { 11 | registerWebhook, 12 | getWebhooks, 13 | deleteWebhook, 14 | subscribe 15 | } = require('../lib/webhooks'); 16 | 17 | const auth = { 18 | access_token, 19 | access_token_secret, 20 | consumer_key, 21 | consumer_secret 22 | }; 23 | 24 | async function cleanOldWebhook(auth, env) { 25 | console.log('Getting existing webhook'); 26 | const webhooks = await getWebhooks(auth, env); 27 | if (!webhooks instanceof Array) throw webhooks; 28 | const webhook = webhooks[0]; 29 | if (webhook) { 30 | console.log(`webhook found`); 31 | console.log(webhook.url) 32 | console.log('Deleting webhook'); 33 | const success = await deleteWebhook(auth, env, webhook.id); 34 | if (!success) throw new Error('webhook deletion failed'); 35 | console.log('Successfully deleted webhook'); 36 | } 37 | else { 38 | console.log('No webhook found'); 39 | } 40 | } 41 | 42 | async function registerNewWebhook(auth, env, webhook_url) { 43 | console.log('Registering new webhook'); 44 | const newWebhook = await registerWebhook(auth, env, webhook_url); 45 | if (!newWebhook) throw new Error(`webhook creation failed`); 46 | console.log('Successfully created webhook 🎉'); 47 | console.log(newWebhook.url) 48 | } 49 | 50 | async function createSubscription(auth, env) { 51 | console.log('Subscribing to events'); 52 | const subscription = await subscribe(auth, env); 53 | console.log('Subscribed!'); 54 | } 55 | 56 | async function main(auth, env, webhook_url) { 57 | await cleanOldWebhook(auth, env); 58 | await registerNewWebhook(auth, env, webhook_url); 59 | await createSubscription(auth, env); 60 | } 61 | 62 | main(auth, env, webhook_url).then(_ => { 63 | console.log('All Done'); 64 | }).catch(e => console.error(e)); 65 | -------------------------------------------------------------------------------- /routes/handle-webhook.js: -------------------------------------------------------------------------------- 1 | const BufferList = require('bl'); 2 | 3 | const { dm, tweet } = require('../lib'); 4 | 5 | const MODERATOR_ID = process.env.MODERATOR_ID; 6 | const BOT_ID = process.env.BOT_ID; 7 | 8 | async function moderate(auth, urls, senderId) { 9 | const target = urls[0].expanded_url; 10 | const split = target.split('/'); 11 | const id = split[split.length - 1]; 12 | try { 13 | await tweet.destroy(auth, id); 14 | await dm.send(auth, senderId, 'Moderated.'); 15 | } 16 | catch (e) { 17 | console.error(e); 18 | await dm.send(auth, senderId, 'Something went wrong 😢.'); 19 | } 20 | } 21 | 22 | async function talkpay(auth, msg, senderId) { 23 | try { 24 | const tweetID = await tweet.update(auth, msg); 25 | await dm.send(auth, senderId, 'I\'ve shared your salary information and deleted all messages. Thanks for sharing 🎉'); 26 | } 27 | catch (e) { 28 | console.error(e); 29 | await dm.send(auth, senderId, 'Something went wrong, please try again.'); 30 | } 31 | } 32 | 33 | async function handleDM(auth, m) { 34 | if (m.type !== 'message_create') return; 35 | console.log('direct message received'); 36 | 37 | const msg = m.message_create.message_data.text; 38 | const senderId = m.message_create.sender_id; 39 | const receiverId = m.message_create.target.recipient_id; 40 | const msgID = m.id; 41 | const urls = m.message_create.message_data.entities.urls; 42 | 43 | // immediately destroy message we received 44 | await dm.destroy(auth, msgID); 45 | console.log('direct message destroyed'); 46 | 47 | // if the message is from itself do nothing 48 | if (senderId === BOT_ID) { 49 | return; 50 | } 51 | // if the message is from a moderator and includes #shitbird delete the referenced tweet 52 | // share a tweet via DM with #shitbird 53 | // msg = ['#shitbird', 'http://t.co/someshortthing'] 54 | // we need the full url though to extract the msgID we want to delete' 55 | if (senderId == MODERATOR_ID && msg.startsWith('#cleanup')) { 56 | await moderate(auth, urls, senderId); 57 | } 58 | // No sharing links 59 | else if (urls.length) { 60 | await dm.send(auth, senderId, 'Sorry, I will not send out messages that include links.', senderId, msgID); 61 | } 62 | // if the message includes #talkpay tweet it 63 | else if (msg.includes('#talkpay')) { 64 | await talkpay(auth, msg, senderId); 65 | } 66 | // if all else fails warn the messanger of what they need to do 67 | else { 68 | await dm.send(auth, senderId, 'You need to include #talkpay in your DM for me to do my thing'); 69 | } 70 | } 71 | 72 | async function handleWebhook(auth, req) { 73 | console.log('handling webhook') 74 | const dataBuffer = new BufferList(); 75 | req.on('data', chunk => { 76 | dataBuffer.append(chunk); 77 | }); 78 | req.on('end', async () => { 79 | const result = JSON.parse(dataBuffer.toString()); 80 | const dms = result['direct_message_events']; 81 | if (dms && dms.length) { 82 | try { 83 | await Promise.all(dms.map(message => { 84 | handleDM(auth, message); 85 | })); 86 | } 87 | catch (e) { 88 | console.error(e); 89 | } 90 | } 91 | }); 92 | } 93 | 94 | module.exports = handleWebhook; 95 | --------------------------------------------------------------------------------