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;
}
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;
}
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,
},
},
});
}
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,
});
}
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);
});
- 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);
}
}
}
- 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);
});
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!
Top comments (0)