Skill: serdarsalim/grocery-assistant (version 1.1.3)

--- name: grocery-checklist description: "Persistent pantry-backed grocery checklist for OpenClaw, intended for normal conversational use with Telegram shopping-list UI." version: 1.1.3 user-invocable: true metadata: openclaw: emoji: "🛒" requires: bins: - bash - python3 - openclaw reads: - ~/.openclaw/openclaw.json writes: - ~/.openclaw/data/grocery-checklist/state.json - ~/.openclaw/data/grocery-checklist/telegram-bot-state.json --- # Grocery Checklist This skill stores grocery state locally and supports a Telegram checklist UX. Intended usage: - OpenClaw handles conversation normally - this skill provides grocery state and actions - Telegram renders shopping and pantry views - the managed OpenClaw route is the primary install mode Runtime behavior: - reads Telegram account config from `~/.openclaw/openclaw.json` - writes pantry state to `~/.openclaw/data/grocery-checklist/state.json` - writes Telegram polling state to `~/.openclaw/data/grocery-checklist/telegram-bot-state.json` - uses the bundled wrapper at `scripts/grocery.sh` Use it for: - `I ran out of salt` - `Add milk and eggs to groceries` - `What do I need to buy?` - `Mark eggs bought` - `I'm shopping now` - `Should I go shopping today?` Wrapper: ```bash bash <skill_dir>/scripts/grocery.sh ... ``` Core states: - `needed` - `have` Telegram callbacks use: ```text callback_data: gchk:... ``` Behavior guidance: - when tools are available, use `mutate_grocery_items` for state changes and `render_grocery_view` for Telegram UI - treat natural grocery mutation phrasing as state changes - for mutation intents, run the mutation first; do not render as a substitute for the mutation - treat `show me the shopping list`, `what do I need to buy`, and “I am shopping now” phrasing as shopping-list renders - treat `show me the pantry` and `what do I have` as pantry renders - keep normal grocery conversation conversational - after a Telegram UI render or callback, do not send a second explanatory message - for successful Telegram UI actions, the ideal model output is exactly `NO_REPLY`

path: SETUP.md
content: # Setup

This is the shortest reliable setup for a real user.

The goal is not just "show a list." The goal is to keep pantry state in OpenClaw and expose a Telegram-friendly shopping checklist.

Recommended architecture:
- OpenClaw handles normal conversation
- this skill handles grocery state and actions
- Telegram is the UI surface for shopping list and pantry rendering
- use a dedicated `grocery` Telegram account if you want the cleanest separation

This package:
- includes `index.js` at the skill root so OpenClaw can load the grocery tools and Telegram callback handler
- reads `~/.openclaw/openclaw.json` for the Telegram `grocery` account token and allowlist when Telegram is enabled
- writes pantry state to `~/.openclaw/data/grocery-checklist/state.json`
- writes Telegram polling state to `~/.openclaw/data/grocery-checklist/telegram-bot-state.json`
- executes only the bundled scripts in this skill folder

## 1. Install the skill

Put this skill at:

```bash
~/.openclaw/skills/grocery-checklist
```

## 2. Verify prerequisites

You need:

```bash
python3 --version
openclaw --version
```

## 3. Smoke test the local wrapper

Run:

```bash
bash ~/.openclaw/skills/grocery-checklist/scripts/grocery.sh need salt eggs
bash ~/.openclaw/skills/grocery-checklist/scripts/grocery.sh show
```

## 4. Connect Telegram Through OpenClaw

This skill works best when the grocery bot is separate from your main assistant bot.

Typical shape:

```bash
openclaw channels add --channel telegram --account grocery --name "Grocery Shopping" --token <telegram-bot-token>
openclaw agents add grocery --workspace ~/.openclaw/workspace --bind telegram:grocery --non-interactive
```

Route grocery messages for that bot to this skill's wrapper and let OpenClaw stay in charge of conversation handling.

## 5. Allow exec approvals

If your OpenClaw setup requires exec approvals, allowlist:

```bash
~/.openclaw/skills/grocery-checklist/scripts/grocery.sh
~/.openclaw/skills/grocery-checklist/scripts/*.py
```

## 6. Smoke test in chat

Try:

```text
I ran out of salt and eggs
what do I need to buy
I bought eggs
```

## 7. Smoke test in Telegram

If you wired a Telegram bot:
- ask `what do i need to buy`
- tap a few items
- each tap immediately marks that item bought

## Common failures

`exec approval is required`
- allowlist the grocery wrapper and script paths

`wrong Telegram bot receives the checklist`
- make sure the `grocery` Telegram account is configured in `~/.openclaw/openclaw.json`

`button taps do nothing`
- confirm your OpenClaw Telegram route preserves `gchk:...` callback payloads

`list shows prose instead of checklist`
- your grocery Telegram route is probably going through a generic assistant prompt instead of the grocery skill path

## Advanced: standalone bot

`scripts/telegram_bot.py` is included as an optional standalone Telegram bot implementation.

Use it only if you explicitly want to bypass OpenClaw's managed conversation layer. The recommended install path is still the managed OpenClaw route above.


path: index.js
content: import { createHash } from 'node:crypto';
import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
import { homedir } from 'node:os';
import path from 'node:path';
import https from 'node:https';

// ─── Constants ────────────────────────────────────────────────────────────────

const STATUS_NEEDED = 'needed';
const STATUS_HAVE = 'have';
const CALLBACK_PREFIX = 'gchk';
const CALLBACK_TOGGLE = 'tgl';
const CALLBACK_VIEW = 'view';
const VIEW_NEEDED = 'needed';
const VIEW_ALL = 'all';

// ─── Utilities ────────────────────────────────────────────────────────────────

function utcNow() {
    return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
}

function statePath() {
    const env = process.env.GROCERY_STATE_FILE;
    if (env) return env.replace(/^~/, homedir());
    return path.join(homedir(), '.openclaw', 'data', 'grocery-checklist', 'state.json');
}

function openclawConfigPath() {
    return path.join(homedir(), '.openclaw', 'openclaw.json');
}

// ─── Name normalization ───────────────────────────────────────────────────────

function depluralize(word) {
    if (word.length <= 3) return word;
    if (word.endsWith('ies') && word.length > 4) return word.slice(0, -3) + 'y';
    if (word.endsWith('s') && !word.endsWith('ss')) return word.slice(0, -1);
    return word;
}

function normalizeName(value) {
    let s = value.trim().toLowerCase();
    s = s.replace(/[&+]/g, ' and ');
    s = s.replace(/[^a-z0-9]+/g, ' ');
    s = s.replace(/\s+/g, ' ').trim();
    return s.split(' ').filter(Boolean).map(depluralize).join(' ');
}

function displayName(value) {
    return value.replace(/\s+/g, ' ').trim();
}

function itemIdFor(normalized) {
    return createHash('sha1').update(normalized, 'utf8').digest('hex').slice(0, 10);
}

function splitItemTokens(values) {
    const items = [];
    for (const raw of values) {
        for (const part of raw.split(/,|\n|\band\b/)) {
            const cleaned = displayName(part);
            if (cleaned) items.push(cleaned);
        }
    }
    const seen = new Set();
    const unique = [];
    for (const item of items) {
        const key = normalizeName(item);
        if (!key || seen.has(key)) continue;
        seen.add(key);
        unique.push(item);
    }
    return unique;
}

// ─── Categories ───────────────────────────────────────────────────────────────

const CATEGORY_KEYWORDS = [
    ['Fruits & Vegetables', ['apple', 'banana', 'orange', 'lemon', 'lime', 'tomato', 'onion', 'garlic', 'ginger', 'carrot', 'potato', 'lettuce', 'cucumber', 'capsicum', 'pepper', 'chili', 'coriander', 'spring onion', 'celery', 'mushroom', 'avocado', 'mango', 'grape', 'berry', 'honeydew', 'watermelon', 'melon', 'basil', 'parsley', 'mint', 'spinach', 'broccoli', 'cauliflower', 'zucchini', 'eggplant', 'corn', 'sweet corn', 'pea', 'bean', 'herb']],
    ['Meat & Fish', ['chicken', 'beef', 'pork', 'lamb', 'fish', 'prawn', 'shrimp', 'salmon', 'tuna', 'mackerel', 'turkey', 'duck', 'sausage', 'bacon', 'mince', 'steak']],
    ['Dairy & Eggs', ['milk', 'cheese', 'egg', 'yogurt', 'yoghurt', 'butter', 'cream', 'condensed milk']],
    ['Bakery', ['bread', 'wrap', 'tortilla', 'pita', 'bun', 'roll', 'bagel']],
    ['Staples', ['rice', 'pasta', 'noodle', 'flour', 'sugar', 'salt', 'oil', 'vinegar', 'sauce', 'soy', 'stock', 'broth', 'paste', 'mayonnaise', 'ketchup', 'mustard', 'coconut']],
    ['Spices', ['seasoning', 'spice', 'cumin', 'turmeric', 'paprika', 'curry', 'laksa', 'fajita', 'chili flake', 'chilli flake']],
    ['Beverages', ['tea', 'coffee', 'juice', 'water', 'drink', 'soda', 'cola']],
    ['Snacks & Sweets', ['chocolate', 'biscuit', 'cookie', 'crisp', 'chip', 'marshmallow', 'candy', 'sweet', 'snack']],
];
const CATEGORY_ORDER = [...CATEGORY_KEYWORDS.map(([cat]) => cat), 'Other'];

function categorizeItem(name) {
    const normalized = normalizeName(name);
    for (const [category, keywords] of CATEGORY_KEYWORDS) {
        for (const keyword of keywords) {
            const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
            if (new RegExp(`\\b${escaped}\\b`).test(normalized)) return category;
        }
    }
    return 'Other';
}

// ─── HTML helpers ─────────────────────────────────────────────────────────────

