Build an AI Meme Generator with OpenAI's function calls, Part 2: Cron Jobs ⏰☎️
Tags: tutorial, ai, cron, meme
Published: 2023-09-12 12:00:00

- [TL;DR](#tldr)
    - [Before We Begin](#before-we-begin)
- [Part 2](#part-two)
    - [Fetching & Updating Templates w/ Cron Jobs](#templates)
        - [Defining our Daily Cron Job](#cron)
        - [Testing](#cron-test)
    - [Editing Memes](#edit)
        - [Server-Side Code](#server)
        - [Client-Side Code](#client)
    - [Deleting Memes](#delete)
    - [Conclusion](#conclusion)


<a name="tldr" href="#tldr"></a>
# TL;DR

In this two-part tutorial, we’re going to build a full-stack instant Meme Generator app using:

- React & NodeJS w/ TypeScript
- OpenAI’s [Function Calling API](https://platform.openai.com/docs/guides/gpt/function-calling)
- ImgFlip.com’s [meme creator API](https://imgflip.com/api)

{% youtube https://youtu.be/4rTXljsphQ8 %}

You can check out a deployed version of the app we’re going to build here: [The Memerator](https://damemerator.netlify.app)

If you just want to see the code for the finished app, check out the [Memerator’s GitHub Repo](https://github.com/vincanger/memerator)

[In Part 1 of this tutorial](https://dev.to/wasp/build-your-own-ai-meme-generator-learn-how-to-use-openais-function-calls-1p21) we built the instant meme generation functionality of the app.

In order to create a meme image using ImgFlip.com’s API, we had to send a request with:

- the meme template `id` we want to use
- and the two text blocks that will accompany the meme, e.g. `text0` and `text1`

Rather than doing this manually, we used OpenAI’s API to ask GPT to generate the meme text boxes for us. 

To do this, we used their `function calling` feature to describe the function that includes our ImgFlip API request. This made sure that OpenAI’s response would always contain two text arguments in JSON format so that we could pass them correctly to the [ImgFlip.com](http://ImgFlip.com) API.

The result is that we could get our app to instantly generate meme Images for us by just submitting some topics! 🤯


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/cj3iwdmm2y4w3zxl9qbd.png)

Now, in Part 2 of this tutorial, we will:

- add a daily recurring cron job to fetch more meme templates
- implement `edit` and `delete` meme functionality

Oh, and if you get stuck / have any questions, feel free to hop into the [Wasp Discord Server](https://discord.gg/rzdnErX) and ask us!

Now let’s go 🚀

<a name="before-we-begin" href="#before-we-begin"></a>
## Before We Begin

We’re working hard at [Wasp](https://wasp-lang.dev) to help you build web apps as easily as possible — including making these tutorials, which are released weekly!

We would be super grateful if you could help us out by starring our repo on GitHub: [https://www.github.com/wasp-lang/wasp](https://www.github.com/wasp-lang/wasp) 🙏

![https://media1.giphy.com/media/ZfK4cXKJTTay1Ava29/giphy.gif?cid=7941fdc6pmqo30ll0e4rzdiisbtagx97sx5t0znx4lk0auju&ep=v1_gifs_search&rid=giphy.gif&ct=g](https://media1.giphy.com/media/ZfK4cXKJTTay1Ava29/giphy.gif?cid=7941fdc6pmqo30ll0e4rzdiisbtagx97sx5t0znx4lk0auju&ep=v1_gifs_search&rid=giphy.gif&ct=g)

{% cta [https://www.github.com/wasp-lang/wasp](https://www.github.com/wasp-lang/wasp) %} ⭐️ Thanks For Your Support 🙏  {% endcta %}

<a name="part-two" href="#part-two"></a>
# Part 2.

So we’ve got ourselves a really good basis for an app at this point.

We’re using [OpenAI’s function calling feature](https://platform.openai.com/docs/guides/gpt/function-calling) to explain a function to GPT, and get it to return results for us in a format we can use to call that function.

This allows us to be certain GPT’s result will be usable in further parts of our application and opens up the door to creating AI agents.

If you think about it, we’ve basically got ourselves a really simple Meme generating “agent”. How cool is that?!

<a name="templates" href="#templates"></a>
## Fetching & Updating Templates with Cron Jobs

To be able to generate our meme images via [ImgFlip’s API](https://imgflip.com/api), we have to choose and send a meme template `id` to the API, along with the text arguments we want to fill it in with.

For example, the `Grandma Finds Internet` meme template has the following `id`:

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/zoo4uainls9jxq7fzysv.png)

But the only way for us to get available meme templates from ImgFlip is to send a `GET` request to
[https://api.imgflip.com/get_memes](https://api.imgflip.com/get_memes). And according to ImgFlip, the **/get-memes** endpoint works like this:

> Gets an array of popular memes that may be captioned with this API. The size of this array and the order of memes may change at any time. When this description was written, it returned 100 memes ordered by how many times they were captioned in the last 30 days
> 

So it returns a list of the top 100 memes from the last 30 days. And as this is always changing, we can run a daily cron job to fetch the list and update our database with any new templates that don’t already exist in it.

We know this will work because the ImgFlip docs for the **/caption-image** endpoint — which we use to create a meme image — says this:

> *key:* template_id
*value:* A template ID as returned by the **get_memes** response. Any ID that was ever returned from the **get_memes** response should work for this parameter…
> 

Awesome!

<a name="cron" href="#cron"></a>
### Defining our Daily Cron Job

Now, to create an automatically [recurring cron job in Wasp](https://wasp-lang.dev/docs/advanced/jobs) is really easy. 

First, go to your `main.wasp` file and add:

```tsx
job storeMemeTemplates {
  executor: PgBoss,
  perform: {
    fn: import { fetchAndStoreMemeTemplates } from "@server/workers.js",
  },
  schedule: {
    // daily at 7 a.m.
    cron: "0 7 * * *" 
  },
  entities: [Template],
}
```

This is telling Wasp to run the `fetchAndStoreMemeTemplates` function every day at 7 a.m.

Next, create a new file in `src/server` called `workers.ts` and add the function:

```tsx
import axios from 'axios';

export const fetchAndStoreMemeTemplates = async (_args: any, context: any) => {
  console.log('.... ><><>< get meme templates cron starting ><><>< ....');

  try {
    const response = await axios.get('https://api.imgflip.com/get_memes');

    const promises = response.data.data.memes.map((meme: any) => {
      return context.entities.Template.upsert({
        where: { id: meme.id },
        create: {
          id: meme.id,
          name: meme.name,
          url: meme.url,
          width: meme.width,
          height: meme.height,
          boxCount: meme.box_count,
        },
        update: {},
      });
    });

    await Promise.all(promises);
  } catch (error) {
    console.error('error fetching meme templates: ', error);
  }
};
```

You can see that we send a `GET` request to the proper endpoint, then we loop through the array of memes it returns to us add any new templates to the database.

Notice that we use Prisma’s `upsert` method here. This allows us to create a new entity in the database if it doesn’t already exist. If it does, we don’t do anything, which is why `update` is left blank.

We use `[Promise.all()` to call that array of promises](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all) correctly.

<a name="cron-test" href="#cron-test"></a>
### Testing

Now, assuming you’ve got your app running with `wasp start`, you will see the cron job run in the console every day at 7 a.m.

If you want to test that the cron job is working correctly, you could run it on a faster schedule. Let’s try that now by changing it in our `main.wasp` file to run every minute:

```tsx
//...
  schedule: {
    // runs every minute.
    cron: "* * * * *" 
  },
```

First, your terminal where you ran `wasp start` to start your app should output the following:

```bash
[Server]  🔍 Validating environment variables...
[Server]  🚀 "Username and password" auth initialized
[Server]  Starting pg-boss...
[Server]  pg-boss started!
[Server]  Server listening on port 3001
```

…followed shortly after by:

```bash
[Server]  .... ><><>< get meme templates cron starting ><><>< ....
```

Great. We’ve got an automatically recurring cron job going.

You can check your database for saved templates by opening another terminal window and running:

```bash
wasp db studio 
```

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/yldqge5186hegvzjyo30.png)

<a name="edit" href="#edit"></a>
## Editing Memes

Unfortunately, sometimes GPT’s results have some mistakes. Or sometimes the idea is really good, but we want to further modify it to make it even better.

Well, that’s pretty simple for us to do since we can just make another call to [ImgFlip’s API](https://imgflip.com/api).

So let’s set do that by setting up a dedicated page where we:

- fetch that specific meme based on its `id`
- display a form to allow the user to edit the meme `text`
- send that info to a server-side Action which calls the ImgFlip API, generates a new image URL, and updates our `Meme` entity in the DB.

<a name="server" href="#server"></a>
### Server-Side Code

To make sure we can fetch the individual meme we want to edit, we first need to set up a Query that does this.

Go to your `main.wasp` file and add this Query declaration:

```tsx
query getMeme {
  fn: import { getMeme } from "@server/queries.js",
  entities: [Meme]
}
```


Now go to `src/server/queries.ts` and add the following function:

```tsx
import type { Meme, Template } from '@wasp/entities';
import type { GetAllMemes, GetMeme } from '@wasp/queries/types';

type GetMemeArgs = { id: string };
type GetMemeResult = Meme & { template: Template };

//...

export const getMeme: GetMeme<GetMemeArgs, GetMemeResult> = async ({ id }, context) => {
  if (!context.user) {
    throw new HttpError(401);
  }

  const meme = await context.entities.Meme.findUniqueOrThrow({
    where: { id: id },
    include: { template: true },
  });

  return meme;
};
```

We’re just fetching the single meme based on its `id` from the database.

We’re also including the related meme `Template` so that we have access to its `id` as well, because we need to send this to the ImgFlip API too.

Pretty simple!

Now let’s create our `editMeme` action by going to our `main.wasp` file and adding the following Action:

```tsx
//...

action editMeme {
  fn: import { editMeme } from "@server/actions.js",
  entities: [Meme, Template, User]
}
```

Next, move over to the `server/actions.ts` file and let’s add the following server-side function:

```tsx
//... other imports
import type { EditMeme } from '@wasp/actions/types';

//... other types
type EditMemeArgs = Pick<Meme, 'id' | 'text0' | 'text1'>;

export const editMeme: EditMeme<EditMemeArgs, Meme> = async ({ id, text0, text1 }, context) => {
  if (!context.user) {
    throw new HttpError(401, 'You must be logged in');
  }

  const meme = await context.entities.Meme.findUniqueOrThrow({
    where: { id: id },
    include: { template: true },
  });

  if (!context.user.isAdmin && meme.userId !== context.user.id) {
    throw new HttpError(403, 'You are not the creator of this meme');
  }

  const memeUrl = await generateMemeImage({
    templateId: meme.template.id,
    text0: text0,
    text1: text1,
  });

  const newMeme = await context.entities.Meme.update({
    where: { id: id },
    data: {
      text0: text0,
      text1: text1,
      url: memeUrl,
    },
  });

  return newMeme;
};
```

As you can see, this function expects the `id` of the already existing meme, along with the new `text` boxes. That’s because we’re letting the user manually input/edit the text that GPT generated, rather than making another request the the OpenAI API.

Next, we look for that specific meme in our database, and if we don’t find it we throw an error (`findUniqueOrThrow`).

We check to make sure that that meme belongs to the user that is currently making the request, because we don’t want a different user to edit a meme that doesn’t belong to them.

Then we send the template `id` of that meme along with the new text to our previously created `generateMemeImage` function. This function calls the ImgFlip API and returns the url of the newly created meme image.

We then `update` the database to save the new URL to our Meme.

Awesome!

<a name="client" href="#client"></a>
### Client-Side Code

Let’s start by adding a new route and page to our `main.wasp` file:

```tsx
//...

route EditMemeRoute { path: "/meme/:id", to: EditMemePage }
page EditMemePage {
  component: import { EditMemePage } from "@client/pages/EditMemePage",
  authRequired: true
}
```

There are two important things to notice:

1. the path includes the `:id` parameter, which means we can access page for any meme in our database by going to, e.g. `memerator.com/meme/5`
2. by using the `authRequired` option, we tell Wasp to automatically block this page from unauthorized users. Nice!

Now, create this page by adding a new file called `EditMemePage.tsx` to `src/client/pages`. Add the following code:

```tsx
import { useState, useEffect, FormEventHandler } from 'react';
import { useQuery } from '@wasp/queries';
import editMeme from '@wasp/actions/editMeme';
import getMeme from '@wasp/queries/getMeme';
import { useParams } from 'react-router-dom';
import { AiOutlineEdit } from 'react-icons/ai';

export function EditMemePage() {
  // http://localhost:3000/meme/573f283c-24e2-4c45-b6b9-543d0b7cc0c7
  const { id } = useParams<{ id: string }>();

  const [text0, setText0] = useState('');
  const [text1, setText1] = useState('');
  const [isLoading, setIsLoading] = useState(false);

  const { data: meme, isLoading: isMemeLoading, error: memeError } = useQuery(getMeme, { id: id });

  useEffect(() => {
    if (meme) {
      setText0(meme.text0);
      setText1(meme.text1);
    }
  }, [meme]);

  const handleSubmit: FormEventHandler<HTMLFormElement> = async (e) => {
    e.preventDefault();
    try {
      setIsLoading(true);
      await editMeme({ id, text0, text1 });
    } catch (error: any) {
      alert('Error generating meme: ' + error.message);
    } finally {
      setIsLoading(false);
    }
  };

  if (isMemeLoading) return 'Loading...';
  if (memeError) return 'Error: ' + memeError.message;

  return (
    <div className='p-4'>
      <h1 className='text-3xl font-bold mb-4'>Edit Meme</h1>
      <form onSubmit={handleSubmit}>
        <div className='flex gap-2 items-end'>
          <div className='mb-2'>
            <label htmlFor='text0' className='block font-bold mb-2'>
              Text 0:
            </label>
            <textarea
              id='text0'
              value={text0}
              onChange={(e) => setText0(e.target.value)}
              className='border rounded px-2 py-1'
            />
          </div>
          <div className='mb-2'>
            <label htmlFor='text1' className='block font-bold mb-2'>
              Text 1:
            </label>

            <div className='flex items-center mb-2'>
              <textarea
                id='text1'
                value={text1}
                onChange={(e) => setText1(e.target.value)}
                className='border rounded px-2 py-1'
              />
            </div>
          </div>
        </div>

        <button
          type='submit'
          className={`flex items-center gap-1 bg-primary-200 hover:bg-primary-300 border-2 text-black text-sm py-1 px-2 rounded ${
            isLoading ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'
          } $}`}
        >
          <AiOutlineEdit />
          {!isLoading ? 'Save Meme' : 'Saving...'}
        </button>
      </form>
      {!!meme && (
        <div className='mt-4  mb-2 bg-gray-100 rounded-lg p-4'>
          <img src={meme.url} width='500px' />
          <div className='flex flex-col items-start mt-2'>
            <div>
              <span className='text-sm text-gray-700'>Topics: </span>
              <span className='text-sm italic text-gray-500'>{meme.topics}</span>
            </div>
            <div>
              <span className='text-sm text-gray-700'>Audience: </span>
              <span className='text-sm italic text-gray-500'>{meme.audience}</span>
            </div>
            <div>
              <span className='text-sm text-gray-700'>ImgFlip Template: </span>
              <span className='text-sm italic text-gray-500'>{meme.template.name}</span>
            </div>
          </div>
        </div>
      )}
    </div>
  );
}
```

Some things to notice here are:

1. because we’re using dynamic routes (`/meme/:id`), we pull the URL paramater `id` from the url with `useParams` hook.
2. we then pass that `id` within the `getMemes` Query to fetch that specific meme to edit: `useQuery(getMeme, { id: id })`
    1. remember, our server-side action depends on this `id` in order to fetch the meme from our database

The rest of the page is just our form for calling the `editMeme` Action, as well as displaying the meme we want to edit.

That’s great!

Now that we have that `EditMemePage`, we need a way to navigate to it from the home page.

To do that, go back to the `Home.tsx` file, add the following imports at the top, and find the comment that says `{/* TODO: implement edit and delete meme features */}` and replace it with the following code: 

```tsx
import { Link } from '@wasp/router';
import { AiOutlineEdit } from 'react-icons/ai';

//...

{user && (user.isAdmin || user.id === memeIdea.userId) && (
  <div className='flex items-center mt-2'>
    <Link key={memeIdea.id} params={{ id: memeIdea.id }} to={`/meme/:id`}>
      <button className='flex items-center gap-1 bg-primary-200 hover:bg-primary-300 border-2 text-black text-xs py-1 px-2 rounded'>
        <AiOutlineEdit />
        Edit Meme
      </button>
    </Link>
    {/* TODO: add delete meme functionality */}
  </div>
)}
```

What’s really cool about this, is that Wasp’s `Link` component will give you [type-safe routes](https://wasp-lang.dev/docs/advanced/links), by making sure you’re following the pattern you defined in your `main.wasp` file.

And with that, so long as the authenticated user was the creator of the meme (or is an admin), the `Edit Meme` button will show up and direct the user to the `EditMemePage`

Give it a try now. It should look like this:

{% youtube https://youtu.be/ymSr2eRXz9c %}

<a name="delete" href="#delete"></a>
## Deleting Memes

Ok. When I initially started writing this tutorial, I thought I’d also explain how to add `delete` meme functionality to the app as well.

But seeing as we’ve gotten this far, and as the entire two-part tutorial is pretty long, I figured you should be able to implement yourself by this point.

So I’ll leave you guide as to how to implement it yourself. Think of it as a bit of homework:

1. define the `deleteMeme` Action in your `main.wasp` file
2. export the async function from the `actions.ts` file
3. import the Action in your client-side code
4. create a button which takes the meme’s `id` as an argument in your `deleteMeme` Action.

If you get stuck, you can use the `editMeme` section as a guide. Or you can check out the [finished app’s GitHub repo](https://github.com/vincanger/memerator/) for the completed code!

<a name="conclusion" href="#conclusion"></a>
# Conclusion

There you have it! Your own instant meme generator 🤖😆

BTW, If you found this useful, **please show us your support by [giving us a star on GitHub](https://github.com/wasp-lang/wasp)**! It will help us continue to make more stuff just like it. 

![https://res.cloudinary.com/practicaldev/image/fetch/s--tnDxibZC--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_66%2Cw_800/https://res.cloudinary.com/practicaldev/image/fetch/s--OCpry2p9--/c_limit%252Cf_auto%252Cfl_progressive%252Cq_66%252Cw_800/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bky8z46ii7ayejprrqw3.gif](https://res.cloudinary.com/practicaldev/image/fetch/s--tnDxibZC--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_66%2Cw_800/https://res.cloudinary.com/practicaldev/image/fetch/s--OCpry2p9--/c_limit%252Cf_auto%252Cfl_progressive%252Cq_66%252Cw_800/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bky8z46ii7ayejprrqw3.gif)

{% cta [https://www.github.com/wasp-lang/wasp](https://www.github.com/wasp-lang/wasp) %} ⭐️ Thanks For Your Support 🙏  {% endcta %}