328 lines
11 KiB
JavaScript
328 lines
11 KiB
JavaScript
// Description:
|
|
// Script that monitors #kudos Slack channel and sends
|
|
// a tip for each star attributes to target users
|
|
//
|
|
// Dependencies:
|
|
// axios: "^0.18.0"
|
|
// memjs: "^1.2.0"
|
|
//
|
|
// Author:
|
|
// PombeirP
|
|
|
|
const axios = require('axios')
|
|
const tokenPayments = require('../lib/token-payments')
|
|
|
|
const options = getOptions(process.env.KUDOS_BOT_CONFIG)
|
|
const botName = 'tip-kudos-recipients'
|
|
const kudosChannelId = options.slack.channel_id
|
|
const tipPerKudoInUsd = parseFloat(options.rules.tip_per_kudo_in_usd)
|
|
const tipPerReactionInUsd = parseFloat(options.rules.tip_per_reaction_in_usd)
|
|
const reactionThreshold = parseInt(options.rules.reaction_threshold)
|
|
const interTransactionDelay = parseInt(options.options.inter_transaction_delay)
|
|
const paymentPeriodicityInSecs = parseInt(options.options.payment_periodicity_in_days) * 24 * 60 * 60
|
|
|
|
const tokenID = process.env.DEBUG ? 'STT' : 'SNT'
|
|
const token = options.payments[tokenID]
|
|
const privateKey = token.private_key
|
|
const contractAddress = token.contract_address
|
|
|
|
const kudosBotDataMemcachedKey = 'tip-kudos-recipients-data'
|
|
const userIdRegex = /@[A-Z0-9]+/gi
|
|
var isCheckingUpdates = false
|
|
var previousNonce = null
|
|
|
|
module.exports = robot => {
|
|
if (!options || !token) {
|
|
robot.log.debug(`${botName} - No options configured. Disabling script`)
|
|
return
|
|
}
|
|
if (!privateKey.startsWith('0x')) {
|
|
robot.log.error(`${botName} - Private key must start with 0x. Disabling script`)
|
|
return
|
|
}
|
|
|
|
robot.log.info(`${botName} - Repeating script every ${paymentPeriodicityInSecs / (24 * 60 * 60)} days`)
|
|
setTimeout(() => processKudosChannelUpdates(robot), process.env.DISABLE_DELAY ? 1 * 1000 : 30 * 1000)
|
|
setInterval(() => processKudosChannelUpdates(robot), 24 * 60 * 60 * 1000)
|
|
}
|
|
|
|
function getOptions (optionsString) {
|
|
return JSON.parse(optionsString.split(`'`).join(`"`))
|
|
}
|
|
|
|
async function processKudosChannelUpdates (robot) {
|
|
if (isCheckingUpdates) {
|
|
return
|
|
}
|
|
|
|
isCheckingUpdates = true
|
|
try {
|
|
const mc = robot['memcache']
|
|
const data = await getSavedData(robot, mc)
|
|
|
|
await fetchPendingKudos(robot, data)
|
|
|
|
try {
|
|
await processPendingPayments(robot, data, d => setSavedData(mc, d))
|
|
} catch (error) {
|
|
robot.log.warn(`${botName} - Failed to make payment: ${error.responseText}`)
|
|
}
|
|
} catch (error) {
|
|
robot.log.error(`${botName} - Error while processing kudos: ${error}`)
|
|
} finally {
|
|
isCheckingUpdates = false
|
|
}
|
|
}
|
|
|
|
async function getSavedData (robot, mc) {
|
|
const json = await mc.get(kudosBotDataMemcachedKey)
|
|
if (json.value) {
|
|
const data = JSON.parse(json.value)
|
|
if (!data.hasOwnProperty('lastMessageTimestamp') || !data.hasOwnProperty('userPendingPayouts')) {
|
|
throw new Error(`${botName} - Invalid cached data`)
|
|
}
|
|
robot.log.debug(`${botName} - Loaded existing state: lastMessageTimestamp=${new Date(data.lastMessageTimestamp * 1000).toISOString()}`)
|
|
return data
|
|
}
|
|
|
|
return {
|
|
lastMessageTimestamp: (new Date()).getTime() / 1000,
|
|
lastPayoutProcessingTimestamp: 1,
|
|
userPendingPayouts: {}
|
|
}
|
|
}
|
|
|
|
async function setSavedData (mc, data) {
|
|
if (!data.hasOwnProperty('lastMessageTimestamp') || !data.hasOwnProperty('userPendingPayouts')) {
|
|
throw new Error(`${botName} - Invalid data, saving aborted`)
|
|
}
|
|
|
|
return mc.set(kudosBotDataMemcachedKey, JSON.stringify(data, {}, 2), {})
|
|
}
|
|
|
|
async function fetchPendingKudos (robot, data) {
|
|
const slackWeb = robot.slackWeb
|
|
const startTime = (new Date()).getTime()
|
|
const thresholdTs = startTime / 1000 - 24 * 60 * 60
|
|
let newMessagesProcessed = 0
|
|
|
|
while (true) {
|
|
const historyPayload = await slackWeb.channels.history(kudosChannelId, { oldest: data.lastMessageTimestamp })
|
|
if (historyPayload.ok) {
|
|
for (const message of historyPayload.messages.reverse()) {
|
|
const messageTs = parseFloat(message.ts)
|
|
if (messageTs >= thresholdTs) {
|
|
// If the kudos was given less than 24 hours ago, let's ignore it
|
|
// and leave it for a later time, so that people have time to vote
|
|
continue
|
|
}
|
|
if (message.type !== 'message' || message.subtype || !message.bot_id || !message.attachments) {
|
|
continue
|
|
}
|
|
|
|
++newMessagesProcessed
|
|
|
|
const kudosReceivers = parseKudosReceivers(message.attachments[0].text)
|
|
const kudosTimestamp = new Date(message.ts * 1000).toISOString()
|
|
if (kudosReceivers.length > 0) {
|
|
const reactionCount = countStarReactions(message, kudosReceivers)
|
|
if (reactionCount >= reactionThreshold) {
|
|
const additionalReactionCount = reactionCount - 1
|
|
const totalTip = tipPerKudoInUsd + additionalReactionCount * tipPerReactionInUsd
|
|
const tipPerUser = totalTip / kudosReceivers.length
|
|
const kudosReceiversData = await fetchKudosReceiversData(robot, kudosReceivers, slackWeb)
|
|
|
|
robot.log.trace(`${botName} - ${kudosTimestamp}: ${JSON.stringify(kudosReceiversData)} received ${reactionCount} reactions (~${tipPerUser}$ each)`)
|
|
|
|
for (const userInfo of kudosReceiversData) {
|
|
let userPendingPayout = data.userPendingPayouts[userInfo.user]
|
|
if (!userPendingPayout) {
|
|
userPendingPayout = { kudosCount: 0, reactionCount: 0, balanceInUsd: 0 }
|
|
data.userPendingPayouts[userInfo.user] = userPendingPayout
|
|
}
|
|
|
|
userPendingPayout.kudosCount++
|
|
userPendingPayout.reactionCount += additionalReactionCount
|
|
userPendingPayout.balanceInUsd += tipPerUser
|
|
}
|
|
} else {
|
|
robot.log.trace(`${botName} - ${kudosTimestamp}: ${JSON.stringify(kudosReceivers)} only received ${reactionCount} reactions`)
|
|
}
|
|
} else {
|
|
robot.log.trace(`${botName} - ${kudosTimestamp}: No receivers`)
|
|
}
|
|
|
|
if (!data.lastMessageTimestamp || messageTs > data.lastMessageTimestamp) {
|
|
data.lastMessageTimestamp = messageTs
|
|
}
|
|
}
|
|
|
|
if (!historyPayload.has_more) {
|
|
if (newMessagesProcessed === 0) {
|
|
robot.log.debug(`${botName} - No new entries in ${kudosChannelId} channel history`)
|
|
} else {
|
|
robot.log.debug(`${botName} - Reached end of ${kudosChannelId} channel history`)
|
|
}
|
|
break
|
|
}
|
|
} else {
|
|
robot.log.debug(`${botName} - Failed to fetch ${kudosChannelId} channel history`)
|
|
break
|
|
}
|
|
}
|
|
|
|
return data
|
|
}
|
|
|
|
async function processPendingPayments (robot, data, saveStateAsyncFunc) {
|
|
if (!process.env.DEBUG && !contractAddress) {
|
|
return
|
|
}
|
|
|
|
if (!data.lastPayoutProcessingTimestamp) {
|
|
data.lastPayoutProcessingTimestamp = (new Date()).getTime() / 1000
|
|
await saveStateAsyncFunc(data)
|
|
}
|
|
|
|
const now = (new Date()).getTime() / 1000
|
|
if ((now - data.lastPayoutProcessingTimestamp) < paymentPeriodicityInSecs) {
|
|
return
|
|
}
|
|
|
|
const tokenPrice = await getTokenPrice(tokenID)
|
|
const slackProfileCache = robot['slackProfileCache']
|
|
|
|
const { contract, wallet } = tokenPayments.getContract(contractAddress, privateKey, token.network_id)
|
|
|
|
// Sort users from lowest to highest balance
|
|
const sortedUsers = Object.keys(data.userPendingPayouts).sort((a, b) => compareBalances(data.userPendingPayouts, a, b))
|
|
|
|
// Print stats
|
|
robot.log.debug(`User name\tAmount (${tokenID})\t# Kudos\t# Reactions\tPub key`)
|
|
for (const slackUserId of sortedUsers) {
|
|
const userPendingPayout = data.userPendingPayouts[slackUserId]
|
|
const slackUsername = await slackProfileCache.getSlackUsernameFromSlackId(slackUserId)
|
|
const pubkey = await slackProfileCache.getMainnetPubKeyFromSlackId(slackUserId)
|
|
const tokenBalance = getTokenBalance(userPendingPayout.balanceInUsd, tokenPrice)
|
|
|
|
robot.log.debug(`@${slackUsername}\t${tokenBalance}\t${userPendingPayout.kudosCount}\t${userPendingPayout.reactionCount}\t${pubkey}`)
|
|
}
|
|
|
|
// Make payments
|
|
let totalPayments = 0
|
|
for (const slackUserId of sortedUsers) {
|
|
const userPendingPayout = data.userPendingPayouts[slackUserId]
|
|
const slackUsername = await slackProfileCache.getSlackUsernameFromSlackId(slackUserId)
|
|
const pubkey = await slackProfileCache.getMainnetPubKeyFromSlackId(slackUserId)
|
|
|
|
if (pubkey && userPendingPayout.balanceInUsd > 0) {
|
|
const tokenBalance = getTokenBalance(userPendingPayout.balanceInUsd, tokenPrice)
|
|
totalPayments += tokenBalance
|
|
|
|
try {
|
|
const transaction = await tokenPayments.transfer(contract, wallet, pubkey, (process.env.DEBUG ? '0.0001' : tokenBalance.toString()))
|
|
|
|
// Ignore transactions with the same nonce as the previous one (since those don't seem to be valid and don't appear on etherscan.io)
|
|
if (transaction.nonce !== previousNonce) {
|
|
// Reset the outstanding payout values
|
|
delete data.userPendingPayouts[slackUserId]
|
|
robot.log.info(`${botName} - Made payment to @${slackUsername} (https://etherscan.io/tx/${transaction.hash}): ${JSON.stringify(transaction)}`)
|
|
previousNonce = transaction.nonce
|
|
|
|
await saveStateAsyncFunc(data)
|
|
}
|
|
} catch (error) {
|
|
robot.log.warn(`${botName} - Failed to make payment to @${slackUsername}: ${error}`)
|
|
}
|
|
|
|
// Need to wait for a bit between transactions, otherwise we start receiving errors
|
|
if (interTransactionDelay > 0) {
|
|
await new Promise(resolve => setTimeout(resolve, interTransactionDelay * 1000))
|
|
}
|
|
}
|
|
}
|
|
|
|
data.lastPayoutProcessingTimestamp = (new Date()).getTime() / 1000
|
|
await saveStateAsyncFunc(data)
|
|
|
|
robot.log.debug(`Total payments: ${totalPayments} ${tokenID}`)
|
|
}
|
|
|
|
function compareBalances (userToPendingPayouts, a, b) {
|
|
const x = userToPendingPayouts[a]
|
|
const y = userToPendingPayouts[b]
|
|
|
|
if (x.balanceInUsd > y.balanceInUsd) {
|
|
return 1
|
|
}
|
|
if (x.balanceInUsd < y.balanceInUsd) {
|
|
return -1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
function getTokenBalance (balanceInUsd, tokenPrice) {
|
|
return Math.round(balanceInUsd / tokenPrice * 100) / 100
|
|
}
|
|
|
|
async function fetchKudosReceiversData (robot, kudosReceivers, slackWeb) {
|
|
const slackProfileCache = robot['slackProfileCache']
|
|
const result = []
|
|
|
|
for (const user of kudosReceivers) {
|
|
const pubkey = await slackProfileCache.getMainnetPubKeyFromSlackId(user)
|
|
|
|
result.push({ user: user, pubkey: pubkey })
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
function parseKudosReceivers (message) {
|
|
const match = message.match(userIdRegex)
|
|
|
|
const result = []
|
|
if (match) {
|
|
for (const k of match) {
|
|
result.push(k.substring(1))
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
function countStarReactions (message, kudosReceivers) {
|
|
let reactionCount = 0
|
|
if (message.reactions) {
|
|
const starsRegex = /> \*(\d+) :star:s?\s*\*/g
|
|
reactionCount = getReactionCount(starsRegex, message.text)
|
|
if (reactionCount === 0) {
|
|
const reactionsRegex = /> \*`(\d+)` Reactions?\s*\*/g
|
|
reactionCount = getReactionCount(reactionsRegex, message.text)
|
|
}
|
|
}
|
|
|
|
return reactionCount
|
|
}
|
|
|
|
function getReactionCount (regex, text) {
|
|
let reactionCount = 0
|
|
let m
|
|
|
|
if ((m = regex.exec(text)) !== null) {
|
|
reactionCount = parseInt(m[1])
|
|
}
|
|
|
|
return reactionCount
|
|
}
|
|
|
|
async function getTokenPrice (tokenID) {
|
|
if (tokenID === 'STT') {
|
|
tokenID = 'SNT'
|
|
}
|
|
|
|
const currency = 'USD'
|
|
const response = await axios.get(`https://min-api.cryptocompare.com/data/price?fsym=${tokenID}&tsyms=${currency}`)
|
|
const tokenPrice = parseFloat(response.data[currency])
|
|
return tokenPrice
|
|
}
|