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
The brief
01 / 05One 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.
- No serverThe whole thing runs in Apps Script on the free quota, against the café's own Google Sheet. Nothing to host, nothing to renew.
- No new appOrders are typed the way staff already speak. There is no screen to learn and no barcode to scan.
- No redeployItems, prices, and recipes are rows in a sheet, so a menu change is a cell edit.
- No silent guessingThe model may only return items that already exist on the menu, and a wrong receipt line is fixed with one follow-up sentence.
How it works
02 / 05Everything runs in Google Apps Script, so there is no server to host or pay for.
- 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.
- 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.
- LogSales, expenses, and inventory live in separate tabs. Each sale deducts ingredients and warns when something drops below its minimum.
- 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 / 05Staff send these as plain messages. Anything else goes to the model as a question, and menu changes need no redeploy.
1 latte 16log an orderadd kani xl, kani m to my salesseveral at onceexpense 588 beanslog a costrecipe spanish lattedrink buildstockinventory levelstodayweekmonthsalesbesttop sellers todayprofit todayrevenue minus costsexpenses todaycost totalmenu addmenu listmenu deleteedit the menuundoremove the last saleundo expenseremove the last cost
Selected code
04 / 05Four 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 / 05What it does not do, written down so nobody assumes otherwise.
- Polling has a waitThe time trigger holds the connection for fifty seconds, so a reply can lag a poll behind. Webhook mode removes the wait but needs a public endpoint.
- The model can still misreadIt is constrained to the menu, not guaranteed correct. Receipt photos need a readable total line; a folded, dim receipt gets a correction request instead of a guess.
- One café, one SheetTwo branches would share the same tabs. It is a small-shop tool, not a point-of-sale system.
- No reportsNumbers land in Sheets already, so anything longer than a daily total is done there rather than in the bot.
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