How To Write a Go/Lambda Based Twitter Meme Bot

11/1/2021

What's more fun than a pep talk by a Coach Ted Lasso bot?!

Coach Lasso

It's been a while since I wrote a fun bot, so on Sunday evening I cracked my knuckles, set up a new repo and wrote a bot I've been thinking about for a little while: a "pep talk" generator themed after Apple's hit show Ted Lasso and a cool pep talk generator graphic posted on Reddit. You can see the bot in-action on Twitter: @LassoPepTalkBot. Here's how my bot works:

Generate the Pep Talk

First, let's generate the talk's text. To do that, I encoded the chart's text in a 2D array (truncated here), and then wrote a function that goes through the top level array, randomly chooses one element from each array, and adds that to a string.

var parts = [][]string{
    {
        "Champ",
        "Fact:",
        "Everybody says",
    ...
    },
    {
        "the mere idea of you",
        "your soul",
        "your hair today",
        ...
    },
    {
        "has serious game",
        "rains magic",
        "deserves the Nobel Prize",
        ...
    },
    {
        "24/7.",
        "can I get an amen?",
        "and that's a fact.",
        ...
    },
}

func generateRandomSentence() string {
    str := strings.Builder{}
    for i, strs := range parts {
        index := rand.Intn(len(strs))
        str.WriteString(strs[index])
        if i == 2 {
            str.WriteString(",")
        }
        if i < len(parts)-1 {
            str.WriteString(" ")
        }
    }
    return str.String()
}

Get An Image

I sourced 17 images of Ted Lasso from the Google Image Search and put them in an S3 bucket. This function picks a random one.

const (
    nImages = 17
)

func getRandomImage(client *s3.Client) (image.Image, error) {
    imageFileName := fmt.Sprintf("%d.jpg", rand.Int31n(nImages))
    input := &s3.GetObjectInput{
        Bucket: aws.String(os.Getenv("SOURCE_IMAGES_BUCKET")),
        Key:    aws.String(imageFileName),
    }
    resp, err := client.GetObject(context.Background(), input)
    if err != nil {
        return nil, err
    }
    return jpeg.Decode(resp.Body)
}

Build the Meme

To assemble the actual meme, I used the library fogleman/gg, which has some helpful graphics functions on top of Go's standard library. This function takes the pep talk sentence and the chosen image, renders the text with a nice little drop shadow on the image, and returns the new image.

const (
  shadowOffset = 2
)

func renderSentence(sentence string, image image.Image) (image.Image, error) {
    ctx := gg.NewContextForImage(image)
    fontSize := float64(image.Bounds().Max.Y) / 21.0
    if err := ctx.LoadFontFace(os.Getenv("FONT_FACE_PATH"), fontSize); err != nil {
        return nil, err
    }
    x := float64(image.Bounds().Max.X) / 2.0
    y := float64(image.Bounds().Max.Y) - (fontSize * 2.0)
    ctx.SetRGB(0, 0, 0)
    ctx.DrawStringAnchored(sentence, x+shadowOffset, y+shadowOffset, 0.5, 0.5)
    ctx.SetRGB(1, 1, 1)
    ctx.DrawStringAnchored(sentence, x, y, 0.5, 0.5)
    return ctx.Image(), nil
}

Transmit to Twitter

With an assembled image, the bot uploads it to Twitter in uploadImage and then tweets it in tweetMeme. This uses a generic OAuth1 library combined with standard HTTP requests rather an any sort of Twitter client.

var (
    twitterConfig = oauth1.NewConfig(os.Getenv("TWITTER_CONSUMER_KEY"), os.Getenv("TWITTER_CONSUMER_SECRET"))
    twitterToken  = oauth1.NewToken(os.Getenv("TWITTER_ACCESS_TOKEN"), os.Getenv("TWITTER_ACCESS_TOKEN_SECRET"))
    twitterClient = twitterConfig.Client(oauth1.NoContext, twitterToken)
)

type tweetMediaResponse struct {
    MediaIdString string `json:"media_id_string"`
}