function htmlEscape(s) {
    return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

// ─── JSON helpers ─────────────────────────────────────────────────────────────

function sortedStringify(value) {
    function replacer(key, val) {
        if (val !== null && typeof val === 'object' && !Array.isArray(val)) {
            return Object.keys(val).sort().reduce((acc, k) => { acc[k] = val[k]; return acc; }, {});
        }
        return val;
    }
    return JSON.stringify(value, replacer, 2) + '\n';
}

function tryParseJson(raw) {
    try { return [JSON.parse(raw), false]; } catch { /* fall through */ }
    // Salvage: scan for end of first balanced JSON object
    let depth = 0, inStr = false, escape = false;
    for (let i = 0; i < raw.length; i++) {
        const c = raw[i];
        if (escape) { escape = false; continue; }
        if (c === '\\' && inStr) { escape = true; continue; }
        if (c === '"') { inStr = !inStr; continue; }
        if (inStr) continue;
        if (c === '{') depth++;
        if (c === '}') {
            depth--;
            if (depth === 0) {
                try { return [JSON.parse(raw.slice(0, i + 1)), true]; } catch { break; }
            }
        }
    }
    return [null, false];
}

// ─── State migration & repair ─────────────────────────────────────────────────

function migrateV1toV2(state) {
    const items = state.items;
    if (!items || typeof items !== 'object') { state.version = 2; return false; }
    const newItems = {};
    let changed = false;
    for (const [oldId, item] of Object.entries(items)) {
        if (!item || typeof item !== 'object') { changed = true; continue; }
        const newNormalized = normalizeName(item.name || '');
        const newId = itemIdFor(newNormalized);
        item.id = newId;
        item.normalized = newNormalized;
        if (newId !== oldId) changed = true;
        if (newId in newItems) {
            const existing = newItems[newId];
            if (item.status === STATUS_NEEDED || existing.status === STATUS_NEEDED) existing.status = STATUS_NEEDED;
            if ((item.updated_at || '') > (existing.updated_at || '')) existing.updated_at = item.updated_at;
        } else {
            newItems[newId] = item;
        }
    }
    state.items = newItems;
    state.version = 2;
    return changed;
}

function pruneCorruptedItems(state) {
    if (!state.items || typeof state.items !== 'object') { state.items = {}; return true; }
    let changed = false;
    for (const [itemId, item] of Object.entries(state.items)) {
        if (!item || typeof item !== 'object') { delete state.items[itemId]; changed = true; continue; }
        const name = String(item.name || '').trim();
        const normalized = name ? normalizeName(name) : '';
        if (/^[0-9a-f]{10}$/.test(normalized) && normalized in state.items && normalized !== itemId) {
            delete state.items[itemId]; changed = true; continue;
        }
        if (name && item.normalized !== normalized) { item.normalized = normalized; changed = true; }
    }
    return changed;
}

// ─── State I/O ────────────────────────────────────────────────────────────────

function loadState(filePath) {
    let raw;
    try { raw = readFileSync(filePath, 'utf-8'); } catch { /* not found */ }
    if (!raw) return { version: 2, updated_at: utcNow(), items: {}, views: {} };

    const [data, repaired] = tryParseJson(raw);
    if (!data || typeof data !== 'object') throw new Error('State file is invalid.');

    data.version ??= 1;
    data.updated_at ??= utcNow();
    data.items ??= {};
    data.views ??= {};

    let needsSave = repaired;
    if ((data.version || 1) < 2) needsSave = migrateV1toV2(data) || needsSave;
    needsSave = pruneCorruptedItems(data) || needsSave;
    if (needsSave) saveState(filePath, data);
    return data;
}

function saveState(filePath, state) {
    state.updated_at = utcNow();
    mkdirSync(path.dirname(filePath), { recursive: true });
    const tmp = `${filePath}.${process.pid}.tmp`;
    writeFileSync(tmp, sortedStringify(state), 'utf-8');
    renameSync(tmp, filePath);
}

// ─── Item CRUD ────────────────────────────────────────────────────────────────

function getOrCreateItem(state, rawName) {
    const name = displayName(rawName);
    const normalized = normalizeName(name);
    if (!normalized) throw new Error('Empty grocery item.');
    const id = itemIdFor(normalized);
    const existing = state.items[id];
    if (existing) {
        existing.normalized = normalized;
        existing.created_at ??= utcNow();
        return existing;
    }
    const item = { id, name, normalized, status: STATUS_NEEDED, created_at: utcNow(), updated_at: utcNow() };
    state.items[id] = item;
    return item;
}

function updateStatus(state, rawItems, status) {
    const changed = [];
    for (const rawName of splitItemTokens(rawItems)) {
        const item = getOrCreateItem(state, rawName);
        if (!item.name) item.name = displayName(rawName);
        item.status = status;
        item.updated_at = utcNow();
        changed.push(item);
    }
    return changed;
}

function removeItems(state, rawItems) {
    const removed = [];
    for (const rawName of splitItemTokens(rawItems)) {
        const id = itemIdFor(normalizeName(rawName));
        const item = state.items[id];
        if (item) { removed.push(item.name); delete state.items[id]; }
    }
    return removed;
}

function mergeItems(state, destinationName, sourceNames) {
    const destId = itemIdFor(normalizeName(destinationName));
    const destExisting = state.items[destId];
    const destination = getOrCreateItem(state, destinationName);
    const statuses = new Set();
    if (destExisting) statuses.add(destination.status || STATUS_HAVE);

    const sourceIds = [];
    for (const rawName of splitItemTokens(sourceNames)) {
        const id = itemIdFor(normalizeName(rawName));
        if (id !== destination.id) sourceIds.push(id);
    }

    const mergedNames = [];
    for (const id of sourceIds) {
        const item = state.items[id];
        if (!item) continue;
        mergedNames.push(item.name || id);
        statuses.add(item.status || STATUS_HAVE);
        delete state.items[id];
    }

    const sourceStatuses = new Set(statuses);
    if (destExisting) sourceStatuses.delete(destination.status || STATUS_HAVE);
    const effective = sourceStatuses.size > 0 ? sourceStatuses : statuses;
    destination.status = effective.has(STATUS_NEEDED) ? STATUS_NEEDED : STATUS_HAVE;
    destination.updated_at = utcNow();
    return { item: destination, merged: mergedNames };
}

function renameItem(state, sourceName, destinationName) {
    const sourceId = itemIdFor(normalizeName(sourceName));
    const source = state.items[sourceId];
    if (!source) throw new Error(`Grocery item not found: ${sourceName}`);
    const result = mergeItems(state, destinationName, [sourceName]);
    result.item.status = source.status || result.item.status || STATUS_HAVE;
    result.item.updated_at = utcNow();
    return result;
}

function sortedItems(state, status) {
    let items = Object.values(state.items);
    if (status === STATUS_NEEDED || status === STATUS_HAVE) {
        items = items.filter(item => item.status === status);
    }
    return items.sort((a, b) => {
        const aLate = a.status !== STATUS_NEEDED ? 1 : 0;
        const bLate = b.status !== STATUS_NEEDED ? 1 : 0;
        if (aLate !== bLate) return aLate - bLate;
        return (a.name || '').toLowerCase().localeCompare((b.name || '').toLowerCase());
    });
}

// ─── Views ────────────────────────────────────────────────────────────────────

function senderKey(account, target, threadId) {
    return `${account}:${target}:${threadId || '-'}`;
}

function resolveView(state, account, target, threadId) {
    const key = senderKey(account, target, threadId);
    const existing = state.views[key];
    if (existing && typeof existing === 'object') {
        existing.mode ??= VIEW_NEEDED;
        existing.account ??= account;
        existing.target ??= target;
        existing.thread_id ??= threadId ?? null;
        return existing;
    }
    const view = { account, target, thread_id: threadId ?? null, mode: VIEW_NEEDED, updated_at: utcNow() };
    state.views[key] = view;
    return view;
}

// ─── Render ───────────────────────────────────────────────────────────────────

function renderMessage(state, mode = VIEW_NEEDED, sessionIds = null) {
    const needed = sortedItems(state, STATUS_NEEDED);
    const have = sortedItems(state, STATUS_HAVE);
    const body = [];
    const buttons = [];

    if (mode === VIEW_ALL) {
        body.push('<b>Pantry</b>', '');
        const allItems = [...needed, ...have];
        if (allItems.length) {
            const byCat = {};
            for (const item of allItems) {
                const cat = categorizeItem(item.name);
                (byCat[cat] ??= []).push(item);
            }
            for (const cat of CATEGORY_ORDER) {
                const catItems = byCat[cat] || [];
                if (!catItems.length) continue;
                const inStock = catItems.filter(i => i.status === STATUS_HAVE).map(i => htmlEscape(i.name));
                const toBuy = catItems.filter(i => i.status === STATUS_NEEDED).map(i => htmlEscape(i.name));
                body.push(`<b>${htmlEscape(cat)}</b>`);
                if (inStock.length) body.push(`in stock: ${inStock.join(', ')}`);
                if (toBuy.length) body.push(`need to buy: ${toBuy.join(', ')}`);
                body.push('');
            }
        } else {
            body.push('Nothing here yet.', '');
        }
        buttons.push([{ text: 'Shopping View', callback_data: `${CALLBACK_PREFIX}:${CALLBACK_VIEW}:${VIEW_NEEDED}` }]);
    } else {
        body.push('Groceries to buy', '');
        const sessionSet = sessionIds instanceof Set ? sessionIds : null;
        const sessionHave = sessionSet ? have.filter(item => sessionSet.has(item.id)) : [];
        const allItems = [...needed, ...sessionHave];
        if (allItems.length) {
            const byCat = {};
            for (const item of allItems) {
                const cat = categorizeItem(item.name);
                (byCat[cat] ??= []).push(item);
            }
            for (const cat of CATEGORY_ORDER) {
                const catItems = byCat[cat] || [];
                if (!catItems.length) continue;
                body.push(`<b>${htmlEscape(cat)}</b>`);
                for (const item of catItems) {
                    if (item.status === STATUS_NEEDED) {
                        body.push(`☐ ${htmlEscape(item.name)}`);
                    } else {
                        body.push(`
☑ ${htmlEscape(item.name)}
`);
                    }
                }
                body.push('');
            }
        } else {
            body.push('Nothing pending.', '');
        }
        body.push('Tap to check off. Tap again to undo.');

        let row = [];
        for (const item of allItems) {
            const label = item.status === STATUS_NEEDED ? `☐ ${item.name}` : `✅ ${item.name}`;
            row.push({ text: label, callback_data: `${CALLBACK_PREFIX}:${CALLBACK_TOGGLE}:${item.id}` });
            if (row.length === 2) { buttons.push(row); row = []; }
        }
        if (row.length) buttons.push(row);
        buttons.push([{ text: 'Pantry View', callback_data: `${CALLBACK_PREFIX}:${CALLBACK_VIEW}:${VIEW_ALL}` }]);
    }

    return { message: body.join('\n').trim(), buttons };
}

// ─── Telegram API ─────────────────────────────────────────────────────────────

function httpsPost(url, data) {
    return new Promise((resolve, reject) => {
        const urlObj = new URL(url);
        const body = new URLSearchParams(
            Object.entries(data).map(([k, v]) => [k, typeof v === 'object' ? JSON.stringify(v) : String(v)])
        ).toString();
        const req = https.request({
            hostname: urlObj.hostname,
            path: urlObj.pathname,
            method: 'POST',
            family: 4,
            headers: {
                'Content-Type': 'application/x-www-form-urlencoded',
                'Content-Length': Buffer.byteLength(body),
            },
            timeout: 15_000,
        }, (res) => {
            const chunks = [];
            res.on('data', chunk => chunks.push(chunk));
            res.on('end', () => {
                try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8'))); }
                catch { reject(new Error(`Invalid JSON from Telegram API`)); }
            });
        });
        req.on('error', reject);
        req.on('timeout', () => { req.destroy(); reject(new Error('Telegram request timed out')); });
        req.write(body);
        req.end();
    });
}

function resolveTelegramToken(account) {
    const config = JSON.parse(readFileSync(openclawConfigPath(), 'utf-8'));
    const [REDACTED_SECRET];
    if (!token) throw new Error(`Missing Telegram bot token for account ${account}.`);
    return token;
}

async function telegramApi(method, payload, account) {
    const [REDACTED_SECRET]);
    const result = await httpsPost(`https://api.telegram.org/bot${token}/${method}`, payload);
    if (!result.ok) throw new Error(result.description || 'Telegram API call failed.');
    return result;
}

