Recently while browsing reddit I came up on a post in the /r/rust subreddit which was a link to a YouTube playlist for the Rusty-Days conference, however there was no way I could find the contents of the playlist without going to YouTube on my phone. This was a nuance so I went to YouTube and curated the list.
This was going to be tiresome if I'd have to do it every time I see a post that links to a YouTube playlist.
So here we are writing a bot do this task for everyone. This bot will run on a server somewhere (hopefully forever) and curate playlist info for all the people who avail its service.
Creating Credentials For Our Bot
In order to write our bot we first need to get some credentials from reddit so that we can access reddit apis programmatically.
First we need an application id and secret so that reddit can know our application. We can get this information by going to preferences/app and clicking are you a developer? create an app...
button cause we definitely are.
Reddit lets us choose the type of the app we want to build. The three types of app are :
Web app: Runs as part of a web service on a server you control. Can keep a secret.
Installed app: Runs on devices you don't control, such as the user's mobile phone. Cannot keep a secret, and therefore, does not receive one.
Script app: Runs on hardware you control, such as your own laptop or server. Can keep a secret. Only has access to your account.
More info about about the apps can be found here.
We choose the script
type, enter a name and description for our bot, and use the dummy url http://www.example.com/unused/redirect/uri
for the redirect url.
We have now created the credentials with Client Id : TjC0s2uTaTHYCg
and Client Secret : mrkAaWitnXLf_DiRagIRS_33cD8
.
Using and Storing the credentials
We can now hard code the credentials in our source code and use like this.
# main.rs
static CLIENT_ID:&str="TjC0s2uTaTHYCg";
static CLIENT_SECRET:&str="mrkAaWitnXLf_DiRagIRS_33cD8";
fn main(){
println!("Client ID: {}",CLIENT_ID);
println!("Client Secret: {}",CLIENT_SECRET);
}
DeltaManiac @ ~/git/rust/vyom
└─ $ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.08s
Running `target/debug/vyom`
Client ID: SmQ7CzGkKA62yA
Client Secret: UItY35BYBEN_rFVnGVzud9Pig6g
This is a very easy and clear way to handle credentials but it is flawed.
If we need to change the credentials then we would have to change the code, rebuild the app and restart the app.
If we decide to share the code with someone or push it github, it will expose our credentials, which can be used to hijack our account and do bad things.
So lets see if we can fix the first problem, by moving the credentials out of the source code. But where do we put it then ? If you're thinking about environment variables then you're absolutely right. Environment variables are a good place to store such values and they are fairly easy to change.
# main.rs
fn main(){
match std::env::var("CLIENT_ID1") {
Ok(client_id) => println!("Client ID: {}", client_id),
Err(e) => panic!("Couldn't read CLIENT_ID ({})", e),
};
match std::env::var("CLIENT_SECRET1") {
Ok(client_secret) => println!("Client Secret: {}", client_secret),
Err(e) => panic!("Couldn't read CLIENT_SECRET ({})", e),
};
}
Since our bot wont work without a client_id
and a client_secret
we call panic! so that the application exits with an error.
(base) DeltaManiac @ ~/git/rust/vyom
└─ $ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.41s
Running `target/debug/vyom`
thread 'main' panicked at \'Couldn\'t read CLIENT_ID (environment variable not found),
src/main.rs:9:19
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
# Set the environment variables
(base) DeltaManiac @ ~/git/rust/vyom
└─ $ export CLIENT_SECRET=UItY35BYBEN_rFVnGVzud9Pig6g
(base) DeltaManiac @ ~/git/rust/vyom
└─ $ export CLIENT_ID=SmQ7CzGkKA62yA
(base) DeltaManiac @ ~/git/rust/vyom
└─ $ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.47s
Running `target/debug/vyom`
Client ID: SmQ7CzGkKA62yA
Client Secret: UItY35BYBEN_rFVnGVzud9Pig6g
Most of the time we don't really want to export a lot of environment variables manually. It is exhausting. We could fix this problem by writing a shell script that has all our export
statements... or we can use dotenv. Dotenv is a crate that provides us a way to put environment variables in a .env
file and read them. Dotenv is smart to enough to only read from the file if the Environment Variable is not set on the system.
We first add the dotenv
dependency to our Cargo.toml
file.
# Cargo.toml
[package]
name = "vyom"
version = "0.1.0"
authors = ["DeltaManiac <maxpaynered@gmail.com>"]
edition = "2018"
[dependencies]
dotenv_codegen="0.15.0" # dotenv dependency
We then setup the environment variables in the .env
file.
# .env
CLIENT_ID=test_123
CLIENT_SECRET=test_321
Test=DeezTests
We finally modify our code to use the dotenv
crate.
# main.rs
#[macro_use]
extern crate dotenv_codegen;
fn main(){
println!("Env Not on Sys: {}",dotenv!("Test"));
println!("Client ID: {}",dotenv!("CLIENT_ID"));
println!("Client Secret: {}",dotenv!("CLIENT_SECRET"));
}
(base) DeltaManiac @ ~/git/rust/vyom
└─ $ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.08s
Running `target/debug/vyom`
Env Not on Sys: ss #Value from the .env file
Client ID: SmQ7CzGkKA62yA #Value from the system
Client Secret: UItY35BYBEN_rFVnGVzud9Pig6g #Value from the system
How will the bot work ?
The bot will listen to a mention like /u/VyomBot
and would check if the post is a link to a YouTube playlist or at a later stage if the parent comment of the mention is a YouTube playlist.
Setting up Reddit
We can follow these steps to setup reddit for testing/developing this bot
Created a new user called VyomBot so that the bot can be mentioned via
/u/VyomBot
Registered a new app of
script
type for/u/VyomBot
Create a new subreddit
/r/VyomBot
as a test play ground.
Create a new post with the link to the playlist.
Talking to Reddit
Getting Messages from Inbox
Lets start off by querying reddit to see if we have a new mention and printing the message. We will use the roux crate for interacting with the reddit apis.
Direct quote from the description of the crate
A simple, asynchronous Reddit API wrapper implemented in Rust.
This means that we have to use a framework like tokio to provide the async runtime for our bot.
Lets go about doing that.
Add the dependencies to our Cargo.toml file.
# Cargo.toml
[package]
name = "vyom"
version = "0.1.0"
authors = ["DeltaManiac <maxpaynered@gmail.com>"]
edition = "2018"
[dependencies]
dotenv_codegen="0.15.0" # dotenv dependency
roux="1.0.0" # roux dependency
tokio = {version="0.2.22",features=["macros"]} # tokio dependency and only enable the macro feature
Update our code to use the library and call the reddit apis.
# main.rs
#[macro_use]
extern crate dotenv_codegen;
#[macro_use]
extern crate log; // Used for logging
use roux::Reddit;
#[tokio::main]
async fn main() {
match Reddit::new(
dotenv!("VYOM_USERAGENT"),
dotenv!("VYOM_CLIENT_ID"),
dotenv!("VYOM_CLIENT_SECRET"),
)
.username(dotenv!("VYOM_USERNAME"))
.password(dotenv!("VYOM_PASSWORD"))
.login()
.await
{ // Try to make a new client with the credentials
Ok(client) => match client.inbox().await {
// Fetch the inbox of the logged in user
Ok(listing) => {
println!("Message Count {}", listing.data.children.len());
dbg!(listing.data.children.get(0).unwrap());
}
Err(_) => {
error!("Failed to fetch messages");
}
},
Err(e) => panic!(e),
}
}
When we run the program we get the number of messages we have and the dbg!
macro shows what the passed in variable which in this case is a InboxItem
struct, looks like.
(base) DeltaManiac @ ~/git/rust/vyom
└─ $ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 3.71s
Running `target/debug/vyom`
Message Count 5
[src/main.rs:24] &listing.data.children.get(0).unwrap().data = InboxItem {
id: "g0vfbra",
subject: "username mention",
was_comment: true,
author: Some(
"DeltaManiac",
),
parent_id: Some(
"t3_i6fk15",
),
subreddit_name_prefixed: Some(
"r/VyomBot",
),
new: true,
type: "username_mention",
body: "/u/VyomBot",
dest: "VyomBot",
body_html: "<!-- SC_OFF --><div class=\"md\"><p><a href=\"/u/VyomBot\">/u/VyomBot</a></p>\n</div><!-- SC_ON -->",
name: "t1_g0vfbra",
created: 1596987973.0,
created_utc: 1596959173.0,
context: "/r/VyomBot/comments/i6fk15/test_playlist/g0vfbra/?context=3",
}
We can use the new
property to identify if this is a message that we had previously read.
The type property can be used to determine if the item is a comment or a username mention.
We can use this to iterate over the messages retrieved and and determine the messages that we have to reply to.
Replying to the message
Roux provides us a convenient method aptly name comment
to reply to the message. Let's go ahead and use this to reply to the message.
# main.rs
async fn main() {
...
...
// Fetch the inbox of the logged in user
Ok(listing) => {
for message in listing.data.children.iter() {
is message unread and of type "username_mention"
if message.data.new && message.data.r#type == "username_mention" {
match client
.comment(
"You have been Noted by Vyom. Please Stand By!",
&message.data.name.as_str(),
)
.await
{
Ok(_) => info!("Replied to {}", message.data.name),
Err(_) => error!("Failed to reply to mention"),
};
}
}
}
...
...
Psst.., I'll let you in on something cool. In rust
type
is a reserved keyword. In most programming languages you can use a keyword only as keyword, e.g. you cannot have a variable calledfor
. In rust we can usetype
as an attribute of a struct and access it by specifing it as a raw string using ther#
likemessage.data.r#type
Now that we have written the code lets run it and see what happens..
(base) DeltaManiac @ ~/git/rust/vyom
└─ $ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 4.45s
Running `target/debug/vyom`
Nice! It logged that we replied to the mention. Lets run it again, this time it should not reply to an allready replied message as we have read it.
[2020-08-09T12:59:25Z INFO vyom] Replied to t1_g0vfbra
(base) DeltaManiac @ ~/git/rust/vyom
└─ $ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.10s
Running `target/debug/vyom`
[2020-08-09T12:59:29Z INFO vyom] Replied to t1_g0vfbra
(base) DeltaManiac @ ~/git/rust/vyom
└─ $ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.09s
Running `target/debug/vyom`
[2020-08-09T12:59:32Z INFO vyom] Replied to t1_g0vfbra
Damn!
IT REPLIED AGAIN!!!😞
And this is what the subreddit looks like now.
Time to find that pesky bug and get rid of it for good.
Lets go to reddit and see what the inbox looks like.
Well, its just as we suspected, when we reply to a mention with the comment
function it does not change the status of the message. Sifting through the documentation of roux
we can find a method that marks a message as read
.
The place we are at right now reminds of a the poem The Road Not Taken by Robert Frost. It talks about how the author finds two roads diverging in the wood and he ponders which one to travel upon. I ask you to take a few minutes and read the poem, its beautiful.
I'll be waiting!
Oh BTW the code can be found on the part-I
branch here
Top comments (0)