Building a Twitter Bot: Part 2 (Finish it all up)
Lesson 20: Building a Twitter Bot
Today I want to finish up our Twitter Bot. There's really not a lot left to
do, we just have to tie a bunch of loose-ends up, parameterize some stuff and
make it reusable. (Remember our previous discussion: write good, readable,
usable code.)
Identify Dependencies
There are always dependencies in code, you'll never get around this. I want
to identify the ones in our code today, and use them to push us to the next
stage of development.
- Dependency 1: the Consumer Key and Secret (hell, include the Access Token
and Secret);
- Dependency 2: the text file (our lyric file);
- Dependency 3: the directory for said text file;
- Dependency 4: what to split on (
\r\n\r\n
in our case, but it could be
anything);
- Dependency 5: the account ID;
This actually sums our dependencies up quite nicely. Now I want to define
them in some sort of "configuration" file, in our case I'm going to use JSON
because it's just so damn convenient. Let's define the aforementioned JSON
config file:
{
"ConsumerKey": "abcd1234",
"ConsumerSecret": "abcd1234",
"AccessToken": "1234-abcd1234",
"AccessTokenSecret": "abcd1234",
"AccountId": 9223372036854775807,
"TextFile": "File.txt",
"Split": "\r\n",
"BaseDir": "."
}
Too easy. We can define the entire thing as 8 lines, that describe the type
of data we expect. These are all the dependencies we have. All of them.
Wrapped up with a nice little bow.
The next step is to bring those dependencies into our code. This is
bewilderingly easy with F#: we're going to Install-Package FSharp.Data
if
you're using NuGet (just type that in the Package Manager Console
), if you
use packet
then you're smarter than me and know exactly how to install it.
(I know nothing of packet
, but I hear it's nice.)
Now I save the config JSON above in a file called Config.sample.json
, we'll
need it to pull into F# so that we can define a type
for the configuration.
This is literally one line (well two, with the open
):
open FSharp.Data
type Parameters = JsonProvider<"Config.sample.json">
And done. The JsonProvider
will read our Config.sample.json
file, and
define an entire type for us to use. We can read something in as follows:
let parameters = configFile |> File.ReadAllText |> Parameters.Parse
Easy enough. The configFile
will be a filename, I default to Config.json
but you can use whatever you like.
Fix our Percent Encoding
If you follow me on Twitter, you'll notice that I built a bot
at the request of a good friend / colleague of mine, and I
recently tweeted that I broke it. (Which I did, but I fixed it.)
This issue was entirely in our percentEncode
, and as I mentioned way back
in the last lesson, we didn't account for Unicode characters. This was bad,
at least for this new bot (for the Don McLean bot it was a non-issue).
This is fixed easily enough, and I want to give you the code to do so:
open System.Text
let encoding = Encoding.UTF8
let percentEncode : string -> string =
encoding.GetBytes
>> Array.collect (fun x ->
match x with
| cint when cint = 0x2Duy
|| cint = 0x2Euy
|| cint = 0x5Fuy
|| cint = 0x7Euy
|| (cint >= 0x30uy && cint <= 0x39uy)
|| (cint >= 0x41uy && cint <= 0x5Auy)
|| (cint >= 0x61uy && cint <= 0x7Auy) -> [|cint|]
| cint -> sprintf "%%%s" (cint.ToString("X")) |> Seq.toArray |> Array.map byte)
>> Array.map char
>> String
So our previous code relied on analyzing the char
, which is completely
inappropriate for Twitter when dealing with non-ASCII text. The char
in
.NET is a UTF-16 character, whereas Twitter works with UTF-8. So, we have to
make an adjustment for that. Instead of using the char
, we just use the
System.Text.Encoding.UTF8.GetBytes(str)
function to get a byte-array for
the text we need to encode, then we iterate through it, test each char for
the necessity of encoding (because UTF-8 is full ASCII-128 compatible, we
don't need to do anything special, our same character codes will work), and
return the same resultant String
. So nothing comsuming percentEncode
has
to change (awesome!)._Activator
Analyze a Timeline
So now we get to the hard part. For the bot to be successful I wanted it to
be capable of analyzing it's timeline and determining what position it was in
to tweet next. This would mean no state has to be retained anywhere, you
could in fact run the .exe
from any computer and it would still continue
the correct sequence.
To do this, we have to read a timeline. To do that, we have to abstract our
OAuth / API handling code. UGH
Alright, so this isn't actually that hard. We want to build a composition
chain that goes through the full API request.
If you lost our sign
function, I have it right here:
let sign httpMethod endpoint oauthParams queryParams postParams =
Array.concat [|oauthParams; queryParams; postParams|]
|> Array.sortBy fst
|> Array.map keyValueToStr
|> stringJoin "&"
|> Array.singleton
|> Array.append [|httpMethod |> httpMethodToStr; endpoint|]
|> Array.map percentEncode
|> stringJoin "&"
|> hasher
You'll see it's slightly different. I have this httpMethodToStr
function
which, by the looks of things, takes something called httpMethod
and
converts it to a string. Ah right, I should share that!
type HttpMethod = | Get | Post
let httpMethodToStr = function | Get -> "GET" | Post -> "POST"
Because I like having the help of the compiler, I built a quick HttpMethod
type which is just a Get
or Post
, and the httpMethodToStr
converts that
to the capital-case value.
The other helper there:
let keyValueToStr (key, value) = sprintf "%s=%s" key (value |> percentEncode)
Again, pretty simple. So now we can see that our sign
function is really
just some chains. You'll also see I have a hasher
, which we didn't have
last time.
let hmacSha1Hash (encoding : Encoding) (key : string) (str : string) : string =
use hmacSha1 = new HMACSHA1(key |> encoding.GetBytes)
str |> encoding.GetBytes |> hmacSha1.ComputeHash |> Convert.ToBase64String
let baseOauthParams = [|("oauth_signature_method", "HMAC-SHA1"); ("oauth_version", "1.0"); ("oauth_consumer_key", parameters.ConsumerKey); ("oauth_token", parameters.AccessToken)|]
let hasher = hmacSha1Hash encoding ([|parameters.ConsumerSecret; parameters.AccessTokenSecret|] |> stringJoin "&")
Here we see our parameters
from the config come into play. We pull the
appropriate static OAuth parameters (signature method, version, etc.) into a
standard array, then we build a hasher
which is a partially-applied
function which will only require us to add the final string to hash. The
key
and encoding
are embedded.
For ease of use, I built a sendRequest
, which looks as follows:
let sendRequest (submitMethod, oauthString, url : string, postParams) =
try
use wc = new WebClient()
wc.Headers.Add(HttpRequestHeader.Authorization, oauthString)
match submitMethod with
| Post -> wc.UploadString(url, postParams)
| Get -> wc.DownloadString(url)
|> Some
with
| :? WebException as ex ->
use sr = new StreamReader(ex.Response.GetResponseStream())
printfn "Failure (%s): %A" ex.Message (sr.ReadToEnd())
None
| ex ->
printfn "Failure: %A" ex
None
This was really quite simple. Add headers, determine submit method, send the
request, return. If it errors, print the error and return None
.
Now we previously had a buildTweetRequest
which would do a lot of work to
create a signed OAuth request. Today we took a lot of that away and instead
created a buildBaseRequest
, which can be a more abstract version:
let baseBuildRequest submitMethod path postParams queryParams =
let url = [baseUrl; path] |> stringJoin ""
let timestamp = DateTime.Now |> timeToEpoch
let nonce = Guid.NewGuid().ToString("N")
let oauthParams =
[|("oauth_timestamp", timestamp.ToString()); ("oauth_nonce", nonce)|]
|> Array.append baseOauthParams
let oauthString =
[|("oauth_signature", sign submitMethod url oauthParams queryParams postParams)|]
|> Array.append oauthParams
|> formOAuthString
let queryString = queryParams |> Array.map keyValueToStr |> stringJoin "&"
let queryString = if queryString.Length > 0 then sprintf "?%s" queryString else queryString
let postString = postParams |> Array.map keyValueToStr |> stringJoin "&"
(submitMethod, oauthString, (sprintf "%s%s" url queryString), postString)
You'll notice that it does a lot of the boilerplate, but it requires quite a
few parameters to function properly. This will create the base request, which
can be directly piped to sendRequest
. This means our new
buildTweetRequest
looks as follows:
let buildTweetRequest tweet =
baseBuildRequest Post "statuses/update.json" [||] [|("status", tweet)|]
Again, nothing complex, we just build a base request with the appropriate
parameters, and then return it.
Consumption of sending a tweet at this point is literally
tweet |> buildTweetRequest |> sendRequest
. You can even check if it
succeeeded or failed.
Of course, we aren't worried about that yet. We want to get a timeline first:
let buildGetTimeline (userid : int64) =
baseBuildRequest Get "statuses/user_timeline.json" [||] [|("user_id", userid.ToString()); ("count", "25"); ("tweet_mode", "extended")|]
Oh yeah, getting a timeline is super smooth. Adding new API support, in fact,
is very simple and easy. We might be in the home stretch.
let getTimelineTweets = buildGetTimeline >> sendRequest
OK, maybe not. We have the timeline now, but we have to build out the ability
to read it.
I have a Twitter Timeline.sample.json
which I'll upload as an attachment to
this post, which I use to create a type-provider type:
type Timeline = JsonProvider<"Twitter Timeline.sample.json">
I also created a Tweet
type where I only care about the important things:
type Tweet = { TId : int64; Text : string; Truncated : bool; CreatedAt : DateTime }
Because I don't give a hoot about the rest of the data, I made it a slimmed
down version of an actual tweet.
We're also going to need to consume statuses/show.json
, which I'm providing
below:
let buildShowRequest (id : int64) =
baseBuildRequest Get "statuses/show.json" [||] [|("id", id.ToString()); ("tweet_mode", "extended")|]
This is another pretty basic function, it takes a tweet ID and gives us the
request to send to get that tweet.
Finally, to get the timeline into memory, we need a timelineToTweets
function:
let timelineToTweets (timeline : string) : Tweet array option =
let mapFn (x : Timeline.Root) = { TId = x.Id; Text = x.FullText; Truncated = x.Truncated; CreatedAt = DateTime.ParseExact(x.CreatedAt.Substring(0, 19), "ddd MMM dd HH:mm:ss", null) }
let idsEqual t1 t2 = t1.TId = t2.TId
let tweets = timeline |> Timeline.Parse |> Array.map mapFn
let idsLookup = tweets |> Array.filter (fun x -> x.Truncated) |> Array.map (fun x -> x.TId)
idsLookup
|> Array.choose (buildShowRequest >> sendRequest >> Option.map (Timeline.Parse >> Array.map mapFn))
|> Array.concat
|> Some
|> Option.map (fun x ->
tweets |> Array.map (fun t1 ->
x |> Array.filter (idsEqual t1) |> function | [|t|] -> t | _ -> t1))
It should be easy to see that we're just loading a timeline JSON into memory,
checking what tweets are "truncated", then looking those up, and mapping them
to a new tweet array. (If we had to get a new tweet, we'll use it, otherwise
we keep the original.)
Alright, so we have an array of tweets (timeline) in memory, what do we do
next? Well, we need to calculate what index we were most recently at, so we
can find the next one in the list.
Because our lyric program has several tweets that will be identical in the
same sequence, we need to look at more than one tweet in a row. I pulled
25
tweets from the timeline (look at the count
parameter in
buildGetTimeline
), then make sure that we get as many matches as possible.
let getIndex (tweets : Tweet array) (comparisons : string array) =
let comparisons = comparisons |> Array.rev |> Array.map newLineReplace
let tweets =
tweets
|> Array.sortByDescending (fun x -> x.CreatedAt)
|> Array.map (fun x -> x.Text)
|> Array.filter (fun x -> comparisons |> Array.contains x)
let rec alg skip =
if skip >= comparisons.Length then 0
else
let si = comparisons |> Array.skip skip |> Array.findIndex ((=) tweets.[0]) |> (+) skip
let matches = tweets |> Array.fold (fun (r, i) t -> (r && (comparisons.[i % comparisons.Length] = t), i + 1)) (true, si) |> fst
if matches then si else alg (si + 1)
if tweets.Length = 0 then 0 else alg 0
This is a somewhat convoluted function, but it basically starts at a tweet,
then determines if it matched from there on in the comparisons
(reference)
array. If it does, then that's our current index.
Consume it all
Consuming this whole thing is done by the following function:
member this.run send =
let path = [rootDir; parameters.TextFile] |> stringJoin ""
let file = File.ReadAllText(path)
let parts = file.Split([|parameters.Split|], StringSplitOptions.RemoveEmptyEntries) |> Array.filter (Seq.length >> (>=) 280) |> Array.map (fun x -> x.Trim())
let existingTweets =
parameters.AccountId
|> getTimelineTweets
|> Option.map timelineToTweets
|> Option.flatten
|> function | None -> [||] | Some v -> v
let newStatus = parts.[(parts.Length - getIndex existingTweets parts) % parts.Length] |> newLineReplace
let req = buildTweetRequest newStatus
if send then (req, newStatus, req |> sendRequest)
else (req, newStatus, None)
Wait, member
? That's new.
So F# allows us to define types with members, and in fact, I defined almost
all the functions above as let
definitions in the type
, and then the only
necessary member
is the single function above. This can be run by a client
to either send the next tweet, or just load and return it. I built that in
for the case when we want to debug, and see what it's going to send next
without actually doing so.
All in all, the entire TwitterBot
is 150 lines of F#:
module TwitterBot
open System
open System.IO
open System.Net
open System.Security.Cryptography
open System.Text
open FSharp.Data
type HttpMethod = | Get | Post
type Tweet = { TId : int64; Text : string; Truncated : bool; CreatedAt : DateTime }
type Parameters = JsonProvider<"Config.sample.json">
type Timeline = JsonProvider<"Twitter Timeline.sample.json">
let encoding = Encoding.UTF8
let percentEncode : string -> string =
encoding.GetBytes
>> Array.collect (fun x ->
match x with
| cint when cint = 0x2Duy
|| cint = 0x2Euy
|| cint = 0x5Fuy
|| cint = 0x7Euy
|| (cint >= 0x30uy && cint <= 0x39uy)
|| (cint >= 0x41uy && cint <= 0x5Auy)
|| (cint >= 0x61uy && cint <= 0x7Auy) -> [|cint|]
| cint -> sprintf "%%%s" (cint.ToString("X")) |> Seq.toArray |> Array.map byte)
>> Array.map char
>> String
let httpMethodToStr = function | Get -> "GET" | Post -> "POST"
let newLineReplace (str : string) = str.Replace("\r\n", " / ")
let stringJoin sep (strs : string seq) = String.Join(sep, strs)
let keyValueToStr (key, value) = sprintf "%s=%s" key (value |> percentEncode)
let keyValueToQuotedStr (key, value) = sprintf "%s=\"%s\"" key (value |> percentEncode)
let timeToEpoch (time : DateTime) =
let epoch = DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)
(time.ToUniversalTime() - epoch).TotalSeconds |> bigint
let hmacSha1Hash (encoding : Encoding) (key : string) (str : string) : string =
use hmacSha1 = new HMACSHA1(key |> encoding.GetBytes)
str |> encoding.GetBytes |> hmacSha1.ComputeHash |> Convert.ToBase64String
type Bot(configFile) =
let parameters = configFile |> File.ReadAllText |> Parameters.Parse
let baseUrl = "https://api.twitter.com/1.1/"
let rootDir = sprintf "%s%s" parameters.BaseDir "\\"
let baseOauthParams = [|("oauth_signature_method", "HMAC-SHA1"); ("oauth_version", "1.0"); ("oauth_consumer_key", parameters.ConsumerKey); ("oauth_token", parameters.AccessToken)|]
let hasher = hmacSha1Hash encoding ([|parameters.ConsumerSecret; parameters.AccessTokenSecret|] |> stringJoin "&")
let formOAuthString : (string * string) array -> string =
Array.sortBy fst
>> Array.map keyValueToQuotedStr
>> stringJoin ", "
>> sprintf "OAuth %s"
let sign httpMethod endpoint oauthParams queryParams postParams =
Array.concat [|oauthParams; queryParams; postParams|]
|> Array.sortBy fst
|> Array.map keyValueToStr
|> stringJoin "&"
|> Array.singleton
|> Array.append [|httpMethod |> httpMethodToStr; endpoint|]
|> Array.map percentEncode
|> stringJoin "&"
|> hasher
let sendRequest (submitMethod, oauthString, url : string, postParams) =
try
use wc = new WebClient()
wc.Headers.Add(HttpRequestHeader.Authorization, oauthString)
match submitMethod with
| Post -> wc.UploadString(url, postParams)
| Get -> wc.DownloadString(url)
|> Some
with
| :? WebException as ex ->
use sr = new StreamReader(ex.Response.GetResponseStream())
printfn "Failure (%s): %A" ex.Message (sr.ReadToEnd())
None
| ex ->
printfn "Failure: %A" ex
None
let baseBuildRequest submitMethod path postParams queryParams =
let url = [baseUrl; path] |> stringJoin ""
let timestamp = DateTime.Now |> timeToEpoch
let nonce = Guid.NewGuid().ToString("N")
let oauthParams =
[|("oauth_timestamp", timestamp.ToString()); ("oauth_nonce", nonce)|]
|> Array.append baseOauthParams
let oauthString =
[|("oauth_signature", sign submitMethod url oauthParams queryParams postParams)|]
|> Array.append oauthParams
|> formOAuthString
let queryString = queryParams |> Array.map keyValueToStr |> stringJoin "&"
let queryString = if queryString.Length > 0 then sprintf "?%s" queryString else queryString
let postString = postParams |> Array.map keyValueToStr |> stringJoin "&"
(submitMethod, oauthString, (sprintf "%s%s" url queryString), postString)
let buildShowRequest (id : int64) =
baseBuildRequest Get "statuses/show.json" [||] [|("id", id.ToString()); ("tweet_mode", "extended")|]
let buildGetTimeline (userid : int64) =
baseBuildRequest Get "statuses/user_timeline.json" [||] [|("user_id", userid.ToString()); ("count", "25"); ("tweet_mode", "extended")|]
let buildTweetRequest tweet =
baseBuildRequest Post "statuses/update.json" [||] [|("status", tweet)|]
let getTimelineTweets = buildGetTimeline >> sendRequest
let timelineToTweets (timeline : string) : Tweet array option =
let mapFn (x : Timeline.Root) = { TId = x.Id; Text = x.FullText; Truncated = x.Truncated; CreatedAt = DateTime.ParseExact(x.CreatedAt.Substring(0, 19), "ddd MMM dd HH:mm:ss", null) }
let idsEqual t1 t2 = t1.TId = t2.TId
let tweets = timeline |> Timeline.Parse |> Array.map mapFn
let idsLookup = tweets |> Array.filter (fun x -> x.Truncated) |> Array.map (fun x -> x.TId)
idsLookup
|> Array.choose (buildShowRequest >> sendRequest >> Option.map (Timeline.Parse >> Array.map mapFn))
|> Array.concat
|> Some
|> Option.map (fun x ->
tweets |> Array.map (fun t1 ->
x |> Array.filter (idsEqual t1) |> function | [|t|] -> t | _ -> t1))
let getIndex (tweets : Tweet array) (comparisons : string array) =
let comparisons = comparisons |> Array.rev |> Array.map newLineReplace
let tweets =
tweets
|> Array.sortByDescending (fun x -> x.CreatedAt)
|> Array.map (fun x -> x.Text)
|> Array.filter (fun x -> comparisons |> Array.contains x)
let rec alg skip =
if skip >= comparisons.Length then 0
else
let si = comparisons |> Array.skip skip |> Array.findIndex ((=) tweets.[0]) |> (+) skip
let matches = tweets |> Array.fold (fun (r, i) t -> (r && (comparisons.[i % comparisons.Length] = t), i + 1)) (true, si) |> fst
if matches then si else alg (si + 1)
if tweets.Length = 0 then 0 else alg 0
member this.run send =
let path = [rootDir; parameters.TextFile] |> stringJoin ""
let file = File.ReadAllText(path)
let parts = file.Split([|parameters.Split|], StringSplitOptions.RemoveEmptyEntries) |> Array.filter (Seq.length >> (>=) 280) |> Array.map (fun x -> x.Trim())
let existingTweets =
parameters.AccountId
|> getTimelineTweets
|> Option.map timelineToTweets
|> Option.flatten
|> function | None -> [||] | Some v -> v
let newStatus = parts.[(parts.Length - getIndex existingTweets parts) % parts.Length] |> newLineReplace
let req = buildTweetRequest newStatus
if send then (req, newStatus, req |> sendRequest)
else (req, newStatus, None)
This is almost everything necessary, the only thing left to do is run it.
Build a robust client
Because I want robust bot, and I want to be able to hot-swap config files, I
set our bot up so that it takes two command line arguments (if you want):
- a
bool
ean to indicate whether or not to send;
- a
string
to indicate the configuration file to load;
Basically, I want to specify the following:
type Parms = { Send : bool; Config : string }
Well that's easy. I've demonstrated this before, but my default pattern for
this type of situation is as follows:
let stripQuotes (s : string) = if s.StartsWith "\"" && s.EndsWith "\"" then s.Substring(1, s.Length - 2) else s
let defParms = { Send = false; Config = "Config.json" }
let parms =
match argv with
| [|s; c|] -> { defParms with Send = (s = "true"); Config = c |> stripQuotes }
| [|s|] -> { defParms with Send = (s = "true") }
| _ -> defParms
printfn "%A" parms
Essentially, I specify reasonable defaults, then when a command-line
interface calls it, you can send arguments to override.
Then, the basic setup:
let bot = parms.Config |> TwitterBot.Bot
let request, tweet, response = bot.run parms.Send
let encodedTweet = tweet |> percentEncode
printfn "Request: %A" request
printfn "Tweet: (%i chars) %A" tweet.Length tweet
printfn "Encoded: (%i chars) %A" encodedTweet.Length encodedTweet
printfn "Response: %A" response
If the Parms.Send
was true, the tweet sent, so in the case it's not then I
do some human prompting:
if parms.Send |> not then
let rec getResponse () =
printfn "Send tweet? (Y for yes, N for no)"
let key = Console.ReadKey().Key
printfn ""
match key with
| ConsoleKey.Y -> true
| ConsoleKey.N -> false
| k ->
printfn "Invalid key: %A" k
() |> getResponse
match () |> getResponse with
| true ->
let request, tweet, response = bot.run true
let encodedTweet = tweet |> percentEncode
printfn "Request: %A" request
printfn "Tweet: (%i chars) %A" tweet.Length tweet
printfn "Encoded: (%i chars) %A" encodedTweet.Length encodedTweet
printfn "Response: %A" response
printfn "Press enter to exit..."
Console.ReadLine() |> ignore
| false -> ()
This means we get the best of both worlds: human-interface compatibility, and
argument compatibility. Overall, the whole file is 47 lines:
open System
open TwitterBot
type Parms = { Send : bool; Config : string }
[<EntryPoint>]
let main argv =
let stripQuotes (s : string) = if s.StartsWith "\"" && s.EndsWith "\"" then s.Substring(1, s.Length - 2) else s
let defParms = { Send = false; Config = "Config.json" }
let parms =
match argv with
| [|s; c|] -> { defParms with Send = (s = "true"); Config = c |> stripQuotes }
| [|s|] -> { defParms with Send = (s = "true") }
| _ -> defParms
printfn "%A" parms
let bot = parms.Config |> TwitterBot.Bot
let request, tweet, response = bot.run parms.Send
let encodedTweet = tweet |> percentEncode
printfn "Request: %A" request
printfn "Tweet: (%i chars) %A" tweet.Length tweet
printfn "Encoded: (%i chars) %A" encodedTweet.Length encodedTweet
printfn "Response: %A" response
if parms.Send |> not then
let rec getResponse () =
printfn "Send tweet? (Y for yes, N for no)"
let key = Console.ReadKey().Key
printfn ""
match key with
| ConsoleKey.Y -> true
| ConsoleKey.N -> false
| k ->
printfn "Invalid key: %A" k
() |> getResponse
match () |> getResponse with
| true ->
let request, tweet, response = bot.run true
let encodedTweet = tweet |> percentEncode
printfn "Request: %A" request
printfn "Tweet: (%i chars) %A" tweet.Length tweet
printfn "Encoded: (%i chars) %A" encodedTweet.Length encodedTweet
printfn "Response: %A" response
printfn "Press enter to exit..."
Console.ReadLine() |> ignore
| false -> ()
0
I include the stripQuotes
as when we send a path from argument -> program
we may need to quote it, which will be included in the argv
element. So, we
just need to strip them when we expect them.
And that's it, we've built the whole bot, and we can now automate it entirely
in one tight, self-contained program. There's only one external dependency,
and even that is only for easy of development. We built all the functionality
we wanted, and then some, and did it without any major pain points.
Shoutout to Janelle Shane, who's work inspired the second bot written
with this script, and who's work also encouraged me to finish it up and tie
this two-section blog post series up. I'll be continuing on with the next
installment of "Getting Started with Programming and Getting Absolutely
Nowhere" in the next post.
Files: Twitter Timeline.sample.json (70.11 kb); Config.sample.json (245.00 bytes); TwitterBot.fs (7.23 kb); Program.fs (1.76 kb)