async function telegramSendMessage(target, account, text, buttons, threadId) {
    const payload = { chat_id: target, text, parse_mode: 'HTML', reply_markup: { inline_keyboard: buttons } };
    if (threadId) payload.message_thread_id = threadId;
    return telegramApi('sendMessage', payload, account);
}

async function telegramEditMessage(view, account, text, buttons) {
    if (!view.message_id || !view.chat_id) throw new Error('No Telegram checklist message to edit.');
    const payload = {
        chat_id: view.chat_id,
        message_id: view.message_id,
        text,
        parse_mode: 'HTML',
        reply_markup: { inline_keyboard: buttons },
    };
    try {
        return await telegramApi('editMessageText', payload, account);
    } catch (err) {
        if (String(err).toLowerCase().includes('message is not modified')) return { ok: true, not_modified: true };
        throw err;
    }
}

async function telegramDeleteMessage(chatId, messageId, account) {
    if (!chatId || !messageId) return;
    try { await telegramApi('deleteMessage', { chat_id: chatId, message_id: messageId }, account); }
    catch { /* ignore stale messages */ }
}

// ─── View send / edit ─────────────────────────────────────────────────────────

async function sendTelegramView(state, target, account, mode, threadId) {
    const view = resolveView(state, account, target, threadId);
    // Capture old message ref before mutating view
    const oldChatId = view.chat_id;
    const oldMessageId = view.message_id;

    view.mode = mode;
    if (mode === VIEW_NEEDED) {
        view.session_ids = sortedItems(state, STATUS_NEEDED).map(i => i.id);
    }
    const sessionIds = new Set(view.session_ids || []);
    const payload = renderMessage(state, mode, sessionIds);

    await telegramDeleteMessage(oldChatId, oldMessageId, account);

    const result = await telegramSendMessage(target, account, payload.message, payload.buttons, threadId);
    const msgObj = result.result || {};
    Object.assign(view, {
        message_id: msgObj.message_id,
        chat_id: (msgObj.chat || {}).id || target,
        updated_at: utcNow(),
    });
    return { ok: true, view, message: payload.message, buttons: payload.buttons, result };
}

async function editExistingView(state, target, account, threadId, mode) {
    const view = resolveView(state, account, target, threadId);
    const sessionIds = new Set(view.session_ids || []);
    const payload = renderMessage(state, mode, sessionIds);
    const result = await telegramEditMessage(view, account, payload.message, payload.buttons);
    view.mode = mode;
    view.updated_at = utcNow();
    return { ok: true, view, message: payload.message, buttons: payload.buttons, result };
}

async function updateAllViews(state, account) {
    const staleKeys = [];
    for (const [key, view] of Object.entries(state.views)) {
        if (!view || typeof view !== 'object') continue;
        if (view.account !== account || !view.message_id) continue;
        try {
            await editExistingView(state, view.target || '', account, view.thread_id, view.mode || VIEW_NEEDED);
        } catch (err) {
            const msg = String(err).toLowerCase();
            if (msg.includes('message to edit not found') || msg.includes('message_id_invalid') || msg.includes('chat not found')) {
                staleKeys.push(key);
            }
        }
    }
    for (const key of staleKeys) delete state.views[key];
}

// ─── Callbacks ────────────────────────────────────────────────────────────────

function parseCallback(raw) {
    const match = raw.match(/(?:callback_data:\s*)?(gchk:[^\s]+)/);
    const token = match ? match[1].trim() : raw.trim();
    const parts = token.split(':');
    if (parts.length !== 3 || parts[0] !== CALLBACK_PREFIX) return null;
    return [parts[1], parts[2]];
}

async function togglePending(state, itemId, target, account, threadId) {
    const item = state.items[itemId];
    if (!item) throw new Error('Grocery item not found.');
    item.status = item.status === STATUS_NEEDED ? STATUS_HAVE : STATUS_NEEDED;
    item.updated_at = utcNow();
    await updateAllViews(state, account);
    return { ok: true, item: { id: item.id, name: item.name, status: item.status } };
}

async function handleCallback(state, callback, target, account, threadId) {
    const parsed = parseCallback(callback);
    if (!parsed) throw new Error('Unsupported callback payload.');
    const [action, value] = parsed;
    const view = resolveView(state, account, target, threadId);
    if (action === CALLBACK_TOGGLE) {
        return togglePending(state, value, target, account, threadId);
    }
    if (action === CALLBACK_VIEW) {
        const mode = value === VIEW_ALL ? VIEW_ALL : VIEW_NEEDED;
        if (mode === VIEW_NEEDED) view.session_ids = sortedItems(state, STATUS_NEEDED).map(i => i.id);
        if (view.message_id) return editExistingView(state, target, account, threadId, mode);
        return sendTelegramView(state, target, account, mode, threadId);
    }
    throw new Error('Unsupported callback action.');
}

// ─── Plugin registration ──────────────────────────────────────────────────────

export default function register(api) {
    // Intercept gchk: button callbacks instantly, before Claude sees them.
    api.registerInteractiveHandler({
        channel: 'telegram',
        namespace: 'gchk',
        handler: async ({ callback, senderId }) => {
            const target = String(callback.chatId || senderId);
            const fp = statePath();
            let state;
            try { state = loadState(fp); } catch (err) {
                console.error('[grocery-checklist] state load error:', String(err));
                return;
            }
            try {
                await handleCallback(state, callback.data, target, 'grocery', null);
                saveState(fp, state);
            } catch (err) {
                console.error('[grocery-checklist] callback error:', String(err));
            }
        },
    });

    // Expose render as a proper tool so Claude calls it reliably as a
    // function call rather than trying to compose a bash command.
    api.registerTool({
        name: 'render_grocery_view',
        label: 'Render Grocery View',
        description: 'Render the grocery shopping list or pantry view as a Telegram inline-keyboard message. Use mode "needed" for the shopping list, "all" for the full pantry.',
        parameters: {
            type: 'object',
            properties: {
                mode: {
                    type: 'string',
                    enum: ['needed', 'all'],
                    description: '"needed" = shopping list, "all" = pantry view',
                },
            },
            required: [],
        },
        async execute(_toolCallId, params, context) {
            const mode = params?.mode ?? VIEW_NEEDED;
            const account = 'grocery';
            const senderId = context?.senderId ?? context?.message?.senderId;
            const fp = statePath();
            let state;
            try { state = loadState(fp); } catch (err) {
                return { ok: false, error: String(err) };
            }
            try {
                let targets = [];
                if (senderId) {
                    targets = [String(senderId)];
                } else {
                    targets = Object.values(state.views)
                        .filter(v => v && typeof v === 'object' && v.account === account && v.target)
                        .map(v => v.target);
                    if (!targets.length) {
                        const config = JSON.parse(readFileSync(openclawConfigPath(), 'utf-8'));
                        const allow = config?.channels?.telegram?.accounts?.[account]?.allowFrom || [];
                        targets = allow.map(String);
                    }
                }
                if (!targets.length) return { ok: false, error: 'No target to send to.' };
                const results = [];
                for (const target of targets) {
                    results.push(await sendTelegramView(state, target, account, mode, null));
                }
                saveState(fp, state);
                return results.length === 1 ? results[0] : { ok: true, results };
            } catch (err) {
                return { ok: false, error: String(err) };
            }
        },
    }, { name: 'render_grocery_view' });

    api.registerTool({
        name: 'mutate_grocery_items',
        label: 'Mutate Grocery Items',
        description: 'Update grocery state directly. Use action "need" to add or mark items as needed, "have" to mark bought/in stock, "remove" to delete, "rename" to rename one item, and "merge" to merge source items into a destination.',
        parameters: {
            type: 'object',
            properties: {
                action: {
                    type: 'string',
                    enum: ['need', 'have', 'remove', 'rename', 'merge'],
                },
                items: {
                    type: 'array',
                    items: { type: 'string' },
                    description: 'Items for need/have/remove actions.',
                },
                source: {
                    type: 'string',
                    description: 'Source item name for rename.',
                },
                destination: {
                    type: 'string',
                    description: 'Destination item name for rename/merge.',
                },
                sources: {
                    type: 'array',
                    items: { type: 'string' },
                    description: 'Source item names for merge.',
                },
            },
            required: ['action'],
        },
        async execute(_toolCallId, params) {
            const action = params?.action;
            const fp = statePath();
            let state;
            try { state = loadState(fp); } catch (err) {
                return { ok: false, error: String(err) };
            }
            try {
                let result;
                if (action === 'need' || action === 'have' || action === 'remove') {
                    const items = Array.isArray(params?.items) ? params.items.filter(Boolean).map(String) : [];
                    if (!items.length) return { ok: false, error: 'items is required' };
                    if (action === 'remove') {
                        result = { ok: true, removed: removeItems(state, items) };
                    } else {
                        const status = action === 'need' ? STATUS_NEEDED : STATUS_HAVE;
                        const updated = updateStatus(state, items, status);
                        result = { ok: true, updated: updated.map(i => ({ id: i.id, name: i.name, status: i.status })) };
                    }
                } else if (action === 'rename') {
                    const source = params?.source ? String(params.source) : '';
                    const destination = params?.destination ? String(params.destination) : '';
                    if (!source || !destination) return { ok: false, error: 'source and destination are required' };
                    const renamed = renameItem(state, source, destination);
                    result = { ok: true, item: { id: renamed.item.id, name: renamed.item.name, status: renamed.item.status }, renamed: source };
                } else if (action === 'merge') {
                    const destination = params?.destination ? String(params.destination) : '';
                    const sources = Array.isArray(params?.sources) ? params.sources.filter(Boolean).map(String) : [];
                    if (!destination || !sources.length) return { ok: false, error: 'destination and sources are required' };
                    const merged = mergeItems(state, destination, sources);
                    result = { ok: true, item: { id: merged.item.id, name: merged.item.name, status: merged.item.status }, merged: merged.merged };
                } else {
                    return { ok: false, error: `unsupported action: ${String(action)}` };
                }
                saveState(fp, state);
                return result;
            } catch (err) {
                return { ok: false, error: String(err) };
            }
        },
    }, { name: 'mutate_grocery_items' });
}


path: README.md
content: # Grocery Checklist

Grocery Checklist is a pantry-backed shopping skill for OpenClaw.

It supports:
- persistent `needed` / `have` state
- natural language pantry updates like `I ran out of salt`
- shopping-list queries like `what do I need to buy`
- Telegram inline checklist buttons
- immediate mark-bought on tap in Telegram
- normal conversational handling through OpenClaw

## What this skill is

This is not a generic todo list.

It is a lightweight pantry state machine:
- `ran out of eggs` -> eggs become `needed`
- `bought eggs` -> eggs become `have`
- `what do I need to buy` -> only `needed` items are shown

If you want OpenClaw to be the source of truth and Telegram to be the shopping UI, this is the right shape.

## Intended install mode

The intended experience is:
- OpenClaw handles conversation normally
- this skill provides the grocery state/actions
- Telegram is just the UI surface for shopping list and pantry views

The default install path is the managed OpenClaw route, not a standalone Telegram parser bot.

## Files and config it uses

- reads `~/.openclaw/openclaw.json` when you bind a Telegram `grocery` account
- writes pantry state to `~/.openclaw/data/grocery-checklist/state.json`
- writes Telegram polling offset to `~/.openclaw/data/grocery-checklist/telegram-bot-state.json`
- uses the bundled wrapper at `scripts/grocery.sh`

## What the user needs

- `python3`
- `openclaw`
- a working OpenClaw Telegram bot if you want the inline checklist UI

Optional:
- a dedicated grocery Telegram bot/account bound to a dedicated OpenClaw agent

## Install summary

1. Put the skill at `~/.openclaw/skills/grocery-checklist`
2. Verify `python3` and `openclaw` are installed
3. Keep the bundled `index.js` in the skill root so OpenClaw can load the grocery tools and Telegram callback handler
4. Run the wrapper in `scripts/grocery.sh`
5. Bind a dedicated Telegram bot/account to grocery-only routing if you want Telegram UI
6. Let OpenClaw handle the conversation layer

Advanced:
- `scripts/telegram_bot.py` exists as an optional standalone bot implementation, but it is not the primary recommended setup

For the full process, read [SETUP.md](./SETUP.md).

## Good test prompts

- `I ran out of salt`
- `I ran out of eggs, rice, and bread`
- `what do I need to buy`
- `I bought eggs`

## For agents

Agent-specific operating rules live in [AGENTS.md](./AGENTS.md).


path: package.json
content: {
  "name": "grocery-checklist",
  "version": "1.1.3",
  "description": "Instant Telegram button handling for the grocery checklist skill",
  "type": "module",
  "main": "index.js",
  "openclaw": {
    "extensions": ["./index.js"],
    "build": {
      "openclawVersion": "2026.4.2"
    },
    "compat": {
      "pluginApi": ">=2026.4.0",
      "minGatewayVersion": "2026.4.0"
    }
  }
}


path: scripts/telegram_bot.py
content: #!/usr/bin/env python3

from __future__ import annotations

import argparse
import json
import re
import subprocess
import sys
import time
from pathlib import Path
from typing import Any
from urllib import error, parse, request


SCRIPT_DIR = Path(__file__).resolve().parent
CONFIG_PATH = Path.home() / ".openclaw" / "openclaw.json"
WRAPPER_PATH = SCRIPT_DIR / "grocery.sh"
BOT_STATE_PATH = Path.home() / ".openclaw" / "data" / "grocery-checklist" / "telegram-bot-state.json"


def load_config() -> dict[str, Any]:
    return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))


def grocery_account_config() -> dict[str, Any]:
    config = load_config()
    return (((config.get("channels") or {}).get("telegram") or {}).get("accounts") or {}).get("grocery") or {}


def telegram_token() -> str:
    [REDACTED_SECRET]).get("botToken")
    if not token:
        raise SystemExit("Missing grocery Telegram bot token in openclaw.json")
    return token


def allowed_user_ids() -> set[str]:
    allow_from = grocery_account_config().get("allowFrom") or []
    return {str(value) for value in allow_from}


def api(method: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
    [REDACTED_SECRET])
    payload = payload or {}
    data = parse.urlencode({
        key: json.dumps(value) if isinstance(value, (dict, list)) else str(value)
        for key, value in payload.items()
    }).encode("utf-8")
    req = request.Request(f"https://api.telegram.org/bot{token}/{method}", data=data, method="POST")
    try:
        with request.urlopen(req, timeout=30) as response:
            raw = response.read().decode("utf-8")
    except error.HTTPError as exc:
        detail = exc.read().decode("utf-8", errors="replace")
        raise RuntimeError(detail or str(exc)) from exc
    decoded = json.loads(raw)
    if not decoded.get("ok"):
        raise RuntimeError(decoded.get("description", f"Telegram API error for {method}"))
    return decoded


def run_wrapper(args: list[str]) -> dict[str, Any]:
    completed = subprocess.run(
        [str(WRAPPER_PATH), *args],
        capture_output=True,
        text=True,
        check=False,
    )
    if completed.returncode != 0:
        raise RuntimeError((completed.stderr or completed.stdout or "grocery wrapper failed").strip())
    stdout = completed.stdout.strip()
    if not stdout:
        return {"ok": True}
    try:
        return json.loads(stdout)
    except json.JSONDecodeError:
        return {"ok": True, "raw": stdout}


def send_text(chat_id: str, text: str) -> None:
    api("sendMessage", {"chat_id": chat_id, "text": text})


def answer_callback(callback_id: str) -> None:
    api("answerCallbackQuery", {"callback_query_id": callback_id})


def load_offset() -> int | None:
    if not BOT_STATE_PATH.exists():
        return None
    try:
        data = json.loads(BOT_STATE_PATH.read_text(encoding="utf-8"))
    except json.JSONDecodeError:
        return None
    value = data.get("offset")
    return int(value) if isinstance(value, int) else None


def save_offset(offset: int) -> None:
    BOT_STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
    BOT_STATE_PATH.write_text(json.dumps({"offset": offset}, indent=2) + "\n", encoding="utf-8")


def split_items(raw: str) -> list[str]:
    parts = re.split(r",|\n|(?:\band\b)", raw, flags=re.IGNORECASE)
    items = []
    for part in parts:
        cleaned = re.sub(r"\s+", " ", part).strip(" .")
        if cleaned:
            items.append(cleaned)
    return items


def normalize_item_phrase(raw: str) -> str:
    cleaned = re.sub(r"\s+", " ", raw).strip(" .")
    cleaned = re.sub(r"^(?:also\s+|just\s+|please\s+)", "", cleaned, flags=re.IGNORECASE)
    cleaned = re.sub(
        r"\s+(?:in|on)\s+(?:the\s+)?shopping list$",
        "",
        cleaned,
        flags=re.IGNORECASE,
    )
    return cleaned.strip(" .")


def parse_merge_intent(text: str) -> tuple[str, list[str]] | None:
    lower = text.lower().strip()
    match = re.search(r"merge\s+(.+?)\s+into\s+(.+)$", lower)
    if match:
        sources = split_items(match.group(1))
        destination = re.sub(r"\s+", " ", match.group(2)).strip()
        if destination and sources:
            return destination, sources

    match = re.search(r"fix\s+(.+)$", lower)
    if match:
        destination = re.sub(r"\s+", " ", match.group(1)).strip()
        tokens = destination.split()
        if len(tokens) >= 2:
            return destination, tokens

    match = re.search(r"(.+?)\s+shouldn['’]t be separate", lower)
    if match:
        source = re.sub(r"\s+", " ", match.group(1)).strip()
        if " and " in source:
            sources = [part.strip() for part in source.split(" and ") if part.strip()]
            destination = " ".join(sources)
            if len(sources) >= 2:
                return destination, sources
    return None


def parse_rename_intent(text: str) -> tuple[str, str] | None:
    lower = text.lower().strip()
    match = re.search(r"rename\s+(.+?)\s+to\s+(.+)$", lower)
    if match:
        source = re.sub(r"\s+", " ", match.group(1)).strip()
        destination = re.sub(r"\s+", " ", match.group(2)).strip()
        if source and destination:
            return source, destination
    match = re.search(r"change\s+(.+?)\s+to\s+(.+)$", lower)
    if match:
        source = re.sub(r"\s+", " ", match.group(1)).strip()
        destination = re.sub(r"\s+", " ", match.group(2)).strip()
        if source and destination:
            return source, destination
    return None


