DEV Community

Cover image for Automating Grocery Lists with Notion and Telegram
3 1 1

Automating Grocery Lists with Notion and Telegram

TL;DR

Managing grocery lists manually was chaotic, so I automated the process using Notion and a Telegram bot. A TypeScript script connects to Notion’s API, gathers ingredients from selected recipes, and creates a shopping list automatically. The bot allows me to generate lists and mark items as purchased with simple commands.

Introduction

My partner and I recently decided to eat healthier, which meant cooking more at home. She shared several recipe links, but when I went grocery shopping, I realized managing ingredients manually was overwhelming. Jumping between recipe links while trying to buy everything efficiently was frustrating.

That’s when I had an idea: automate the whole process!

The Notion Setup

To keep track of everything, I used Notion, where we already manage household tasks. I created several databases:

  • Ingredients – A list of all the ingredients we might need.
  • Recipes – Each recipe links to the necessary ingredients.
  • Shopping Lists – A database where each entry represents a shopping trip, containing a to-do list of ingredients to buy.

This setup made organizing ingredients easier, but I still had to manually transfer them from recipes to the shopping list. Not efficient enough!

Automating with TypeScript and Notion’s API

To fully automate the process, I wrote a TypeScript script that connects to Notion’s API. Here’s what it does:

1- Scans all recipes where a specific checkbox is enabled.

async function getRecipesToAdd() {
  const response = await notion.databases.query({
    database_id: RECIPES_DB_ID, // Your notion db ID, you can grab it from the url
    filter: {
      property: "Add to list?", // The checkbox we manually enable if we want recipes to be processed
      checkbox: {
        equals: true,
      },
    },
  });
  return response.results;
}

Enter fullscreen mode Exit fullscreen mode

2- Extracts the required ingredients.

async function getIngredientsList(recipePage) {
  const relationArray = recipePage.properties["Ingredients"]?.relation;
  if (!relationArray || !relationArray.length) {
    return [];
  }

  const ingredientNames = [];

  for (const rel of relationArray) {
    const ingredientPageId = rel.id;

    const ingredientPage = await notion.pages.retrieve({
      page_id: ingredientPageId,
    });
    const nameProp = (ingredientPage as PageObjectResponse).properties[
      "Ingredient"
    ];
    let ingredientName = "Unnamed Ingredient";
    if (
      nameProp &&
      isTitleProperty(nameProp) &&
      nameProp.title &&
      nameProp.title[0]
    ) {
      ingredientName = nameProp.title[0].plain_text;
    }

    ingredientNames.push(ingredientName);
  }

  return ingredientNames;
}
Enter fullscreen mode Exit fullscreen mode

3- Creates a new shopping list.

async function createShoppingListPage() {
  const todayStr = new Date().toISOString().slice(0, 10);
  const pageName = `Nueva lista ${todayStr}`;

  return await notion.pages.create({
    parent: { database_id: SHOPPING_LISTS_DB_ID },
    properties: {
      Name: {
        title: [{ type: "text", text: { content: pageName } }],
      },
      Fecha: {
        date: { start: todayStr },
      },
      Comprado: {
        checkbox: false,
      },
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

4- Populate the page with ingredients, we are going to use to-do blocks

async function appendIngredientChecklist(pageId, ingredients) {
  const children = ingredients.map((item) => ({
    object: "block",
    type: "to_do",
    to_do: {
      rich_text: [
        {
          type: "text",
          text: { content: item },
        },
      ],
      checked: false,
    },
  }));

  await notion.blocks.children.append({
    block_id: pageId,
    children,
  });
}
Enter fullscreen mode Exit fullscreen mode

This meant that with a simple selection of recipes, my shopping list would be generated instantly.

Running the Script via Telegram

I didn’t want to manually run the script on my computer every time, so I integrated it with Telegram:

  • I built a Telegram bot using telegraf, that triggers the script with a command.
  • The bot automatically compiles the grocery list inside Notion.
bot.command("import", async (ctx) => {
  const isValid = await checkUserValid(ctx.from.username, ctx); // Just a check making sure my partner and I are the only ones allowed to use certain commands
  if (!isValid) {
    return;
  }
  await ctx.reply("Importing ingredients...");
  return buildShoppingList(ctx);
});
Enter fullscreen mode Exit fullscreen mode
  • A second command lists pending shopping lists.
export async function listShoppingLists(context: Context) {
  await context.reply("Loading shopping list...");
  const response = await notion.databases.query({
    database_id: process.env.SHOPPING_LIST_DB_ID,
    filter: {
      property: "Bought",
      checkbox: {
        equals: false,
      },
    },
  });

  if (response.results.length > 0) {
    await context.reply(
      `${context.from.first_name}, elements pending to buy:`,
    );
  } else {
    return context.reply("No elements to add.");
  }

  for (const page of response.results) {
    const allIngredients: string[] = [];
    const ingredients = await notion.blocks.children.list({
      block_id: page.id,
    });
    allIngredients.push(
      ...ingredients.results
        .map((ingredient: BlockObjectResponse) => {
          if (ingredient.type === "to_do") {
            const checked = ingredient.to_do.checked ? "" : "🔲";
            return `${checked} ${ingredient.to_do.rich_text[0].plain_text}`;
          }
          return null;
        })
        .filter(Boolean),
    );
    if (allIngredients.length > 0) {
      const inlineKeyboard = Markup.inlineKeyboard([
        Markup.button.callback(
          "Mark as bought",
          `markPurchased:${page.id}`,
        ),
      ]);
      const nameProp = (page as PageObjectResponse).properties["Name"];
      if (isTitleProperty(nameProp)) {
        await context.reply(`${nameProp.title[0].plain_text}:`);
      }
      await context.reply(allIngredients.join("\n"), inlineKeyboard);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
  • A button allows marking a list as purchased (added in the code above) and its handler.
bot.action(/markPurchased:(.+)/, async (ctx) => {
  const isValid = await checkUserValid(ctx.from.username, ctx);
  if (!isValid) {
    return;
  }
  const pageId = ctx.match[1];
  await markAsPurchased(ctx, pageId);
});  
Enter fullscreen mode Exit fullscreen mode

Now, with a quick /import command, I get my groceries organized effortlessly.

Next Steps

I initially built this for personal use, but it was also a great way to experiment with Notion’s API. Some ideas for future improvements:

  • Recipe scaling: Adjust ingredient quantities dynamically based on portions.
  • Multiple store support: Categorize ingredients by where to buy them.
  • Something not related to groceries!: Maybe a library and a local search on Telegram to know if you have a certain book?

What Would You Add?

This has been a fun side project, but I’d love to hear ideas for making it even better. Would you find something like this useful? Let me know in the comments!

Sentry image

Hands-on debugging session: instrument, monitor, and fix

Join Lazar for a hands-on session where you’ll build it, break it, debug it, and fix it. You’ll set up Sentry, track errors, use Session Replay and Tracing, and leverage some good ol’ AI to find and fix issues fast.

RSVP here →

Top comments (0)

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay