Muit´s lab

When you are bored you make a bot

I was thinking what to do some days ago, and one idea came to my mind. I remembered the last year when some friends were playing with the twitter api for ruby.

Why not to do the same on node?

Repository on github

The code below is on github at github.com/muit/twitterbot. Feel free to download or contribute.

The Twitter api on node

I found that there are many twitter interfaces for node, but my main focus was to do something quite fast and simple, so i decided to use Twit. It is a simple Twitter client api that supports the rest and streaming apis.
I started the bot just stablishing connection with twitter and registering my twitter app.

Then I modified the example bot object that the Twit developers published and adapted it to my neccesities:

var Twit = require('twit');

var CTBot = module.exports = function(config) {  
  this.twit = new Twit(config);
  this.config = config;
};

CTBot.prototype = {  
  config: null,

  tweet: function(status, callback) {
    if (typeof status !== 'string') {
      return callback(new Error('tweet must be of type String'));
    } else if (status.length > 140) {
      return callback(new Error('tweet is too long: ' + status.length));
    }
    this.twit.post('statuses/update', {
      status: status
    }, callback);
  },

  retweet: function(id, callback) {
    this.twit.post('statuses/retweet/:id', {
      id: id
    }, callback);
  },

  tweetRandom: function(query) {
    var self = this;
    var params = {
      q: query,
      lang: this.config.lang || "es",
      since: datestring(),
      result_type: 'mixed'
    };

    this.twit.get('search/tweets', params, function(err, data, response) {
      if (err) return handleError(err);

      var max = 0;

      var tweets = data.statuses;

      var tweet = randIndex(tweets);
      console.log(tweet);
      self.tweet(tweet.text, function(err, reply) {
        if (err) return handleError(err);

        console.log('\nTweet: ' + (reply ? reply.text : reply));
      });
    });
  },

  retweetRandom: function(query) {
    var self = this;
    var params = {
      q: query,
      lang: "es",
      since: datestring(),
      result_type: 'mixed'
    };

    this.twit.get('search/tweets', params, function(err, data, response) {
      if (err) return handleError(err);

      var max = 0;

      var tweets = data.statuses;

      var tweet = randIndex(tweets);
      if (tweet) {
        self.retweet(tweet.id_str, function(err, data, retweet) {
          if (err) return handleError(err);

          console.log('\nRetweet: ' + (data ? data.text : data));
        });
      }
    });
  },



  //
  //  choose a random friend of one of your followers, and follow that user
  //
  followRandom: function(callback) {
    var self = this;

    this.twit.get('followers/ids', function(err, reply) {
      if (err) {
        return callback(err);
      }

      var followers = reply.ids,
        randFollower = randIndex(followers);

      self.twit.get('friends/ids', {
        user_id: randFollower
      }, function(err, reply) {
        if (err) {
          return callback(err);
        }

        var friends = reply.ids,
          target = randIndex(friends);

        self.follow(target, callback);
      });
    });
  },

  follow: function(target, callback) {
    this.twit.post('friendships/create', {
      id: target
    }, callback);
  },

  unfollow: function(target, callback) {
    this.twit.post('friendships/destroy', {
      id: target
    }, callback);
  },

  isFollowingMe: function(user_id, callback) {
    this.twit.get('friendships/lookup', {
      user_id: user_id
    }, function(err, reply) {
      var result = true;
      if (err) {
        console.log("IsFollowingMe error: "+err);
        result = false;
      } else if (reply) {
        result = false;
      }
      callback(result);
    });
  },

  //
  //  prune your followers list; unfollow a friend that hasn't followed you back
  //
  unfollowRandom: function(callback) {
    var self = this;

    this.twit.get('followers/ids', function(err, reply) {
      if (err) return callback(err);

      var followers = reply.ids;

      self.twit.get('friends/ids', function(err, reply) {
        if (err) return callback(err);

        var friends = reply.ids,
          pruned = false;

        while (!pruned) {
          var target = randIndex(friends);

          if (!~followers.indexOf(target)) {
            pruned = true;
            self.unfollow(target, callback);
          }
        }
      });
    });
  }
}

