Arnel ColarAll work   Get in touch

Case study · Google Apps Script

A café's sales, costs, and stock, logged from Telegram.

Staff type orders into Telegram the way they'd say them out loud, or ask for several in one sentence. The bot prices each item from a menu sheet, logs it to Google Sheets, deducts stock, and replies with a total. Ask for a drink recipe and it reads the recipe sheet. A photo of a supplier receipt becomes an itemized expense.

  • Apps Script
  • Telegram Bot API
  • Google Sheets
  • OpenRouter vision
  • No server, no libraries
add kani xl, kani m to my sales
✅ Logged 2 item(s): • 1x Rice bowl: kani (xl) = Php 180 • 1x Rice bowl: kani (m) = Php 160 💰 Grand Total: Php 340.00
recipe spanish latte
☕ SPANISH LATTE (16) • Espresso: 2 shots • Sweetener/Syrup: 20ml • Milk: 160ml • Ice: 1 cup 👨‍🍳 Build the ice, pour the milk, then the shots.
receipt.jpg
💸 Auto-logged and restocked: • Fresh milk 1L: Php 98.00 • Crab sticks 250g: Php 145.00 💰 Total: Php 243.00 🚫 Excluded: bottled soda (personal)

The brief

01 / 05

One person maintains this, the staff already had Telegram open all day, and the café had no budget for a subscription. Those three constraints decided almost everything about how it is built.

How it works

02 / 05

Everything runs in Google Apps Script, so there is no server to host or pay for.

  1. Poll or pushA time trigger polls Telegram for fifty seconds at a time, then hands back to the trigger. The offset is saved in Script Properties so nothing is logged twice. A webhook mode does the same job without the wait.
  2. ParseA typed line splits into quantity, item, and size, then gets priced from the Menu sheet. Sentences that do not fit, like "add kani xl, kani m to my sales", go to the model, which may only answer with items already on the menu.
  3. LogSales, expenses, and inventory live in separate tabs. Each sale deducts ingredients and warns when something drops below its minimum.
  4. Read receiptsPhotos go to a vision model that returns JSON. Personal items are dropped, shop supplies are logged and restocked, and one follow-up sentence corrects a wrong call.

Commands

03 / 05

Staff send these as plain messages. Anything else goes to the model as a question, and menu changes need no redeploy.

Selected code

04 / 05

Four excerpts. The full script stays private because a real café runs on it.

Keys stay out of the code

The bot token, API key, and sheet ID live in Script Properties. The code can be shared or pasted into a chat without handing over access.

var PROPS = PropertiesService.getScriptProperties();
var TOKEN = PROPS.getProperty("TELEGRAM_TOKEN");
var SHEET_ID = PROPS.getProperty("SHEET_ID");
var OPENROUTER_API_KEY = PROPS.getProperty("OPENROUTER_API_KEY");

Reading an order

One regular expression turns "2 kani-mango s" into a quantity, an item, and a size. When the size is left off, it defaults to 16.

function parseOrder(text) {
  var lower = text.toLowerCase().trim();
  var match = lower.match(/^(\d+)\s+(.+?)(?:\s+(\S+))?$/);
  if (!match) {
    return { error: 'Format: "1 latte 16" or "2 kani-mango s"' };
  }
  return {
    qty: parseInt(match[1], 10),
    item: match[2].trim(),
    size: match[3] || "16",
    error: null
  };
}

Only menu items count

A sentence with several drinks has no fixed shape, so the menu is handed to the model with the message and the model may only answer with names and sizes that already exist. Anything missing from the reply is dropped rather than guessed.

var prompt = "Parse the user's message into a JSON list of orders.\n" +
  "Available menu items (name (size)): " + menuList.join(", ") + "\n" +
  "Return ONLY valid JSON: " +
  "{\"orders\":[{\"qty\":number,\"item\":\"menu item\",\"size\":\"size\"}]}\n" +
  "Only include items that exist in the available menu list.";

var payload = {
  model: "minimax/minimax-m3.1-flash:free",
  response_format: { type: "json_object" },
  temperature: 0,
  messages: [{ role: "user", content: prompt }]
};

Parsing the model's reply

Models sometimes wrap JSON in prose or code fences. The bot cuts out the first object it finds before parsing, and retries up to three times when the API errors.

var start = textResponse.indexOf('{');
var end = textResponse.lastIndexOf('}');

if (start !== -1 && end > start) {
  textResponse = textResponse.substring(start, end + 1);
} else {
  textResponse = textResponse
    .replace(/```json/g, "").replace(/```/g, "").trim();
}

var resultJson = JSON.parse(textResponse);

Limits

05 / 05

What it does not do, written down so nobody assumes otherwise.

Want one for your site?

Fixed scope, fixed price, quoted before I start. You own the code, and I reply within one working day.

Send me the page and the deadline See more work