diff --git a/app.js b/app.js index 3d96513..bfeb55f 100644 --- a/app.js +++ b/app.js @@ -3,7 +3,7 @@ var request = require('request'); var fs = require('fs'); var path = require('path'); -['SLACK_TOKEN', 'RPCUSER', 'RPCPASSWORD', 'IMGUR_CLIENT_ID'].forEach(function(envVar) { +['SLACK_TOKEN', 'RPCUSER', 'RPCPASSWORD', 'MONGODB_URL'].forEach(function (envVar) { if (!process.env[envVar]) { throw new Error(envVar + ' env var required'); } @@ -15,7 +15,6 @@ var slackbot = new SlackBot({ }); - function sendWelcomeMessage(user) { fs.readFile(path.join(path.dirname(require.main.filename), 'slack-greeting.md'), {encoding: 'utf-8'}, function (error, data) { if (!error) { @@ -30,8 +29,8 @@ tipbot.init(process.env.RPCUSER, process.env.RPCPASSWORD); var hashbot = require('./bots/hashbot'); hashbot.init(slackbot, process.env.MINING_CHANNEL); -//var gifbot = require('./bots/gifbot'); -//gifbot.init(slackbot, process.env.IMGUR_CLIENT_ID); +var claimbot = require('./bots/claimbot'); +claimbot.init(slackbot, process.env.CLAIMS_CHANNEL, process.env.RPCUSER, process.env.RPCPASSWORD, process.env.MONGODB_URL); slackbot.on('start', function() { slackbot.on('message', function(data) { diff --git a/bots/claimbot.js b/bots/claimbot.js new file mode 100644 index 0000000..c09b839 --- /dev/null +++ b/bots/claimbot.js @@ -0,0 +1,272 @@ +'use strict'; + +var lbry; +var mongo; +var moment = require('moment'); + +module.exports = { + init: init, +}; + + +function init(slackbot, channel, rpcuser, rpcpassword, mongodburl) { + if (lbry) + { + throw new Error('init was already called once'); + } + + if (!channel) + { + console.log('No claims channel, disabling claimbot'); + return; + } + + const MongoClient = require('mongodb').MongoClient; + MongoClient.connect(mongodburl, function (err, db) { + if (err) + { + throw err; + } + mongo = db; + + const bitcoin = require('bitcoin'); + lbry = new bitcoin.Client({ + host: 'localhost', + 'port': 9245, + 'user': rpcuser, + 'pass': rpcpassword + }); + + setInterval(function () { + announceNewClaims(slackbot, channel); + }, 60 * 1000); + announceNewClaims(slackbot, channel); + }); +} + + +function announceNewClaims(slackbot, channel) { + + if (!mongo) + { + slackbot.postMessage(channel, 'Failed to connect to mongo', {icon_emoji: ':exclamation:'}); + return; + } + + if (!lbry) + { + slackbot.postMessage(channel, 'Failed to connect to lbrycrd', {icon_emoji: ':exclamation:'}); + return; + } + + Promise.all([getLastBlock(), lbryCall('getinfo')]) + .then(function ([lastProcessedBlock, currentBlockInfo]) { + const currentHeight = currentBlockInfo['blocks']; + + // console.log('Checking for new blocks'); + + if (lastProcessedBlock === null) + { + console.log('First run. Setting last processed block to ' + currentHeight + ' and exiting.'); + return setLastBlock(currentHeight); + } + + if (lastProcessedBlock < currentHeight) + { + const firstBlockToProcess = lastProcessedBlock + 1, + lastBlockToProcess = currentHeight; + + // console.log('Doing blocks ' + firstBlockToProcess + ' to ' + lastBlockToProcess); + return announceClaimsLoop(slackbot, channel, firstBlockToProcess, lastBlockToProcess, currentHeight); + + } + }) + .catch(function (err) { + slackbot.postMessage(channel, err.stack, {icon_emoji: ':exclamation:'}); + }); +} + +function announceClaimsLoop(slackbot, channel, block, lastBlock, currentHeight) { + // console.log('Doing block ' + block) + return lbryCall('getblockhash', block) + .then(function (blockHash) { + return lbryCall('getblock', blockHash); + }) + .then(function (blockData) { + return Promise.all(blockData['tx'].map(getClaimsForTxid)) + }) + .then(function (arrayOfClaimArrays) { + const claims = Array.prototype.concat(...arrayOfClaimArrays).filter(function (c) { + return !!c; + }); + console.log('Found ' + claims.length + ' claims in ' + block); + return Promise.all(claims.map(function (claim) { + return announceClaim(claim, block, currentHeight, slackbot, channel); + })); + }) + .then(function () { + return setLastBlock(block); + }) + .then(function () { + const nextBlock = block + 1; + if (nextBlock <= lastBlock) + { + return announceClaimsLoop(slackbot, channel, nextBlock, lastBlock, currentHeight); + } + }); +} + +function announceClaim(claim, claimBlockHeight, currentHeight, slackbot, channel) { + console.log('' + claimBlockHeight + ': New claim for ' + claim['name']); + return Promise.all([ + lbryCall('getvalueforname', claim['name']), + lbryCall('getclaimsforname', claim['name']), + ]) + .then(function ([currentWinningClaim, claimsForName]) { + let value; + try + { + value = JSON.parse(claim['value']); + } + catch (e) + { + } + + // console.log(claim); + // console.log(value); + + const text = []; + + if (value['author']) + { + text.push(value['author']); + } + if (value['description']) + { + text.push(value['description']); + } + // if (value['content_type']) + // { + // text.push("*Content Type:* " + value['content_type']); + // } + if (value['nsfw']) + { + text.push("*Warning: Adult Content*"); + } + if (value['fee']) + { + const fees = []; + for (var key in value['fee']) + { + fees.push(value['fee'][key]['amount'] + ' ' + key); + } + text.push(fees.join(', ')); + } + + const fields = []; + if (!claim['is controlling']) + { + // the following is based on https://lbry.io/faq/claimtrie-implementation + const lastTakeoverHeight = claimsForName['nLastTakeoverHeight'], + maxDelay = 4032, // 7 days of blocks at 2.5min per block + activationDelay = Math.min(maxDelay, Math.floor((claimBlockHeight - lastTakeoverHeight) / 32)), + takeoverHeight = claimBlockHeight + activationDelay, + secondsPerBlock = 161, // in theory this should be 150, but in practice its closer to 161 + takeoverTime = Date.now() + ((takeoverHeight - currentHeight) * secondsPerBlock * 1000); + + text.push('Takes effect on approx. *' + moment(takeoverTime, 'x').format('MMMM Do [at] HH:mm [UTC]') + '* (block ' + takeoverHeight + ')'); + } + + + const attachment = !value ? null : { + "fallback": "New claim for lbry://" + claim['name'] + ': "' + claim['title'] + '" by ' + claim['author'], + "color": "#155b4a", + // "pretext": "New claim in block " + claimBlockHeight, + "author_name": 'lbry://' + claim['name'], + "author_link": 'lbry://' + claim['name'], + // "author_icon": "http://flickr.com/icons/bobby.jpg", + "title": escapeSlackHtml(value['title']), + "title_link": "lbry://" + claim['name'], + "text": escapeSlackHtml(text.join("\n")), + "fields": fields, + // "image_url": value['nsfw'] ? null : value['thumbnail'], + "thumb_url": value['nsfw'] ? null : value['thumbnail'], + "unfurl_links": false, + "unfurl_media": false, + "link_names": false, + "parse": "none", + "footer": "Block " + claimBlockHeight + " • Claim ID " + claim['claimId'], + "mrkdwn_in": ['text'], + }; + + slackbot.postMessage(channel, '', {icon_emoji: ':bellhop_bell:', attachments: [attachment]}); + }) +} + +function escapeSlackHtml(txt) { + return txt.replace('&', '&').replace('<', '<').replace('>', '>'); +} + +function getClaimsForTxid(txid) { + return lbryCall('getclaimsfortx', txid) + .catch(function (err) { + // an error here most likely means the transaction is spent, + // which also means there are no claims worth looking at + return []; + }); +} + +function getLastBlock() { + return new Promise(function (resolve, reject) { + mongo.collection('claimbot').findOne({}, function (err, obj) { + if (err) + { + reject(err); + } + else if (!obj) + { + mongo.collection('claimbot').createIndex({'last_block': 1}, {unique: true}); + resolve(null); + } + else + { + resolve(obj.last_block); + } + }); + }); +} + +function setLastBlock(block) { + return new Promise(function (resolve, reject) { + mongo.collection('claimbot').findOneAndUpdate( + {'last_block': {'$exists': true}}, + {'last_block': block}, + {'upsert': true, 'returnOriginal': false}, + function (err, obj) { + if (!err && obj && obj.value.last_block != block) + { + reject('Last value should be ' + block + ', but it is ' + obj.value.last_block); + } + else + { + resolve(); + } + } + ); + }); +} + +function lbryCall(...args) { + return new Promise(function (resolve, reject) { + lbry.cmd(...args, function (err, ...response) { + if (err) + { + reject(new Error('JSONRPC call failed. Args: [' + args.join(', ') + ']')); + } + else + { + resolve(...response); + } + }); + }); +} \ No newline at end of file diff --git a/bots/gifbot.js b/bots/gifbot.js index 7f51b80..ab88df5 100644 --- a/bots/gifbot.js +++ b/bots/gifbot.js @@ -30,6 +30,12 @@ module.exports = { function init(_slackbot, imgur_client_id) { + if (!imgur_client_id) + { + console.log('No imgur client id, disabling gifbot'); + return; + } + slackbot = _slackbot; imgur = require('imgur'); imgur.setClientId(imgur_client_id); @@ -59,6 +65,11 @@ function jsonrpc_call(method, params, callback) { function handle_msg(msg, channel) { + if (!imgur) + { + return; + } + var words = msg.trim().split(' '); words.forEach(function(word) diff --git a/package.json b/package.json index 8d7c748..307769c 100644 --- a/package.json +++ b/package.json @@ -5,11 +5,13 @@ "main": "app.js", "dependencies": { "bitcoin": "^3.0.1", + "imgur": "0.1.7", + "moment": "^2.17.1", + "mongodb": "^2.2.22", "needle": "^1.0.0", - "slackbots": "^0.5.1", "request": "^2.74.0", - "xmlhttprequest": "1.8.0", - "imgur": "0.1.7" + "slackbots": "^0.5.1", + "xmlhttprequest": "1.8.0" }, "devDependencies": {}, "scripts": {