def extract_need_items(text: str) -> list[str]:
    patterns = [
        r"^(?:i|we)\s+ran out of\s+(.+)$",
        r"^(?:i|we)\s+(?:don't|dont|do not)\s+have\s+(.+?)(?:\s+in stock)?$",
        r"^(?:i|we)\s+(?:also\s+)?need to buy\s+(.+)$",
        r"^(?:i|we)\s+(?:also\s+)?need\s+(.+)$",
        r"^(?:we\s+also\s+need)\s+(.+)$",
        r"^(?:also\s+)?need to buy\s+(.+)$",
        r"^put\s+(.+?)\s+(?:in|on)\s+(?:the\s+)?shopping list$",
        r"^put\s+(.+?)\s+on\s+the list$",
        r"^put\s+(.+?)\s+in groceries$",
        r"^add\s+(.+?)\s+to groceries$",
        r"^add\s+(.+?)\s+to the shopping list$",
        r"^add\s+(.+?)\s+to the list$",
    ]
    stripped = text.strip()
    for pattern in patterns:
        match = re.match(pattern, stripped, flags=re.IGNORECASE)
        if match:
            normalized = normalize_item_phrase(match.group(1))
            if normalized:
                return split_items(normalized)
    return []


def extract_have_items(text: str) -> list[str]:
    patterns = [
        r"^(?:i|we)\s+bought\s+(.+)$",
        r"^(?:i|we)\s+got\s+(.+)$",
        r"^(?:i|we)\s+picked up\s+(.+)$",
        r"^mark\s+(.+?)\s+bought$",
        r"^mark\s+(.+?)\s+as bought$",
        r"^i have\s+(.+)$",
    ]
    stripped = text.strip()
    for pattern in patterns:
        match = re.match(pattern, stripped, flags=re.IGNORECASE)
        if match:
            return split_items(match.group(1))
    return []


def is_shopping_view_intent(lower: str) -> bool:
    if "show me" in lower and ("shopping view" in lower or "shopping list" in lower):
        return True
    if lower in {"shopping view", "shopping list"}:
        return True
    if "i'm shopping" in lower or "im shopping" in lower:
        return True
    if "what do i need to buy" in lower:
        return True
    return False


def is_pantry_view_intent(lower: str) -> bool:
    if "show pantry" in lower or "show me the pantry" in lower or "pantry view" in lower:
        return True
    if "what do i have" in lower or "what's in the pantry" in lower or "whats in the pantry" in lower:
        return True
    return False


def is_greeting(lower: str) -> bool:
    greetings = {"hi", "hello", "hey", "yo", "sup", "are you there", "hello?", "hi?"}
    return lower in greetings


def non_grocery_reply(lower: str) -> str | None:
    if lower in {"nothing atm", "nothing at the moment", "nothing right now", "nope", "nah"}:
        return "Okay."
    if re.search(r"\bi (?:do not|don't|dont) want any groceries\b", lower):
        return "Okay."
    if re.search(r"\bno groceries\b", lower):
        return "Okay."
    if any(token in lower for token in {"fuck", "motherfucker", "wtf", "stupid bot"}):
        return "I'm here when you need groceries."
    return None


def handle_text(chat_id: str, sender_id: str, text: str) -> None:
    lower = text.lower().strip()

    if is_greeting(lower):
        send_text(chat_id, "I'm here.")
        return

    casual = non_grocery_reply(lower)
    if casual:
        send_text(chat_id, casual)
        return

    rename = parse_rename_intent(text)
    if rename:
        source, destination = rename
        run_wrapper(["rename", source, destination])
        send_text(chat_id, f"Renamed to {destination}.")
        return

    merge = parse_merge_intent(text)
    if merge:
        destination, sources = merge
        run_wrapper(["merge", destination, *sources])
        send_text(chat_id, f"Merged into {destination}.")
        return

    if is_shopping_view_intent(lower):
        run_wrapper(["render-telegram", "--target", sender_id, "--account", "grocery"])
        return

    if is_pantry_view_intent(lower):
        run_wrapper(["render-telegram", "--target", sender_id, "--account", "grocery", "--mode", "all"])
        return

    need_items = extract_need_items(text)
    if need_items:
        run_wrapper(["need", *need_items])
        send_text(chat_id, "Added to groceries.")
        return

    have_items = extract_have_items(text)
    if have_items:
        run_wrapper(["have", *have_items])
        send_text(chat_id, "Updated pantry.")
        return

    send_text(chat_id, "Tell me what you want to add, buy, remove, or show.")


def handle_callback(callback: dict[str, Any]) -> None:
    callback_id = callback.get("id")
    message = callback.get("message") or {}
    chat = message.get("chat") or {}
    chat_id = str(chat.get("id"))
    from_user = callback.get("from") or {}
    sender_id = str(from_user.get("id"))
    data = callback.get("data") or ""
    if data.startswith("gchk:"):
        run_wrapper(["handle-callback", data, "--target", sender_id, "--account", "grocery"])
    if callback_id:
        answer_callback(callback_id)


def poll_forever() -> None:
    allow = allowed_user_ids()
    offset = load_offset()
    while True:
        payload: dict[str, Any] = {"timeout": 25}
        if offset is not None:
            payload["offset"] = offset
        updates = api("getUpdates", payload).get("result", [])
        for update in updates:
            offset = int(update["update_id"]) + 1
            if "callback_query" in update:
                callback = update["callback_query"]
                sender_id = str((callback.get("from") or {}).get("id"))
                if sender_id in allow:
                    handle_callback(callback)
                continue

            message = update.get("message") or {}
            chat = message.get("chat") or {}
            if chat.get("type") != "private":
                continue
            sender_id = str((message.get("from") or {}).get("id"))
            if sender_id not in allow:
                continue
            text = message.get("text")
            if isinstance(text, str) and text.strip():
                handle_text(str(chat.get("id")), sender_id, text)
        if offset is not None:
            save_offset(offset)
        time.sleep(1)


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.parse_args()
    poll_forever()


if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        sys.exit(0)


path: scripts/grocery.py
content: #!/usr/bin/env python3

from __future__ import annotations

import argparse
import hashlib
import json
import os
import re
import subprocess
import sys
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
import socket
from urllib import error, parse, request

# Telegram's API servers are reachable via IPv4. Force IPv4 to avoid
# the multi-second delay that happens when IPv6 is attempted first but
# unreachable (same workaround OpenClaw's Node networking applies).
_orig_getaddrinfo = socket.getaddrinfo
def _ipv4_getaddrinfo(host, port, family=0, *args, **kwargs):
    results = _orig_getaddrinfo(host, port, family, *args, **kwargs)
    ipv4 = [r for r in results if r[0] == socket.AF_INET]
    return ipv4 if ipv4 else results
socket.getaddrinfo = _ipv4_getaddrinfo


STATUS_NEEDED = "needed"
STATUS_HAVE = "have"
DEFAULT_ACCOUNT = "default"
CALLBACK_PREFIX = "gchk"
CALLBACK_TOGGLE = "tgl"
CALLBACK_COMMIT = "commit"
CALLBACK_VIEW = "view"
VIEW_NEEDED = "needed"
VIEW_ALL = "all"


def utc_now() -> str:
    return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")


def state_path() -> Path:
    raw = os.environ.get("GROCERY_STATE_FILE")
    if raw:
        return Path(raw).expanduser()
    return Path.home() / ".openclaw" / "data" / "grocery-checklist" / "state.json"


def ensure_parent(path: Path) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)


def salvage_json_object(raw: str) -> tuple[dict[str, Any], bool]:
    decoder = json.JSONDecoder()
    parsed, end = decoder.raw_decode(raw)
    if not isinstance(parsed, dict):
        raise SystemExit("State file is invalid.")
    trailing = raw[end:].strip()
    return parsed, bool(trailing)


def prune_corrupted_items(state: dict[str, Any]) -> bool:
    items = state.get("items")
    if not isinstance(items, dict):
        state["items"] = {}
        return True

    changed = False
    remove_ids: list[str] = []
    for item_id, item in items.items():
        if not isinstance(item, dict):
            remove_ids.append(item_id)
            changed = True
            continue
        name = str(item.get("name", "")).strip()
        normalized = normalize_name(name) if name else ""
        if (
            re.fullmatch(r"[0-9a-f]{10}", normalized)
            and normalized in items
            and normalized != item_id
        ):
            remove_ids.append(item_id)
            changed = True
            continue
        if name and item.get("normalized") != normalized:
            item["normalized"] = normalized
            changed = True

    for item_id in remove_ids:
        items.pop(item_id, None)

    return changed


def migrate_v1_to_v2(state: dict[str, Any]) -> bool:
    """Re-index item IDs after plural normalization was added in v2."""
    items = state.get("items")
    if not isinstance(items, dict):
        state["version"] = 2
        return False
    new_items: dict[str, Any] = {}
    changed = False
    for old_id, item in items.items():
        if not isinstance(item, dict):
            changed = True
            continue
        name = item.get("name", "")
        new_normalized = normalize_name(name)
        new_id = item_id_for(new_normalized)
        item["id"] = new_id
        item["normalized"] = new_normalized
        if new_id != old_id:
            changed = True
        if new_id in new_items:
            existing = new_items[new_id]
            if item.get("status") == STATUS_NEEDED or existing.get("status") == STATUS_NEEDED:
                existing["status"] = STATUS_NEEDED
            if item.get("updated_at", "") > existing.get("updated_at", ""):
                existing["updated_at"] = item["updated_at"]
        else:
            new_items[new_id] = item
    state["items"] = new_items
    state["version"] = 2
    return changed


def load_state(path: Path) -> dict[str, Any]:
    if not path.exists():
        return {
            "version": 2,
            "updated_at": utc_now(),
            "items": {},
            "views": {},
        }
    repaired = False
    raw = path.read_text(encoding="utf-8")
    try:
        data = json.loads(raw)
    except json.JSONDecodeError:
        data, repaired = salvage_json_object(raw)
    if not isinstance(data, dict):
        raise SystemExit("State file is invalid.")
    data.setdefault("version", 1)
    data.setdefault("updated_at", utc_now())
    data.setdefault("items", {})
    data.setdefault("views", {})
    if data.get("version", 1) < 2:
        repaired = migrate_v1_to_v2(data) or repaired
    repaired = prune_corrupted_items(data) or repaired
    if repaired:
        save_state(path, data)
    return data