function randIndex(arr) {  
  var index = Math.floor(arr.length * Math.random());
  return arr[index];
};

//get date string for today's date (e.g. '2011-01-01')
function datestring() {  
  var d = new Date(Date.now() - 5 * 60 * 60 * 1000); //est timezone - 5 hours
  return d.getUTCFullYear() + '-' + (d.getUTCMonth() + 1) + '-' + d.getDate();
};

function handleError(err) {  
  console.error('Error(' + err.statusCode + "): " + err.data);
}

I did this to give the main code a simplified way to manage twitter.

Marcov chains to generate random tweets

I used the Marcov chains algorithm to allow the bot learn from some hashtags and write randomly generated tweets.
Basically Markov allows us to generate random strings depending on a dictionary. It joins one word to another depending on the probability. I think this webpage can explain it better: http://setosa.io/blog/2014/07/26/markov-chains/

Same as the twitter code, i created a separated class to markov.
His memory is saved as a json file each 20 minutes, and you may be thinking why didnt he use a database? Becouse i didn´t found the correct way to implement it maintaining a good performance. Anyway, im thinking about using mongoose, maybe on the future.

/****************
 * Markov Class *
 ****************/
function Markov(config, memory, count) {  
  var self = this;

  if (memory && count) {

    this.memory = memory;
    this.count = count;
    console.log("(Markov) Done.");
  }

  self.separator = config.separator;
  self.order = config.order;
}

Markov.prototype = {  
  config: null,
  memory: {},
  separator: ' ',
  order: 2,
  learnSize: 10000,

  count: 0,

  learn: function(txt) {
    this.count++;
    if (this.count > this.learnSize) {
      this.reset();
    }

    var mem = this.memory;
    this.breakText(txt, learnPart);

    this.log("Learned '" + txt + "'");

    function learnPart(key, value) {
      if (!mem[key]) {
        mem[key] = [];
      }
      mem[key].push(value);

      return mem;
    }
  },

  ask: function(seed) {
    if (!seed) {
      seed = this.genInitial();
    }

    return seed.concat(this.step(seed, [])).join(this.separator);
  },

  step: function(state, ret) {
    var nextAvailable = this.memory[state] || [''],
      next = nextAvailable.random();

    //we don't have anywhere to go
    if (!next) {
      return ret;
    }

    ret.push(next);

    var nextState = state.slice(1);
    nextState.push(next);
    return this.step(nextState, ret);
  },

  breakText: function(txt, cb) {
    var parts = txt.split(this.separator),
      prev = this.genInitial();

    parts.forEach(step);
    cb(prev, '');

    function step(next) {
      cb(prev, next);
      prev.shift();
      prev.push(next);
    }
  },

  genInitial: function() {
    var ret = [];

    for (
      var i = 0; i < this.order; ret.push(''), i++
    );

    return ret;
  },

  reset: function() {
    this.memory = {};
    this.count = 0;
  },

  log: function(message) {
    console.log("(Markov) " + message);
  }
};
module.exports = Markov;

Array.prototype.random = function() {  
  return this[Math.floor(Math.random() * this.length)];
};

Make it work

The final step to make this work is to join the twitter api with the markov chains. I also included here the save/load source to make persistent the markov learning. Also some cool logging, config loading and the auto tweet and retweet events.

var fs = require('fs');

var Bot = require('./bot'),  
  Markov = require("./markov"),
  config = require('../config');

var bot = new Bot(config.twitter);

console.log('CTBot: Running.');

