Short mastodon scripts with Elefren

Yesterday, someone I follow on mastodon talked about how they wrote a quick python script to count how many people they followed (and how many followed them) from mastodon.social, to see how much their social graph would be affected if they were to block mastodon.social from their instance.

This made me want to do something similar, but I really wanted to see how easy it would be to do it with rust and elefren. So, after initially failing because of a bug in elefren (which I posted about yesterday) which also prompted a new elefren release, I present the finished script:

extern crate elefren;
extern crate promptly;

use std::error::Error;
use std::io::{self, Write};

use promptly::prompt;
use elefren::prelude::*;

fn main() -> Result<(), Box<Error>> {
    let registration = Registration::new("https://ceilidh.space")
            .client_name("following-count")
            .build()?;
    let url = registration.authorize_url()?;
    println!("Go to this URL in your browser: {}", url);
    io::stdout().flush()?;
    let code: String = prompt("Paste code here");

    let client = registration.complete(&code)?;
    let me = client.verify_credentials()?;

    // this retrieves all the users I'm following
    let following = client.following(&me.id)?
        // and this turns the page of Accounts I get into an 
        // iterator that will take care of all the pagination logic
        // and turns it into a single iterator of Accounts
        .items_iter();

    let mut total = 0;
    let ms = following
        // first, let's go through each Account and see if the user
        // is on mastodon.social
        .filter(|account| {
            // first let's update the total counter
            total += 1;

            // account.acct might be just `username` for local
            // accounts, or `username@domain` for remote names.
            //
            // splitn() gives us an iterator, with 0, 1, or 2 items in it
            let mut parts = account.acct.splitn(2, '@');

            // probably won't happen, but let's guard against it
            // anyway
            if parts.next().is_none() {
                return false;
            }

            // if it's a remote user, then there will be a second item
            // in the iterator
            if let Some(domain) = parts.next() {
                domain == "mastodon.social"
            } else {
                false
            }
        })
        .count();

    println!(
        "You are following {} users on mastodon.social out of {} total",
        ms,
        total
    );

    Ok(())
}

This is ~36 lines of actual code, 34 if you don't count the extern crate declarations. Which is not bad, for a compiled language, right?

Still, we should be able to do better. There are a few aspects of elefrens API that could be shortened to make tasks like this easier.

The Authenticated User

First, let's look at this section:

let client = registration.complete(&code)?;
let me = client.verify_credentials()?;

// this retrieves all the users I'm following
let following = client.following(&me.id)?

Here we see that, in order to pull down the list of follows, we actually need to make 2 calls, one to retrieve the account information for the authenticated user, and another to actually pull down the follows. This “get something for the logged in user” is bound to be a pretty common use-case, so what if we added some methods to make it easier to do this? There are specifically 4 methods that could probably use a complementary method that performs the action using the authenticated user's account id:

fn followers(&self, id: &str) -> Result<Page<Account, H>>
fn following(&self, id: &str) -> Result<Page<Account, H>>
fn reblogged_by(&self, id: &str) -> Result<Page<Account, H>>
fn favourited_by(&self, id: &str) -> Result<Page<Account, H>>

So let's assume we're going to add these 4 methods:

fn follows_me(&self) -> Result<Page<Account, H>>
fn followed_by_me(&self) -> Result<Page<Account, H>>
fn reblogged_by_me(&self) -> Result<Page<Account, H>>
fn favourited_by_me(&self) -> Result<Page<Account, H>>

This gets rid of a line of code from the snippet above:

let client = registration.complete(&code)?;

// this retrieves all the users I'm following
let following = client.followed_by_me()?;

Retrieving OAuth Details

Next, let's take a look at this snippet:

let registration = Registration::new("https://ceilidh.space")
    .client_name("following-count")
    .build()?;
let url = registration.authorize_url()?;
println!("Go to this URL in your browser: {}", url);
io::stdout().flush()?;
let code: String = prompt("Paste code here");

let client = registration.complete(&code)?;

This is ugly. We have helpers for loading & saving OAuth information into various data formats, but we don't have anything to help with the initial task of interactively retrieving this information on the command line. Now, not every application is going to be using the command line for authentication, so anything we add for this probably shouldn't be front-and-center in the API, but we could at least add a helper for it. We could turn the above snippet into something more like the following:

use elefren::helpers::cli;

let registration = Registration::new("https://ceilidh.space")
    .client_name("following-count")
    .build()?;
let client = cli::authenticate(&registration)?;

Which looks much, much better, and saves us another 4 lines of code!

Helpers methods on models

The last snippet I want to look at is in the closure that we pass to .filter:

// account.acct might be just `username` for local 
// accounts, or `username@domain` for remote names.
//
// splitn() gives us an iterator, with 0, 1, or 2 items in it
let mut parts = account.acct.splitn(2, '@');

// probably won't happen, but let's guard against it anyway
if parts.next().is_none() {
    return false;
}

// if it's a remote user, then there will be a second item
// in the iterator
if let Some(domain) = parts.next() {

This is 5 lines of code, plus a bunch of comments, just to take the Account::acct string and extract the domain from it. I'm counting the comments here because the code is not terribly self-documenting, and it's purpose might not be immediately obvious without them. We can do better.

Right now the elefren::entities::account::Account struct is pretty much just used to hold the deserialized information that is returned from the mastodon API. But that does not mean it has to stay an inert container of fields, right? Getting the domain for a specific account is bound to be something other users might want to do, so let's make it into a helper method:

impl Account {
    // it's `Option<String>` because a local account won't
    // have a domain as part of the `acct` string
    fn domain(&self) -> Option<String> {
        // pretty much the same logic from the above snippet
    }
}

Now, in our filter() closure, we can just do this:

 .filter(|account| {
            // first let's update the total counter
            total += 1;

            // account.acct might be just `username` for local
            // accounts, or `username@domain` for remote
            // names.
            if let Some(ref domain) = account.domain() {
                domain == "mastodon.social"
            } else {
                false
            }
        })

Which eliminates another 4 lines of code!

Result

So with all these changes, what would the full example look like? Well, like this:

extern crate elefren;
extern crate promptly;

use std::error::Error;

use promptly::prompt;
use elefren::prelude::*;
use elefren::helpers::cli;

fn main() -> Result<(), Box<Error>> {
    let registration = Registration::new("https://ceilidh.space")
            .client_name("following-count")
            .build()?;
    let client = cli::authenticate(&registration)?;
    // this retrieves all the users I'm following
    let following = client.followed_by_me()?
        // and this turns the page of Accounts I get into an
        // iterator that will take care of all the pagination
        // logic and turns it into a single iterator of Accounts
        .items_iter();

    let mut total = 0;
    let ms = following
        // first, let's go through each Account and see if the
        // user is on mastodon.social
        .filter(|account| {
            // first let's update the total counter
            total += 1;

            // account.acct might be just `username` for local
            // accounts, or `username@domain` for remote
            // names.
            if let Some(ref domain) = account.domain() {
                domain == "mastodon.social"
            } else {
                false
            }
        })
        .count();

    println!(
        "You are following {} users on mastodon.social out of {} total",
        ms,
        total
    );

    Ok(())
}

Our final script is down to 26 lines! Still not as small as if we were using Ruby or Python, but for a compiled language I think that's pretty good.

My Social Graph

So what did this script compute for me?

You are following 216 users on mastodon.social out of 877 total

Yikes, that's a quarter of the people I follow!