def save_state(path: Path, state: dict[str, Any]) -> None:
    ensure_parent(path)
    state["updated_at"] = utc_now()
    tmp = path.with_name(f"{path.stem}.{os.getpid()}.tmp")
    with tmp.open("w", encoding="utf-8") as fh:
        json.dump(state, fh, indent=2, sort_keys=True)
        fh.write("\n")
    tmp.replace(path)


def _depluralize_word(word: str) -> str:
    if len(word) <= 3:
        return word
    if word.endswith("ies") and len(word) > 4:
        return word[:-3] + "y"
    if word.endswith("s") and not word.endswith("ss"):
        return word[:-1]
    return word


def normalize_name(value: str) -> str:
    lowered = value.strip().lower()
    lowered = re.sub(r"[&+]", " and ", lowered)
    lowered = re.sub(r"[^a-z0-9]+", " ", lowered)
    lowered = re.sub(r"\s+", " ", lowered).strip()
    return " ".join(_depluralize_word(w) for w in lowered.split())


def display_name(value: str) -> str:
    value = re.sub(r"\s+", " ", value.strip())
    return value


def item_id_for(normalized: str) -> str:
    return hashlib.sha1(normalized.encode("utf-8")).hexdigest()[:10]


_CATEGORY_KEYWORDS: list[tuple[str, list[str]]] = [
    ("Fruits & Vegetables", ["apple", "banana", "orange", "lemon", "lime", "tomato", "onion", "garlic", "ginger", "carrot", "potato", "lettuce", "cucumber", "capsicum", "pepper", "chili", "coriander", "spring onion", "celery", "mushroom", "avocado", "mango", "grape", "berry", "honeydew", "watermelon", "melon", "basil", "parsley", "mint", "spinach", "broccoli", "cauliflower", "zucchini", "eggplant", "corn", "sweet corn", "pea", "bean", "herb"]),
    ("Meat & Fish", ["chicken", "beef", "pork", "lamb", "fish", "prawn", "shrimp", "salmon", "tuna", "mackerel", "turkey", "duck", "sausage", "bacon", "mince", "steak"]),
    ("Dairy & Eggs", ["milk", "cheese", "egg", "yogurt", "yoghurt", "butter", "cream", "condensed milk"]),
    ("Bakery", ["bread", "wrap", "tortilla", "pita", "bun", "roll", "bagel"]),
    ("Staples", ["rice", "pasta", "noodle", "flour", "sugar", "salt", "oil", "vinegar", "sauce", "soy", "stock", "broth", "paste", "mayonnaise", "ketchup", "mustard", "coconut"]),
    ("Spices", ["seasoning", "spice", "cumin", "turmeric", "paprika", "curry", "laksa", "fajita", "chili flake", "chilli flake"]),
    ("Beverages", ["tea", "coffee", "juice", "water", "drink", "soda", "cola"]),
    ("Snacks & Sweets", ["chocolate", "biscuit", "cookie", "crisp", "chip", "marshmallow", "candy", "sweet", "snack"]),
]
_CATEGORY_ORDER = [cat for cat, _ in _CATEGORY_KEYWORDS] + ["Other"]

_HTML_TAG_RE = re.compile(r"<[^>]+>")


def categorize_item(name: str) -> str:
    normalized = normalize_name(name)
    for category, keywords in _CATEGORY_KEYWORDS:
        for keyword in keywords:
            if re.search(r"\b" + re.escape(keyword) + r"\b", normalized):
                return category
    return "Other"


def strip_html(text: str) -> str:
    text = _HTML_TAG_RE.sub("", text)
    return text.replace("&amp;", "&").replace("&lt;", "<").replace("&gt;", ">")


def split_item_tokens(values: list[str]) -> list[str]:
    items: list[str] = []
    for raw in values:
        for part in re.split(r",|\n|(?:\band\b)", raw):
            cleaned = display_name(part)
            if cleaned:
                items.append(cleaned)
    seen: set[str] = set()
    unique: list[str] = []
    for item in items:
        key = normalize_name(item)
        if not key or key in seen:
            continue
        seen.add(key)
        unique.append(item)
    return unique


def get_or_create_item(state: dict[str, Any], raw_name: str) -> dict[str, Any]:
    name = display_name(raw_name)
    normalized = normalize_name(name)
    if not normalized:
        raise ValueError("Empty grocery item.")
    item_id = item_id_for(normalized)
    items = state["items"]
    existing = items.get(item_id)
    if existing:
        existing["normalized"] = normalized
        existing.setdefault("created_at", utc_now())
        return existing
    item = {
        "id": item_id,
        "name": name,
        "normalized": normalized,
        "status": STATUS_NEEDED,
        "created_at": utc_now(),
        "updated_at": utc_now(),
    }
    items[item_id] = item
    return item


def update_status(state: dict[str, Any], raw_items: list[str], status: str) -> list[dict[str, Any]]:
    changed: list[dict[str, Any]] = []
    for raw_name in split_item_tokens(raw_items):
        item = get_or_create_item(state, raw_name)
        if not item.get("name"):
            item["name"] = display_name(raw_name)
        item["status"] = status
        item["updated_at"] = utc_now()
        changed.append(item)
    return changed


def remove_items(state: dict[str, Any], raw_items: list[str]) -> list[str]:
    removed: list[str] = []
    for raw_name in split_item_tokens(raw_items):
        normalized = normalize_name(raw_name)
        item_id = item_id_for(normalized)
        item = state["items"].pop(item_id, None)
        if item:
            removed.append(item["name"])
    return removed


def merge_items(state: dict[str, Any], destination_name: str, source_names: list[str]) -> dict[str, Any]:
    destination_normalized = normalize_name(destination_name)
    destination_id = item_id_for(destination_normalized)
    destination_existing = state.get("items", {}).get(destination_id)
    destination = get_or_create_item(state, destination_name)
    merged_names: list[str] = []
    statuses: set[str] = set()
    if destination_existing:
        statuses.add(destination.get("status", STATUS_HAVE))

    source_ids = []
    for raw_name in split_item_tokens(source_names):
        normalized = normalize_name(raw_name)
        item_id = item_id_for(normalized)
        if item_id == destination["id"]:
            continue
        source_ids.append(item_id)

    for item_id in source_ids:
        item = state["items"].get(item_id)
        if not item:
            continue
        merged_names.append(item.get("name", item_id))
        statuses.add(item.get("status", STATUS_HAVE))
        state["items"].pop(item_id, None)

    source_statuses = statuses.copy()
    if destination_existing:
        source_statuses.discard(destination.get("status", STATUS_HAVE))
    effective_statuses = source_statuses or statuses
    destination["status"] = STATUS_NEEDED if STATUS_NEEDED in effective_statuses else STATUS_HAVE
    destination["updated_at"] = utc_now()
    return {
        "item": destination,
        "merged": merged_names,
    }


def rename_item(state: dict[str, Any], source_name: str, destination_name: str) -> dict[str, Any]:
    normalized_source = normalize_name(source_name)
    source_id = item_id_for(normalized_source)
    source = state["items"].get(source_id)
    if not source:
        raise RuntimeError(f"Grocery item not found: {source_name}")

    result = merge_items(state, destination_name, [source_name])
    destination = result["item"]
    destination["status"] = source.get("status", destination.get("status", STATUS_HAVE))
    destination["updated_at"] = utc_now()
    return result


def sorted_items(state: dict[str, Any], status: str | None = None) -> list[dict[str, Any]]:
    items = list(state["items"].values())
    if status in {STATUS_NEEDED, STATUS_HAVE}:
        items = [item for item in items if item.get("status") == status]
    return sorted(items, key=lambda item: (item.get("status") != STATUS_NEEDED, item.get("name", "").lower()))


def sender_key(account: str, target: str, thread_id: str | None) -> str:
    return f"{account}:{target}:{thread_id or '-'}"


def html_escape(value: str) -> str:
    return (
        value.replace("&", "&amp;")
        .replace("<", "&lt;")
        .replace(">", "&gt;")
    )


def checkbox_line(name: str, checked: bool) -> str:
    safe = html_escape(name)
    if checked:
        return f"☑ 
{safe}
"
    return f"☐ {safe}"


def resolve_view(state: dict[str, Any], account: str, target: str, thread_id: str | None) -> dict[str, Any]:
    key = sender_key(account, target, thread_id)
    views = state["views"]
    raw = views.get(key)
    if isinstance(raw, dict):
        raw.setdefault("mode", VIEW_NEEDED)
        raw.setdefault("account", account)
        raw.setdefault("target", target)
        raw.setdefault("thread_id", thread_id)
        return raw
    view = {
        "account": account,
        "target": target,
        "thread_id": thread_id,
        "mode": VIEW_NEEDED,
        "updated_at": utc_now(),
    }
    views[key] = view
    return view