if (config.markov && config.markov !== false) {  
  //Load Markov memory
  console.log("(Markov) Loading memory.");
  try {
    var memory = readFileSync("./saved_memory.json");
    var count = readFileSync("./saved_count.json");
  } catch (e) {
    console.log("(Markov) There wasnt a memory saved.");
  }
  var markov = new Markov(config.markov, memory, count);

  //MARKOV Tweet
  var learnQueries = [
    //'@LVPesCSGO',
    '#csgo',
    '@ESEA',
    '@FACEIT',
    '#CSGOProLeague'
  ];

  var stream = bot.twit.stream('statuses/filter', {
    track: learnQueries.join(","),
    language: config.lang || 'es, en'
  });

  stream.on('tweet', function(tweet) {
    //Skip retweets
    if (!tweet.text.startsWith("RT")) {
      markov.learn(tweet.text);
    }
  });

  setInterval(function() {
    bot.tweet(markov.ask(), function(err, data, retweet) {
      if (err) return handleError(err);
      console.log('\nTweet: ' + (data ? data.text : data));
    });
  }, getRandomInt(20 * 60, 40 * 60) * 1000);


  //Save Markov memory
  setInterval(function() {
    writeFile("./saved_count.json", markov.count, function(err) {
      if (err) return console.error(err);
      console.log("\n(Markov) Saved memory.");

      writeFile("./saved_memory.json", markov.memory, function(err) {
        if (err) return console.error(err);
        console.log("(Markov) Saved memory.");
      });
    });
  }, 20 * 60 * 1000);
}


if (config.autoFollow) {  
  setInterval(function() {
    bot.followRandom(function(err, reply) {
      if (err) return handleError(err);

      var name = reply.screen_name;
      var id = reply.id;
      console.log('\nFollowed @' + name);

      setTimeout(function() {
        bot.isFollowingMe(id, function(result) {
          if (result) { //He followed me
            setTimeout(function() {
              bot.unfollow(id, function(err, reply) {
                if (err) {
                  console.log("Couldn´t unfollow. " + err);
                  return;
                }
                console.log('\nUnfollowed @' + reply.screen_name);
              });
            }, 3 * 60 * 60 * 1000); //Random between 4 and 5 hours
          } else { //He didnt follow me
            bot.unfollow(id, function(err, reply) {
              if (err) {
                console.log("Couldn´t unfollow. " + err);
                return;
              }
              console.log('\nUnfollowed @' + reply.screen_name + ".\nHe is not my friend >:(");
            });
          }
        });
      }, 4 * 60 * 60 * 1000); //Random between 4 and 5 hours
    });
  }, 130000);
}

var queries = [  
  //'@LVPesCSGO',
  '#csgo',
  '@ESEA',
  '@FACEIT',
  '#CSGOProLeague'
];

if (config.autoRetweet) {  
  bot.retweetRandom(queries.join(' OR '));
  setInterval(function() {
    bot.retweetRandom(queries.join(' OR '));
  }, getRandomInt(30 * 60, 50 * 60) * 1000);
}



function handleError(err) {  
  console.error('Error(' + err.statusCode + "): " + err.data);
}

function getRandomInt(min, max) {  
  return Math.floor(Math.random() * (max - min + 1)) + min;
}

function readFileSync(file, options) {  
  options = options || {}
  if (typeof options === 'string') {
    options = {
      encoding: options
    }
  }

  var shouldThrow = 'throws' in options ? options.throw : true

  if (shouldThrow) { // i.e. throw on invalid JSON
    return JSON.parse(fs.readFileSync(file, options), options.reviver)
  } else {
    try {
      return JSON.parse(fs.readFileSync(file, options), options.reviver)
    } catch (err) {
      return null
    }
  }
}

function writeFile(file, obj, options, callback) {  
  if (callback == null) {
    callback = options
    options = {}
  }

  var spaces = typeof options === 'object' && options !== null ? 'spaces' in options ? options.spaces : this.spaces : this.spaces

  var str = ''
  try {
    str = JSON.stringify(obj, options ? options.replacer : null, spaces) + '\n'
  } catch (err) {
    if (callback) return callback(err, null)
  }

  fs.writeFile(file, str, options, callback)
}