Perl programmer for hire: download my resume (PDF).
John Bokma's Hacking & Hiking

Posting a tweet picked from a file at random

March 8, 2019

Today I finished a small Perl program that I started on yesterday; a program to pick a tweet from a file at random and post it to Twitter. The idea is to post automatically, using cron, 3 or 4 tweets a day to promote my blog.

Edit: A slightly better written version is available on GitHub.

Authentication file format

Instead of hardcoding the authentication information, a bad practice I have seen on a few occasions, this program reads the required information from a file. This makes it easy to support multiple accounts.

The tokens and keys obtained from Twitter must be entered in a file using the following format:

#
# Configuration for the john_bokma Twitter account
#

consumer_key        = xxxxxxxxxxxxxxxxxxxxxxxxx
consumer_secret     = xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
access_token        = xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
access_token_secret = xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Lines starting with a # are comments and are ignored.

I name this file the same as my Twitter account using the conf extension to make clear it's a configuration file; john_bokma.conf.

The sub that reads this file is the most complicated part of this Perl script because I considered it a good idea to add additional steps that catch possible mistakes:

sub read_conf {

    my $filename = shift;

    my %conf;
    my @keys = qw( consumer_key consumer_secret
                   access_token access_token_secret );
    my %required;
    @required{ @keys } = ();
    my $fh = path( $filename )->openr_utf8();
    while ( my $line = <$fh> ) {
        chomp $line;
        if ( my ( $key, $value ) = $line =~ /^([a-z_]+)\s*[:=]\s*(\S+)$/ ) {
            exists $required{ $key } or
                die "Unexpected key $key at $filename line $.\n";
            !exists $conf{ $key } or
                die "Duplicate key $key at $filename line $.\n";
            $conf{ $key } = $value;
        }
    }
    exists $conf{ $_ } or die "Missing key $_ at $filename line $.\n"
        for @keys;

    close( $fh );

    return \%conf;
}

The sub opens the file of which the filename is given as a parameter and reads line by line from the file. The chomp function is used to remove the end-of-line and next the code checks if the line matches a certain pattern. If a match occurs a check is made if this is a required configuration key. If not, the program terminates, reporting that the key is unexpected.

Next, it checks if the key is encountered for the first time. If not, the program terminates, reporting that the key is a duplicate.

When all lines have been read, but before closing the file, the program checks if all keys have been provided. Since the program also reports the line number, $., this has to happen before the file is closed otherwise this variable is reset to 0.

After the file has been closed, a reference to the configuration hash is returned.

Tweets file format

The file with tweets uses a format that's the same as a fortune cookies file, each tweet is separated by a % character on a line of its own. An example of a file with 3 tweets:

An earth #snake on the road

#mexico #nature

http://johnbokma.com/blog/2017/07/07/an-earth-snake-on-the-road.html
%
Adding Color to a #LaTeX Resume

http://johnbokma.com/blog/2017/05/30/adding-color-to-latex-resume.html
%
A juvenile #opossum in the kitchen

#mexico #nature

http://johnbokma.com/blog/2017/04/29/a-juvenile-opossum-in-the-kitchen.html

I start the name of this text file with my Twitter account name: john_bokma-tweets.txt.

The sub that reads this file and splits it into tweets is very simple because it uses the slurp_utf8 function of Path::Tiny to read the entire file into memory and Perl's split to obtain the separate tweets from this:

sub read_tweets {

    my $filename = shift;
    return [ split /^%\n/m, path( $filename )->slurp_utf8() ];
}

The sub returns a reference to an array of tweets.

Posting a Tweet

The posting a tweet sub is as follows:

sub tweet {

    my ( $conf, $tweet ) = @_;

    my $nt = Net::Twitter::Lite::WithAPIv1_1->new(
        traits => [ 'API::RESTv1_1' ],
        ssl    => 1,
        %$conf,
    );

    my $result;
    my $error_message;
    try {
        $result = $nt->update( $tweet );
    }
    catch {
        my $error = $_;
        if ( blessed $error and $error->isa( 'Net::Twitter::Lite::Error' ) ) {
            $error_message = $error->message . "\n" . $error->error . "<<<\n";
        }
        else {
            $error_message = $error;
        }
    };
    die $error_message if defined $error_message;

    return;
}

The first parameter is the configuration read from a file which is used to authenticate the bot. The second parameter is the tweet itself.

The posting of the tweet, using the update method, is done in a try block. If an exception (error) is thrown the catch block handles this. If the exception is a blessed reference and a Net::Twitter::Lite::Error the error message is build by calling methods on this object. Otherwise it's assumed the message is plain text or can be stringified to plain text.

The complete Perl program

The complete program, tweetfile.pl, is given below. Note that you don't have to make any changes to the program itself as the authentication configuration and tweets are read from files which must be specified on the command line.

In order to obtain help about the program start it with the --help option as follows: perl tweetfile.pl --help.

#!/usr/bin/perl
#
# (c) John Bokma, 2019
#
# This program is free software; you can redistribute it and/or modify it
# under the same terms as Perl itself.

use strict;
use warnings;

use Try::Tiny;
use Path::Tiny;
use Getopt::Long;
use Net::Twitter::Lite::WithAPIv1_1;
use Scalar::Util 'blessed';

my $conf_filename;
my $tweets_filename;
my $help    = 0;
my $quiet   = 0;
my $dry_run = 0;
my $first;