def render_message(state: dict[str, Any], mode: str = VIEW_NEEDED, session_ids: set[str] | None = None) -> dict[str, Any]:
    needed = sorted_items(state, STATUS_NEEDED)
    have = sorted_items(state, STATUS_HAVE)
    body: list[str] = []
    buttons: list[list[dict[str, str]]] = []

    if mode == VIEW_ALL:
        body.append("<b>Pantry</b>")
        body.append("")
        all_items = needed + have
        if all_items:
            by_cat: dict[str, list] = defaultdict(list)
            for item in all_items:
                by_cat[categorize_item(item["name"])].append(item)
            for cat in _CATEGORY_ORDER:
                cat_items = by_cat.get(cat, [])
                if not cat_items:
                    continue
                in_stock = [html_escape(i["name"]) for i in cat_items if i["status"] == STATUS_HAVE]
                to_buy = [html_escape(i["name"]) for i in cat_items if i["status"] == STATUS_NEEDED]
                body.append(f"<b>{html_escape(cat)}</b>")
                if in_stock:
                    body.append(f"in stock: {', '.join(in_stock)}")
                if to_buy:
                    body.append(f"need to buy: {', '.join(to_buy)}")
                body.append("")
        else:
            body.append("Nothing here yet.")
            body.append("")
        buttons.append([{"text": "Shopping View", "callback_data": f"{CALLBACK_PREFIX}:{CALLBACK_VIEW}:{VIEW_NEEDED}"}])
    else:
        body.append("Groceries to buy")
        body.append("")
        session_have = [item for item in have if session_ids and item["id"] in session_ids]
        all_items = needed + session_have
        if all_items:
            by_cat: dict[str, list] = defaultdict(list)
            for item in all_items:
                by_cat[categorize_item(item["name"])].append(item)
            for cat in _CATEGORY_ORDER:
                cat_items = by_cat.get(cat, [])
                if not cat_items:
                    continue
                body.append(f"<b>{html_escape(cat)}</b>")
                for item in cat_items:
                    if item["status"] == STATUS_NEEDED:
                        body.append(f"☐ {html_escape(item['name'])}")
                    else:
                        body.append(f"
☑ {html_escape(item['name'])}
")
                body.append("")
        else:
            body.append("Nothing pending.")
            body.append("")
        body.append("Tap to check off. Tap again to undo.")

        row: list[dict[str, str]] = []
        for item in all_items:
            label = f"☐ {item['name']}" if item["status"] == STATUS_NEEDED else f"✅ {item['name']}"
            row.append({
                "text": label,
                "callback_data": f"{CALLBACK_PREFIX}:{CALLBACK_TOGGLE}:{item['id']}",
            })
            if len(row) == 2:
                buttons.append(row)
                row = []
        if row:
            buttons.append(row)
        buttons.append([{"text": "Pantry View", "callback_data": f"{CALLBACK_PREFIX}:{CALLBACK_VIEW}:{VIEW_ALL}"}])
    return {
        "message": "\n".join(body).strip(),
        "buttons": buttons,
    }


def run_openclaw(args: list[str], dry_run: bool = False) -> dict[str, Any]:
    cmd = ["openclaw", *args]
    if dry_run:
        return {"ok": True, "dry_run": True, "command": cmd}
    completed = subprocess.run(cmd, capture_output=True, text=True)
    if completed.returncode != 0:
        raise RuntimeError((completed.stderr or completed.stdout or "openclaw command failed").strip())
    stdout = completed.stdout.strip()
    if not stdout:
        return {"ok": True}
    try:
        return json.loads(stdout)
    except json.JSONDecodeError:
        return {"ok": True, "raw": stdout}


def openclaw_config_path() -> Path:
    return Path.home() / ".openclaw" / "openclaw.json"


def resolve_telegram_token(account: str) -> str:
    config = json.loads(openclaw_config_path().read_text(encoding="utf-8"))
    [REDACTED_SECRET]"channels") or {}).get("telegram") or {}).get("accounts") or {}).get(account, {}).get("botToken")
    if not token:
        raise RuntimeError(f"Missing Telegram bot token for account {account}.")
    return token


def telegram_api(method: str, payload: dict[str, Any], account: str, dry_run: bool = False) -> dict[str, Any]:
    [REDACTED_SECRET])
    if dry_run:
        return {"ok": True, "dry_run": True, "method": method, "payload": payload}
    data = parse.urlencode({k: json.dumps(v) if isinstance(v, (dict, list)) else str(v) for k, v in payload.items()}).encode("utf-8")
    req = request.Request(f"https://api.telegram.org/bot{token}/{method}", data=data, method="POST")
    try:
        with request.urlopen(req, timeout=15) as resp:
            raw = resp.read().decode("utf-8")
    except error.HTTPError as exc:
        detail = exc.read().decode("utf-8", errors="replace")
        raise RuntimeError(detail or str(exc)) from exc
    decoded = json.loads(raw)
    if not decoded.get("ok"):
        raise RuntimeError(decoded.get("description", "Telegram API call failed."))
    return decoded


def telegram_send_message(target: str, account: str, text: str, buttons: list[list[dict[str, str]]], thread_id: str | None, dry_run: bool) -> dict[str, Any]:
    payload: dict[str, Any] = {
        "chat_id": target,
        "text": text,
        "parse_mode": "HTML",
        "reply_markup": {"inline_keyboard": buttons},
    }
    if thread_id:
        payload["message_thread_id"] = thread_id
    return telegram_api("sendMessage", payload, account, dry_run=dry_run)


def telegram_edit_message(view: dict[str, Any], account: str, text: str, buttons: list[list[dict[str, str]]], dry_run: bool) -> dict[str, Any]:
    if not view.get("message_id") or not view.get("chat_id"):
        raise RuntimeError("No Telegram checklist message to edit.")
    payload = {
        "chat_id": view["chat_id"],
        "message_id": view["message_id"],
        "text": text,
        "parse_mode": "HTML",
        "reply_markup": {"inline_keyboard": buttons},
    }
    try:
        return telegram_api("editMessageText", payload, account, dry_run=dry_run)
    except RuntimeError as exc:
        if "message is not modified" in str(exc).lower():
            return {"ok": True, "not_modified": True}
        raise


def telegram_delete_message(view: dict[str, Any], account: str, dry_run: bool) -> None:
    if not view.get("message_id") or not view.get("chat_id"):
        return
    try:
        telegram_api("deleteMessage", {
            "chat_id": view["chat_id"],
            "message_id": view["message_id"],
        }, account, dry_run=dry_run)
    except RuntimeError:
        return


def maybe_delete_previous_view(state: dict[str, Any], key: str, account: str, dry_run: bool) -> None:
    view = state["views"].get(key)
    if isinstance(view, dict):
        telegram_delete_message(view, account, dry_run=dry_run)


def send_telegram_view(state: dict[str, Any], target: str, account: str, mode: str, thread_id: str | None, dry_run: bool) -> dict[str, Any]:
    view = resolve_view(state, account, target, thread_id)
    view["mode"] = mode
    if mode == VIEW_NEEDED:
        view["session_ids"] = [item["id"] for item in sorted_items(state, STATUS_NEEDED)]
    session_ids = set(view.get("session_ids") or [])
    payload = render_message(state, mode=mode, session_ids=session_ids)
    key = sender_key(account, target, thread_id)
    if not dry_run:
        maybe_delete_previous_view(state, key, account, dry_run=False)
    result = telegram_send_message(target, account, payload["message"], payload["buttons"], thread_id, dry_run=dry_run)
    message_obj = result.get("result", {})
    view.update({
        "message_id": message_obj.get("message_id"),
        "chat_id": (message_obj.get("chat") or {}).get("id", target),
        "updated_at": utc_now(),
    })
    return {
        "ok": True,
        "view": view,
        "message": payload["message"],
        "buttons": payload["buttons"],
        "result": result,
    }


def parse_callback(raw: str) -> tuple[str, str] | None:
    match = re.search(r"(?:callback_data:\s*)?(gchk:[^\s]+)", raw)
    [REDACTED_SECRET]).strip() if match else raw.strip()
    parts = token.split(":")
    if len(parts) != 3 or parts[0] != CALLBACK_PREFIX:
        return None
    return parts[1], parts[2]


def edit_existing_view(state: dict[str, Any], target: str, account: str, thread_id: str | None, mode: str, dry_run: bool) -> dict[str, Any]:
    view = resolve_view(state, account, target, thread_id)
    session_ids = set(view.get("session_ids") or [])
    payload = render_message(state, mode=mode, session_ids=session_ids)
    result = telegram_edit_message(view, account, payload["message"], payload["buttons"], dry_run=dry_run)
    view["mode"] = mode
    view["updated_at"] = utc_now()
    return {
        "ok": True,
        "view": view,
        "message": payload["message"],
        "buttons": payload["buttons"],
        "result": result,
    }


def update_all_views(state: dict[str, Any], account: str, dry_run: bool) -> None:
    """Re-render every active view for this account. Used after an item status change."""
    views = state.get("views", {})
    stale_keys: list[str] = []
    for key, view in list(views.items()):
        if not isinstance(view, dict):
            continue
        if view.get("account") != account:
            continue
        if not view.get("message_id"):
            continue
        mode = view.get("mode", VIEW_NEEDED)
        target = view.get("target", "")
        thread_id = view.get("thread_id")
        try:
            edit_existing_view(state, target=target, account=account, thread_id=thread_id, mode=mode, dry_run=dry_run)
        except RuntimeError as exc:
            msg = str(exc).lower()
            if "message to edit not found" in msg or "message_id_invalid" in msg or "chat not found" in msg:
                stale_keys.append(key)
    for key in stale_keys:
        views.pop(key, None)


def handle_callback(state: dict[str, Any], callback: str, target: str, account: str, thread_id: str | None, dry_run: bool) -> dict[str, Any]:
    parsed = parse_callback(callback)
    if not parsed:
        raise RuntimeError("Unsupported callback payload.")
    action, value = parsed
    view = resolve_view(state, account, target, thread_id)
    if action == CALLBACK_TOGGLE:
        return toggle_pending(state, item_id=value, target=target, account=account, thread_id=thread_id, dry_run=dry_run)
    if action == CALLBACK_COMMIT:
        return commit_pending(state, target=target, account=account, thread_id=thread_id, dry_run=dry_run)
    if action == CALLBACK_VIEW:
        mode = VIEW_ALL if value == VIEW_ALL else VIEW_NEEDED
        if mode == VIEW_NEEDED:
            view["session_ids"] = [item["id"] for item in sorted_items(state, STATUS_NEEDED)]
        if view.get("message_id"):
            return edit_existing_view(state, target=target, account=account, thread_id=thread_id, mode=mode, dry_run=dry_run)
        return send_telegram_view(state, target=target, account=account, mode=mode, thread_id=thread_id, dry_run=dry_run)
    raise RuntimeError("Unsupported callback action.")


def toggle_pending(state: dict[str, Any], item_id: str, target: str, account: str, thread_id: str | None, dry_run: bool) -> dict[str, Any]:
    """Immediately toggle item between needed and have, then update all active views."""
    item = state["items"].get(item_id)
    if not item:
        raise RuntimeError("Grocery item not found.")
    if item["status"] == STATUS_NEEDED:
        item["status"] = STATUS_HAVE
    else:
        item["status"] = STATUS_NEEDED
    item["updated_at"] = utc_now()
    update_all_views(state, account, dry_run)
    return {"ok": True, "item": {"id": item["id"], "name": item["name"], "status": item["status"]}}


