Разработка персональных ботов для Голоса. Урок 5: автономное голосование и более удобное управление
Сегодня наш бот получает необходимый функционал для полноценной работы. Этот пост больше и длиннее предыдущих, но разбивать его на отдельные уроки нет смысла: мы добавим автономное голосование за авторов, удобное обновление настроек бота и практически исключим возможность повторного голосования за посты.
Предыдущие уроки
Внимание! Для корректной работы проверяйте, чтобы package.json зависимость golos выглядела так: "golos":"ontofractal/golosjs"
Предисловие
- Обязательно задавайте вопросы в комментариях или пишите в http://chat.golos.io, если я что-то непонятно объясняю!
- Требуется базовый уровень понимания JavaScript, веб технологий и командной строки.
- У меня минимальный опыт работы с русскоязычной терминологией в программировании, поэтому некоторые названия я буду оставлять на английском языке.
- В этом уроке используется неоптимальный код, паттерны и структура, в приоритете находится простота и читаемость кода.
Бот куратор
В прошлых уроках у нас было только одно правило -- копирование голосов. В этом мы добавим второе правило, активирующее возможность автономной поддержки авторов.
Автономное голосование отлично подходит тем, кто "вознаграждает авторов, а не лайкает посты".
Совместить работу бота с обеспечением качества кураторства несложно: достаточно просматривать посты за которые проголосовал бот и снимать голос, если пост оказался неудовлетворительного качества. Удалять из списка автономного голосования в случае повторных низкокачественных постов.
Обновление архитектуры бота
В предыдущих уроках настройки бота можно было поменять только в самом коде или при запуске докер контейнера. Это не совсем практично.
Нам нужен удобный "интерфейс" для изменений настроек бота. Для этого мы используем простой, но познавательный метод: список аккаунтов будет размещен на github в виде gist и будем обновляться с помощью HTTP запроса.
Для внедрения этого функционала нам нужно принять важное архитектурное решение. До этого момента наш бот был "stateless" системой, не имея изменяющегося внутреннего состояния.
Исключая баги в имплементации, реакция бота на одинаковые события блокчейна была бы идентичной.
Теперь бот становится "stateful" системой, регулярно обновляя данные о списке аккаунтов из внешнего источника. Реакция бота на события блокчейна будет отличаться в зависимости от внутрненнего состояния программы, в данном случае списка аккаунтов для автономного голосования.
Обновление структуры кода
Код бота усложняется, поэтому для удобства и улучшения читаемости кода, мы разделим код бота на три файла:
- config.js для всех настроек оператора бота
- state_manager.js для управления и обновления состояния (списков аккаунтов)
- main.js код бота для взаимодействия с блокчейном
Конфигурация
const operatorAccountName = process.env.GOLOS_OPERATOR_ACCOUNT // аккаунт оператора бота
// const operatorPostingKey = '5K...' // альтернативный вариант: вводим приватный ключ прямо в код
const operatorPostingKey = process.env.GOLOS_POSTING_KEY // предпочтительный вариант: используем environment variable для доступа к приватному постинг ключу
// нам нужна ссылка на raw gist, запрос к которой возвращает только текст внутри gist-а (без HTML страницы github)
const accountsToUpvoteGistUrl = process.env.GOLOS_ACCOUNTS_TO_UPVOTE_GIST_URL
// https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Operators/Object_initializer
// используем кратку форму записи объектов, где property равняется переменной, а value ES2015
module.exports = {operatorAccountName, operatorPostingKey, accountsToUpvoteGistUrl}
Управление состоянием
Используем популярную библиотеку request
для HTTP запросов.
const config = require('./config.js')
const golos = require('golos') // импортируем модуль голоса
const request = require('request')
// используем destructuring
const {operatorAccountName, accountsToUpvoteGistUrl} = config
let accountPostsToUpvote = []
// управление и обновление списка аккаунтов, чьи голоса бот должен копировать
// может быть настроено аналогично
const accountVotesToCopy = process.env.GOLOS_ACCOUNT_VOTES_TO_FOLLOW.split(',')
console.log(`Бот будет повторять голоса следующих аккаунтов: ${accountVotesToCopy}`)
const botState = {accountPostsToUpvote, accountVotesToCopy}
const updateAccountPostsToUpvoteFromGist = function () {
request(accountsToUpvoteGistUrl, (error, response, body) => {
// если http запрос не будет успешным, то список не обновится до следующего вызова функции
if (!error && response.statusCode === 200) {
console.log(`Успешно обновлен список аккаунтов для автономного голосования: ${accountPostsToUpvote}`)
botState.accountPostsToUpvote = body.split(',') // используем closure для мутации botState
}
})
}
// при импортировании файла во время require('./state_manager') в main.js следующие функции будут автоматически вызываны
// для использования фолловингов в качестве списка для автономного голосования следует
// заменить updateAccountPostsToUpvote на функцию updateAccountPostsToUpvoteFromFollowings
updateAccountPostsToUpvoteFromGist()
setInterval(updateAccountPostsToUpvoteFromGist, 30 * 60 * 1000)
module.exports = botState
В main.js мы присваиваем переменной референс на объект состояния бота, в котором присутствуют properties accountPostsToUpvote
и accountVotesToFollow
. Функции updateAccountPostsToUpvote...
мутируют значения данных properties с заданным интервалом.
Проверка текущих голосов за пост
Теперь бот использует 2 правила для определения реакции на события блокчейна. Из-за взаимодействия двух правил может проявиться неожиданное поведение: повтороное голосование за один пост. Ноды принимают операцию повторного голосования за пост только при изменении веса голоса за данный пост, в процессе обнуляя кураторское вознаграждение.
Более того, алгоритм позволяет проводить операцию голосования за один пост не больше чем 6 раз (включая обнуление голоса или флаги).
Как мы можем предотвратить повторное голосование? Используем метод API get_active_votes
для получения всех текущих голосов за данный пост.
Пример ответа ноды после выполнения JSONRPC вызова get_active_votes
выглядит приблизительно так:
[
{
"percent": 10000, "reputation": "28759071217014",
"rshares": "18897453242648", "time": "2017-01-14T09:20:21",
"voter": "example-account", "weight": "51460692508758354"
},
{
"percent": 5000, "reputation": "55869071217014",
"rshares": "4853242648", "time": "2017-01-13T18:50:41",
"voter": "example-account-2", "weight": "31354692508758354"
},
{..},
...
]
Зная структуру ответа, мы можем напсать следующую функцию для проверки и голосования за пост в случае прохождения проверки.
const checkStateAndUpvotePost = (author, permlink, weight, delay) => {
// откладываем голосование за пост на delay
setTimeout(
() => golos.api.getActiveVotes(
author,
permlink,
(err, result) => {
// проверяем есть ли в списке активных голосов аккаунт оператора
const operatorHasVoted = result.map(x => x.voter).includes(operatorAccountName)
// если нет JSONRPC запроса и аккаунт оператора не голосовал --> проголосовать
if (!err && !operatorHasVoted) {
// передаем данные для голосования на ноду
golos.broadcast.vote(operatorPostingKey, operatorAccountName, author, permlink, weight, (err, result) => {
if (err) {
console.log('произошла ошибка с передачей голоса на ноду:')
console.log(err)
} else {
// используем ES2016 template strings, которые позволяют форматировать строки интерполируя expressions с помощью ${}
console.log(`@${operatorAccountName} проголосовал за пост ${permlink} написанный @${author} c весом ${weight}`)
}
})
} else if (operatorHasVoted) {
// пишем лог, если оператор уже голосовал за этот пост/комментарий
console.log(`Изебгая повтора, бот не проголосовал за пост ${permlink} написанный ${author}`)
}
}
),
delay
)
}
Реагируем на посты
У нас уже есть функция проверки/голосования за пост. Нам нужна функция реагирования на новые операции типа comment
.
const reactToIncomingComments = (commentData) => {
// console.log(commentData)
const {author, permlink, parent_author} = commentData
// проверяем входит ли проголосовавший аккаунт в список аккаунтов, за которые бот должен голосовать автономно
const isApprovedAuthor = botState.accountPostsToUpvote.includes(author)
// в блокчейне операция "comment" обозначает как посты, так и комментарии
// у постов parent_author равняется пустой строке
const isPost = parent_author === ''
// задаем вес голоса по умолчанию
const defaultWeight = 10000
// задаем время для голосования по умолчанию через 15 минут после публикации
const defaultDelay = 15 * 60 * 1000
if (isApprovedAuthor && isPost) {
console.log(`Обнаружено соответствие правилу автономного голосования: ${author} опубликовал ${permlink}`)
checkStateAndUpvotePost(author, permlink, defaultWeight, defaultDelay)
}
}
const selectOpHandler = (op) => {
// используем destructuring, очень удобную фичу EcmaScript2016
// это, конечно, не паттерн метчинг Elixir или Elm, но все равно сильно помогает улучшить читаемость кода
const [opType, opData] = op
if (opType === 'vote') {
reactToIncomingVotes(opData)
}
else if (opType === 'comment') {
reactToIncomingComments(opData)
}
}
Синхронизируем список аккаунтов с подписками оператора
По запросу нескольких читателей добавляю секцию с кодом, который синхронизирует список аккаунтов для автономного голосования с текущими подписками аккаунта оператора.
Для этого используем метод API get_following
Пример результата выполнения get_following
выглядит примерно так:
Список фолловингом отсортирован по алфавиту.
[ { id: '8.0.8718',
follower: 'ontofractal',
following: 'aleksandraz',
what: [ 'blog' ] },
{ id: '8.0.8681',
follower: 'ontofractal',
following: 'alexna',
what: [ 'blog' ] },
{...}, ...
]
Используя информацию о форме ответа пример, напишем функцию для синхронизации списка аккаунтов для автономного голосования и списка фолловингов.
const updateAccountPostsToUpvoteFromFollowings = function () {
// первый параметр: имя аккаунта,
// второй параметр является курсором хоть так и не называется
// в данном случае указывает с какого фолловинга начинать отсчет (по алфавитному порядку)
// третий параметр: тип фолловинга, в этом случае 'blog'
// четвертый параметр: запрашиваемое количество элементов в списке, не больше 100
golos.api.getFollowing(operatorAccountName, '', 'blog', 100, (err, result) => {
if (err) {
console.log("Во время JSONRPC вызова getFollowing произошла ошибка. ")
console.log(err)
} else {
const followings = result.map(x => x.following)
console.log(`Успешно обновлен список аккаунтов для автономного голосования: ${followings}`)
botState.accountPostsToUpvote = followings // используем closure для мутации botState
}
})
}
Все вместе
Код уже стал слишком большим, чтобы публиковать его в посте. Вместо этого даю ссылку на commit в репозитории бейби бота.
В этом уроке мы научились синхронизировать состояние бота с внешними источниками данных для удобства управления, подключили возможность автономного голосования и обеспечили отсутствие повторного голосования.
О коллбеках
В этом уроке используется мой порт библиотеки steemjs, основным интерфейсом которого являются функции принимающие коллбеки.
Глубокие уровни вложенности коллбэков считаются анти-паттерном и ведут к "аду коллбеков", в прошлом значительной проблемой в javascript программировании. Одним из решений были Promises, которые стали частью стандарта в ES2016. Недостатки Promises для читаемости были похожи: длинные цепочки методов .then()
ведут к "пирамиде ужаса"
Элегантным решением этой проблемы является использование async/await, новых кивордов, прошедших стандартизацию в ES2017. Keyword async меняет поведение функции: внутри нее можно использовать keyword await, return
в async функции будет всегда возвращать Promise. Киворд await позволяет поставить на паузу выполнение async функции до окончания Promise находящегося справа от await.
C использованием async/await функция запуска нашего бота выглядела бы так:
async function startBot() {
try {
const props = await dynamicGlobalProperties()
const height = pluckBlockHeight(props)
startFetchingBlocks(height)
} catch (e) {
console.log(e)
}
}
startBot()
Справка о async/await: MDN
В следующих уроках мы сделаем рефакторинг кода и заменим коллбеки async/await функциями.
Важно
Код выпущен под MIT лицензией. Всю ответственность за использование кода вы принимаете на себя.