Quantcast
Channel: Stefan Bohacek
Viewing all articles
Browse latest Browse all 71

Making a Mastodon bot with Google Sheets and Apps Scripts: Part 2

$
0
0

A Mastodon post from a test bot account showing an image of a line chart attached.

In my previous tutorial I walked you through setting up a Mastodon bot that posts data from your Google Sheets spreadsheet. Here I will show you how you can upload a chart as an image with your post.

As a reminder, this was the finished script.

function postToMastodon(event) {
  try {
    if (event) {
      const range = event.range;
      console.log(
        `cell in column ${range.columnStart} and row ${range.rowStart} was updated`
      );

      if (range.columnStart === 2 && range.columnEnd === 2) {
        const scriptProperties = PropertiesService.getScriptProperties();
        const accessToken = scriptProperties.getProperty(
          "MASTODON_ACCESS_TOKEN_SECRET"
        );
        const apiUrl = scriptProperties.getProperty("MASTODON_API_URL");

        const row = 1;
        const col = 5;
        const sum = SpreadsheetApp.getActiveSheet()
          .getRange(row, col)
          .getValue();

        const USDollar = new Intl.NumberFormat("en-US", {
          style: "currency",
          currency: "USD",
        });

        const response = UrlFetchApp.fetch(`${apiUrl}/statuses`, {
          method: "POST",
          contentType: "application/json",
          payload: JSON.stringify({
            status: `The total sum of donations is ${USDollar.format(sum)}.`,
          }),
          headers: {
            Accept: "application/json",
            "Content-Type": "application/json",
            Authorization: "Bearer " + accessToken,
          },
        });

        console.log(response.getContentText());
      } else {
        console.log("skipping...");
      }
    } else {
      console.log("no data was sent");
    }
  } catch (err) {
    console.log(`there was an error: ${err.message}`);
  }
}

And this is the table we worked with.

DateAmountTotal=SUM(B:B)
2024-01-24$59.78
2024-02-02$41.03
2024-02-04$67.58
2024-02-06$3.27
2024-02-11$61.96

For the purpose of this tutorial, I am going to assume that you know how to create a chart in Google Sheets. If not, the official Google Sheets documentation should have you covered. Here’s what my chart looks like.

A basic line chart showing donations over time. Data is from the table in the article above.

Posting an image on Mastodon is a two-step process.

  • First, we need to upload the image using the api/v2/media endpoint.
  • And then we attach the image to the post.

An important part of the image upload will be the image description, or alt text, which will be a bit more involved, so let’s address that towards the end.

First, we will need to grab the chart from our spreadsheet.

const sheet = event.range.getSheet();
const charts = sheet.getCharts();
const chart = charts[0];
const imageData = chart.getBlob();

And now we can add the code that handles the image upload.

const mediaUploadResponse = UrlFetchApp.fetch(
  `${apiUrl.replace("/api/v1", "/api/v2")}/media`,
  {
    method: "POST",
    headers: {
      Authorization: "Bearer " + accessToken,
    },
    payload: {
      file: imageData.setName("temp-chart-file.png")
    },
    muteHttpExceptions: true,
  }
);

console.log("debug:response", mediaUploadResponse.getContentText());
const mediaData = JSON.parse(mediaUploadResponse.getContentText());
const mediaId = mediaData.id;

The mediaUploadResponse response object will contain the ID of our uploaded image, which we can then attach to the status we’re posting.

const postStatusResponse = UrlFetchApp.fetch(`${apiUrl}/statuses`, {
  method: "POST",
  headers: {
    Accept: "application/json",
    "Content-Type": "application/json",
    Authorization: "Bearer " + accessToken,
  },
  payload: JSON.stringify({
    status: `The total sum of donations is ${USDollar.format(sum)}.`,
    media_ids: [mediaId],
  }),
});

Here’s the script we have so far in full.