def commit_pending(state: dict[str, Any], target: str, account: str, thread_id: str | None, dry_run: bool) -> dict[str, Any]:
    """Mark all pending items as bought and refresh the view. Kept for CLI use."""
    view = resolve_view(state, account, target, thread_id)
    pending_ids = set(view.get("pending_ids", []))
    for item_id in list(pending_ids):
        item = state["items"].get(item_id)
        if item:
            item["status"] = STATUS_HAVE
            item["updated_at"] = utc_now()
    view["pending_ids"] = []
    return edit_existing_view(state, target=target, account=account, thread_id=thread_id, mode=VIEW_NEEDED, dry_run=dry_run)


def print_json(data: dict[str, Any]) -> None:
    json.dump(data, sys.stdout, indent=2, sort_keys=True)
    sys.stdout.write("\n")


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Pantry-backed grocery checklist for OpenClaw and Telegram.")
    parser.add_argument("--state-file", default=None)
    sub = parser.add_subparsers(dest="command", required=True)

    for name, status in [("need", STATUS_NEEDED), ("out", STATUS_NEEDED), ("have", STATUS_HAVE), ("buy", STATUS_HAVE)]:
        cmd = sub.add_parser(name)
        cmd.add_argument("items", nargs="+")
        cmd.set_defaults(status=status)

    remove = sub.add_parser("remove")
    remove.add_argument("items", nargs="+")

    merge = sub.add_parser("merge")
    merge.add_argument("destination")
    merge.add_argument("sources", nargs="+")

    rename = sub.add_parser("rename")
    rename.add_argument("source")
    rename.add_argument("destination")

    show = sub.add_parser("show")
    show.add_argument("--mode", choices=[VIEW_NEEDED, VIEW_ALL], default=VIEW_NEEDED)
    show.add_argument("--json", action="store_true")

    ls = sub.add_parser("list")
    ls.add_argument("--status", choices=[STATUS_NEEDED, STATUS_HAVE, "all"], default="all")
    ls.add_argument("--json", action="store_true")

    stale_cmd = sub.add_parser("stale")
    stale_cmd.add_argument("--days", type=int, default=14)
    stale_cmd.add_argument("--json", action="store_true")

    render = sub.add_parser("render-telegram")
    render.add_argument("--target")
    render.add_argument("--account", default=DEFAULT_ACCOUNT)
    render.add_argument("--mode", choices=[VIEW_NEEDED, VIEW_ALL], default=VIEW_NEEDED)
    render.add_argument("--thread-id")
    render.add_argument("--dry-run", action="store_true")

    callback = sub.add_parser("handle-callback")
    callback.add_argument("callback")
    callback.add_argument("--target", required=True)
    callback.add_argument("--account", default=DEFAULT_ACCOUNT)
    callback.add_argument("--thread-id")
    callback.add_argument("--dry-run", action="store_true")

    toggle = sub.add_parser("toggle")
    toggle.add_argument("item_id")
    toggle.add_argument("--target", required=True)
    toggle.add_argument("--account", default=DEFAULT_ACCOUNT)
    toggle.add_argument("--thread-id")
    toggle.add_argument("--dry-run", action="store_true")

    return parser.parse_args()


def main() -> None:
    args = parse_args()
    path = Path(args.state_file).expanduser() if args.state_file else state_path()
    state = load_state(path)
    changed = False

    if args.command in {"need", "out", "have", "buy"}:
        items = update_status(state, args.items, args.status)
        changed = True
        save_state(path, state)
        print_json({
            "ok": True,
            "updated": [{"id": item["id"], "name": item["name"], "status": item["status"]} for item in items],
            "state_file": str(path),
        })
        return

    if args.command == "remove":
        removed = remove_items(state, args.items)
        changed = True
        save_state(path, state)
        print_json({"ok": True, "removed": removed, "state_file": str(path)})
        return

    if args.command == "merge":
        result = merge_items(state, args.destination, args.sources)
        changed = True
        save_state(path, state)
        print_json({
            "ok": True,
            "state_file": str(path),
            "item": {
                "id": result["item"]["id"],
                "name": result["item"]["name"],
                "status": result["item"]["status"],
            },
            "merged": result["merged"],
        })
        return

    if args.command == "rename":
        result = rename_item(state, args.source, args.destination)
        changed = True
        save_state(path, state)
        print_json({
            "ok": True,
            "state_file": str(path),
            "item": {
                "id": result["item"]["id"],
                "name": result["item"]["name"],
                "status": result["item"]["status"],
            },
            "renamed": args.source,
        })
        return

    if args.command == "show":
        payload = render_message(state, mode=args.mode)
        data = {"ok": True, "message": payload["message"], "buttons": payload["buttons"]}
        if args.json:
            print_json(data)
        else:
            print(strip_html(payload["message"]))
        return

    if args.command == "list":
        status = None if args.status == "all" else args.status
        items = sorted_items(state, status=status)
        data = {"ok": True, "items": items}
        if args.json:
            print_json(data)
        else:
            for item in items:
                print(f"{item['status']}: {item['name']}")
        return

    if args.command == "stale":
        threshold = datetime.now(timezone.utc) - timedelta(days=args.days)
        stale_items = [
            item for item in state["items"].values()
            if item.get("status") == STATUS_NEEDED
            and "updated_at" in item
            and datetime.fromisoformat(item["updated_at"].replace("Z", "+00:00")) < threshold
        ]
        stale_items.sort(key=lambda x: x.get("updated_at", ""))
        if args.json:
            print_json({"ok": True, "stale": stale_items, "days": args.days})
        else:
            if not stale_items:
                print(f"No items needed for more than {args.days} days.")
            else:
                print(f"Items needed for more than {args.days} days:")
                for item in stale_items:
                    print(f"  {item['name']} (since {item['updated_at'][:10]})")
        return

    if args.command == "render-telegram":
        if args.target:
            targets = [args.target]
        else:
            # No target specified: send to all users with an active view for this account,
            # falling back to the account's allowFrom list in openclaw.json.
            views = state.get("views", {})
            targets = [
                v["target"] for v in views.values()
                if isinstance(v, dict) and v.get("account") == args.account and v.get("target")
            ]
            if not targets:
                config = json.loads(openclaw_config_path().read_text(encoding="utf-8"))
                allow = (((config.get("channels") or {}).get("telegram") or {}).get("accounts") or {}).get(args.account, {}).get("allowFrom") or []
                targets = [str(t) for t in allow]
        results = []
        for target in targets:
            result = send_telegram_view(
                state,
                target=target,
                account=args.account,
                mode=args.mode,
                thread_id=args.thread_id,
                dry_run=args.dry_run,
            )
            results.append(result)
        if not args.dry_run:
            save_state(path, state)
        print_json(results[0] if len(results) == 1 else {"ok": True, "results": results})
        return

    if args.command == "handle-callback":
        result = handle_callback(
            state,
            callback=args.callback,
            target=args.target,
            account=args.account,
            thread_id=args.thread_id,
            dry_run=args.dry_run,
        )
        if not args.dry_run:
            save_state(path, state)
        print_json(result)
        return

    if args.command == "toggle":
        result = toggle_pending(
            state,
            item_id=args.item_id,
            target=args.target,
            account=args.account,
            thread_id=args.thread_id,
            dry_run=args.dry_run,
        )
        if not args.dry_run:
            save_state(path, state)
        print_json(result)
        return

    if changed:
        save_state(path, state)


if __name__ == "__main__":
    main()


path: scripts/prune_grocery_sessions.py
content: #!/usr/bin/env python3

from __future__ import annotations

import json
import shutil
import time
from pathlib import Path


SESSIONS_DIR = Path.home() / ".openclaw" / "agents" / "grocery" / "sessions"
SESSIONS_FILE = SESSIONS_DIR / "sessions.json"
MAX_INPUT_TOKENS = 20_000
MAX_CACHE_READ = 40_000
MAX_JSONL_LINES = 120


def load_sessions() -> dict:
    if not SESSIONS_FILE.exists():
        return {}
    try:
        return json.loads(SESSIONS_FILE.read_text(encoding="utf-8"))
    except json.JSONDecodeError:
        return {}


def session_line_count(session_file: Path) -> int:
    try:
        with session_file.open("r", encoding="utf-8") as handle:
            return sum(1 for _ in handle)
    except FileNotFoundError:
        return 0


def should_prune(meta: dict) -> bool:
    input_tokens = int(meta.get("inputTokens") or 0)
    cache_read = int(meta.get("cacheRead") or 0)
    session_path = Path(str(meta.get("sessionFile") or ""))
    line_count = session_line_count(session_path) if session_path else 0
    return (
        input_tokens >= MAX_INPUT_TOKENS
        or cache_read >= MAX_CACHE_READ
        or line_count >= MAX_JSONL_LINES
    )


def archive_session_files(entries: dict) -> None:
    timestamp = int(time.time())
    archive_dir = SESSIONS_DIR / f"archive-auto-{timestamp}"
    archive_dir.mkdir(parents=True, exist_ok=True)

    for meta in entries.values():
        session_path = Path(str(meta.get("sessionFile") or ""))
        if session_path.exists():
            shutil.move(str(session_path), str(archive_dir / session_path.name))

    for backup in SESSIONS_DIR.glob("*.bak.*"):
        shutil.move(str(backup), str(archive_dir / backup.name))


def main() -> int:
    sessions = load_sessions()
    if not sessions:
        return 0

    prune_needed = any(should_prune(meta) for meta in sessions.values())
    if not prune_needed:
        return 0

    archive_session_files(sessions)
    SESSIONS_FILE.write_text("{}\n", encoding="utf-8")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/grocery.sh
content: #!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec python3 "$SCRIPT_DIR/grocery.py" "$@"


path: AGENTS.md
content: # Grocery Checklist Agent Notes

- Keep OpenClaw as the source of truth.
- Treat Telegram as the checklist UI.
- Button callbacks start with `gchk:`.
- Use the local shell wrapper in `scripts/grocery.sh`.
- For clear grocery intents, execute directly and keep replies brief.
- For Telegram checklist rendering, prefer updating one message in place.


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)