GetOptions(
    'conf=s'   => \$conf_filename,
    'tweets=s' => \$tweets_filename,
    'help'     => \$help,
    'quiet'    => \$quiet,
    'dry-run'  => \$dry_run,
    'first'    => \$first,
);

if ( $help ) {
    show_help();
    exit;
}

if ( !defined $conf_filename || !defined $tweets_filename ) {
    warn "Error: both --conf and --tweets must be given\n\n";
    show_help();
    exit( 1 );
}

$quiet or binmode STDOUT, ':encoding(UTF-8)';
$quiet or print "Reading configuration from $conf_filename\n";
my $conf = read_conf( $conf_filename );

$quiet or print "Reading tweets from $tweets_filename\n";
my $tweets = read_tweets( $tweets_filename );

my $count = @$tweets;
$count or die "No tweets found";
$quiet or print "Found $count tweets\n";
my $index = defined $first ? 0 : rand $count;
my $tweet = $tweets->[ $index ];

$quiet or print "Going to tweet:\n\n$tweet\n";
tweet( $conf, $tweet ) unless $dry_run;


sub read_conf {

    my $filename = shift;

    my %conf;
    my @keys = qw( consumer_key consumer_secret
                   access_token access_token_secret );
    my %required;
    @required{ @keys } = ();
    my $fh = path( $filename )->openr_utf8();
    while ( my $line = <$fh> ) {
        chomp $line;
        if ( my ( $key, $value ) = $line =~ /^([a-z_]+)\s*[:=]\s*(\S+)$/ ) {
            exists $required{ $key } or
                die "Unexpected key $key at $filename line $.\n";
            !exists $conf{ $key } or
                die "Duplicate key $key at $filename line $.\n";
            $conf{ $key } = $value;
        }
    }
    exists $conf{ $_ } or die "Missing key $_ at $filename line $.\n"
        for @keys;

    close( $fh );

    return \%conf;
}

sub read_tweets {

    my $filename = shift;
    return [ split /^%\n/m, path( $filename )->slurp_utf8() ];
}

sub tweet {

    my ( $conf, $tweet ) = @_;

    my $nt = Net::Twitter::Lite::WithAPIv1_1->new(
        traits => [ 'API::RESTv1_1' ],
        ssl    => 1,
        %$conf,
    );

    my $result;
    my $error_message;
    try {
        $result = $nt->update( $tweet );
    }
    catch {
        my $error = $_;
        if ( blessed $error and $error->isa( 'Net::Twitter::Lite::Error' ) ) {
            $error_message = $error->message . "\n" . $error->error . "<<<\n";
        }
        else {
            $error_message = $error;
        }
    };
    die $error_message if defined $error_message;

    return;
}

sub show_help {

    print <<'END_HELP';
NAME
        tweetfile.pl - Posts a random tweet from a file to twitter

SYNOPSIS
        tweetfile.pl [--quiet] [--dry-run] [--first]
            --conf=<your.conf> --tweets=<your-tweets.txt>
        tweetfile.pl --help

DESCRIPTION

        Tweets a single message picked at random from a file given by
        the --tweets argument. In this file, each tweet must be separated by
        a % character on a line by itself.

        The authentication keys and tokens must be made available in a file
        given by the --conf argument. In this file, the following names must
        be available: consumer_key, consumer_secret, access_token, and
        access_token_secret. Each name must be followed by either a : or
        a = character and its value as provided by Twitter.

        The --quiet option prevents the program from printing information
        regarding the progress and which tweet it's going to post.

        The --dry-run option prevents the program from actually posting
        the selected tweet to Twitter. This is useful in testing.

        The --first option picks the first tweet instead of a random one.
        This is useful if you add each new tweet to the top of the file.

        The --help option shows this information.
END_HELP

    return;
}

Installing missing Perl modules

If you run the Perl program for the first time and get an error message similar to the one below:

Can't locate Path/Tiny.pm in @INC (you may need to install the Path::Tiny module
) (@INC contains: ...

you have to install one or more missing Perl modules. If you are on Ubuntu in order to obtain the package name make each directory lower case and replace each / with a - and append the lowecase version of the filename without the extension, In this example you get:

path-tiny

Next, place lib in front and add -perl to the end and use apt-cache search to verify that this is the correct name of the package, i.e.:

$ apt-cache search libpath-tiny-perl
libpath-tiny-perl - file path utility

Install the package as follows:

$ sudo apt-get install libpath-tiny-perl

Note: in these examples do not copy the $ character but the part following it.

If you don't get any result try to remove the filename. For example:

Can't locate Net/Twitter/Lite/WithAPIv1_1.pm in @INC (you may need to install th
e Net::Twitter::Lite::WithAPIv1_1 module) (@INC contains: ...

would per above recipe result in:

$ apt-cache search libnet-twitter-lite-withapiv1_1-perl

which gives no results. Removing the filename part results in:

$ apt-cache search libnet-twitter-lite-perl
libnet-twitter-lite-perl - interface to the Twitter API

This is the correct package. It can be installed as follows:

$ sudo apt-get install libnet-twitter-lite-perl

On my test virtual machine, running Ubuntu 18.10, installing those two packages was enough to make the Perl program work.

Note that instead of adding modules to your system's Perl installation I do recommend to use Perlbrew instead.

Running the bot at specific times

The cron program can be used to run the tweet bot at specific times, see Running a Perl program via cron for more information.

Related