function postToMastodon(event) {
  try {
    if (event) {
      const range = event.range;
      console.log(
        `cell in column ${range.columnStart} and row ${range.rowStart} was updated`
      );

      if (range.columnStart === 2 && range.columnEnd === 2) {
        const scriptProperties = PropertiesService.getScriptProperties();
        const accessToken = scriptProperties.getProperty(
          "MASTODON_ACCESS_TOKEN_SECRET"
        );
        const apiUrl = scriptProperties.getProperty("MASTODON_API_URL");

        const row = 1;
        const col = 5;
        const sum = SpreadsheetApp.getActiveSheet()
          .getRange(row, col)
          .getValue();

        const USDollar = new Intl.NumberFormat("en-US", {
          style: "currency",
          currency: "USD",
        });

        const sheet = event.range.getSheet();
        const charts = sheet.getCharts();
        const chart = charts[0];
        const imageData = chart.getBlob();

        const mediaUploadResponse = UrlFetchApp.fetch(
          `${apiUrl.replace("/api/v1", "/api/v2")}/media`,
          {
            method: "POST",
            headers: {
              Authorization: "Bearer " + accessToken,
            },
            payload: {
              file: imageData.setName("temp-chart-file.png"),
            },
            muteHttpExceptions: true,
          }
        );

        console.log("debug:response", mediaUploadResponse.getContentText());
        const mediaData = JSON.parse(mediaUploadResponse.getContentText());
        const mediaId = mediaData.id;

        const postStatusResponse = UrlFetchApp.fetch(`${apiUrl}/statuses`, {
          method: "POST",
          headers: {
            Accept: "application/json",
            "Content-Type": "application/json",
            Authorization: "Bearer " + accessToken,
          },
          payload: JSON.stringify({
            status: `The total sum of donations is ${USDollar.format(sum)}.`,
            media_ids: [mediaId],
          }),
        });

        console.log(postStatusResponse.getContentText());
      } else {
        console.log("skipping...");
      }
    } else {
      console.log("no data was sent");
    }
  } catch (err) {
    console.log(`there was an error: ${err.message}`);
  }
}

Great, you should now see your bot posting an image of your chart! We’re nearly finished.

The last thing we need to do is to add a good image description for our chart for folks who rely on screen readers to browse the web, or folks with poor internet connection. (I wrote a short article on bots and accessibility for more tips on this topic.)

Ideally, we’d use something like:

A line chart showing daily donations between FIRST_DATE and LAST_DATE with $XX average and $XX median donations.

To get this text, we will need to figure out:

  • the average donation amount
  • the median donation amount
  • the first date when a donation was made
  • and the last date when a donation was made

To make things a bit easier, we can compute the average and median values inside our spreadsheet.

DateAmountTotal=SUM(B:B)
2024-01-24$59.78Median=MEDIAN(B:B)
2024-02-02$41.03Average=AVERAGE(B:B)
2024-02-04$67.58
2024-02-06$3.27
2024-02-11$61.96

And then we can get the values the same way we got the total sum.

const donationsSum = SpreadsheetApp.getActiveSheet()
  .getRange(1, 5)
  .getValue();

const donationsMedian = SpreadsheetApp.getActiveSheet()
  .getRange(2, 5)
  .getValue();

const donationsAverage = SpreadsheetApp.getActiveSheet()
  .getRange(3, 5)
  .getValue();

And we can also format these values nicely.

const USDollar = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
});

const donationsSumFormatted = `${USDollar.format(donationsSum)}`;
const donationsMedianFormatted = `${USDollar.format(donationsMedian)}`;
const donationsAverageFormatted = `${USDollar.format(
  donationsAverage
)}`;

Now let’s look at the date.

const chartData = chart.getRanges()[0].getValues();
// const chartDataHeaders = chartData.shift();

const dates = chartData
  .map((row) => row[0])
  .filter((val) => String(val).trim().length > 0);

const dateOptions = {
  weekday: "long",
  year: "numeric",
  month: "long",
  day: "numeric",
};

const firstDate = new Date(dates.at(0)).toLocaleDateString(
  undefined,
  dateOptions
);