func uploadImage(img image.Image) (string, error) {
    var jsonBytes bytes.Buffer
    var opts jpeg.Options
    opts.Quality = 50
    err := jpeg.Encode(&jsonBytes, img, &opts)
    if err != nil {
        return "", err
    }

    imgB64 := base64.StdEncoding.EncodeToString(jsonBytes.Bytes())

    var v url.Values = make(map[string][]string)
    v.Add("media_category", "tweet_image")
    v.Add("media_data", imgB64)

    req, err := http.NewRequest("POST", "https://upload.twitter.com/1.1/media/upload.json", bytes.NewReader([]byte(v.Encode())))
    if err != nil {
        return "", err
    }

    req.Header.Add("Content-type", "application/x-www-form-urlencoded")

    resp, err := twitterClient.Do(req)
    if err != nil {
        return "", err
    }

    respBody, err := io.ReadAll(resp.Body)
    if err != nil {
        return "", err
    }

    if resp.StatusCode != 200 {
        return "", fmt.Errorf("error response: %s", string(respBody))
    }

    var respStruct tweetMediaResponse
    err = json.Unmarshal(respBody, &respStruct)
    if err != nil {
        return "", err
    }

    return respStruct.MediaIdString, nil
}

func tweetMeme(text string, mediaId string) error {
    var v url.Values = make(map[string][]string)
    modifier := quoteModifiers[rand.Intn(len(quoteModifiers))]
    v.Add("status", fmt.Sprintf("\"%s\"\n - Coach Lasso (%s)", text, modifier))
    v.Add("media_ids", mediaId)
    req, err := http.NewRequest("POST", "https://api.twitter.com/1.1/statuses/update.json?"+v.Encode(), nil)
    if err != nil {
        return err
    }

    req.Header.Add("Authorization", fmt.Sprintf("Bearer: %s", os.Getenv("TWITTER_TOKEN")))
    req.Header.Add("Content-type", "application/json")

    resp, err := twitterClient.Do(req)
    if err != nil {
        return err
    }

    respBody, err := io.ReadAll(resp.Body)
    if err != nil {
        return err
    }

    if resp.StatusCode != 200 {
        return fmt.Errorf("bad response: %s", string(respBody))
    }

    log.Println(string(respBody))

    return nil
}

The Lambda Handler

Bringing it altogether, a Lambda handler calls all these functions:

func handler(ctx context.Context, event events.CloudWatchEvent) {
    cfg, err := config.LoadDefaultConfig(context.Background())
    if err != nil {
        panic(err)
    }

    client := s3.NewFromConfig(cfg)

    rand.Seed(time.Now().UnixNano())
    sentence := generateRandomSentence()
    image, err := getRandomImage(client)
    if err != nil {
        panic(err)
    }
    meme, err := renderSentence(sentence, image)
    if err != nil {
        panic(err)
    }
    mediaId, err := uploadImage(meme)
    if err != nil {
        panic(err)
    }
    err = tweetMeme(sentence, mediaId)
    if err != nil {
        panic(err)
    }
}

func main() {
    lambda.Start(handler)
}

Deploying

I deploy this app using the Serverless framework, which is a breathtakingly simple way to deploy to Lambda. The serverless.yml file for this is straightforward; it provisions a bucket, permissions, and one Lambda that runs every hour.

service: peptalkbot

provider:
  name: aws
  runtime: go1.x

  iamRoleStatements:
    - Effect: "Allow"
      Action:
       - "s3:GetObject"
      Resource:
        Fn::Join:
          - ""
          - - "arn:aws:s3:::"
            - ${self:custom.sourceImagesBucketName}
            - "/*"

custom:
  sourceImagesBucketName: peptalkbotsourceimages

package:
  individually: true
  exclude:
    - ./**/*.go
    - ./go.*
    - ./**/*.jpg
    - ./Makefile
    - ./**/*.yml

functions:
  generate:
    handler: peptalk
    package:
      include:
        - ./peptalk
    events:
      - schedule: rate(1 hour)
    environment:
      SOURCE_IMAGES_BUCKET: ${self:custom.sourceImagesBucketName}
      TWITTER_ACCESS_TOKEN: ${file(./priv.yml):TWITTER_ACCESS_TOKEN}
      TWITTER_ACCESS_TOKEN_SECRET: ${file(./priv.yml):TWITTER_ACCESS_TOKEN_SECRET}
      TWITTER_CONSUMER_SECRET: ${file(./priv.yml):TWITTER_CONSUMER_SECRET}
      TWITTER_CONSUMER_KEY: ${file(./priv.yml):TWITTER_CONSUMER_KEY}

resources:
  Resources:
    SourceBucket:
      Type: AWS::S3::Bucket
      Properties:
        BucketName: ${self:custom.sourceImagesBucketName}

To learn more about this, check out the repo: johnjones4/pep-talk-generator