const lastDate = new Date(dates.at(-1)).toLocaleDateString(
  undefined,
  dateOptions
);

Now we can write our description.

const description = `A line chart showing daily donations between ${firstDate} and ${lastDate} with ${donationsAverageFormatted} average and ${donationsMedianFormatted} median donations.`;

And we’re ready to put this all together.

function postToMastodon(event) {
  try {
    if (event) {
      const range = event.range;

      console.log(
        `cell in column ${range.columnStart} and row ${range.rowStart} was updated`
      );

      if (range.columnStart === 2 && range.columnEnd === 2) {
        const scriptProperties = PropertiesService.getScriptProperties();
        const accessToken = scriptProperties.getProperty(
          "MASTODON_ACCESS_TOKEN_SECRET"
        );
        const apiUrl = scriptProperties.getProperty("MASTODON_API_URL");

        const sheet = event.range.getSheet();
        const charts = sheet.getCharts();
        const chart = charts[0];
        const imageData = chart.getBlob();

        const donationsSum = SpreadsheetApp.getActiveSheet()
          .getRange(1, 5)
          .getValue();

        const donationsMedian = SpreadsheetApp.getActiveSheet()
          .getRange(2, 5)
          .getValue();

        const donationsAverage = SpreadsheetApp.getActiveSheet()
          .getRange(3, 5)
          .getValue();

        const USDollar = new Intl.NumberFormat("en-US", {
          style: "currency",
          currency: "USD",
        });

        const donationsSumFormatted = `${USDollar.format(donationsSum)}`;
        const donationsMedianFormatted = `${USDollar.format(donationsMedian)}`;
        const donationsAverageFormatted = `${USDollar.format(
          donationsAverage
        )}`;

        const chartData = chart.getRanges()[0].getValues();

        const dates = chartData
          .map((row) => row[0])
          .filter((val) => String(val).trim().length > 0);

        const dateOptions = {
          weekday: "long",
          year: "numeric",
          month: "long",
          day: "numeric",
        };

        const firstDate = new Date(dates.at(0)).toLocaleDateString(
          undefined,
          dateOptions
        );

        const lastDate = new Date(dates.at(-1)).toLocaleDateString(
          undefined,
          dateOptions
        );

        const description = `A line chart showing daily donations between ${firstDate} and ${lastDate} with ${donationsAverageFormatted} average and ${donationsMedianFormatted} median donations.`;

        console.log(description);

        const mediaUploadResponse = UrlFetchApp.fetch(
          `${apiUrl.replace("/api/v1", "/api/v2")}/media`,
          {
            method: "POST",
            headers: {
              Authorization: "Bearer " + accessToken,
            },
            payload: {
              file: imageData.setName("temp-chart-file.png"),
              description,
            },
            muteHttpExceptions: true,
          }
        );

        console.log("debug:mediaUploadResponse", mediaUploadResponse.getContentText());
        const mediaData = JSON.parse(mediaUploadResponse.getContentText());
        const mediaId = mediaData.id;

        const postStatusResponse = UrlFetchApp.fetch(`${apiUrl}/statuses`, {
          method: "POST",
          headers: {
            Accept: "application/json",
            "Content-Type": "application/json",
            Authorization: "Bearer " + accessToken,
          },
          payload: JSON.stringify({
            status: `The total sum of donations as of today is ${donationsSumFormatted}!`,
            media_ids: [mediaId],
          }),
        });

        console.log("debug:postStatusResponse", postStatusResponse.getContentText());
      } else {
        console.log("skipping...");
      }
    } else {
      console.log("no data was sent");
    }
  } catch (err) {
    console.log(`there was an error: ${err.message}`);
  }
}

And we’re finished!

An example Mastodon post from the "Stefan's test bot" account @fediversebot. The text says:

"The total sum of donations is $2,845.05!"

Attached is an image of a line chart showing daily donations over a period of time.

The bot uses a smiling emoji with closed eyes as the profile picture

Hope you enjoyed this tutorial, until next time!


Viewing all articles
Browse latest Browse all 71

Trending Articles