"""Inline-button routing (callback_query). Data grammar (≤64 bytes): op:{arg}:{d8} / pk:{d8}:{field}:{idx} / qc:{idx} / ac:{op}[:{i}] / ar:{op} — full state lives in bot_drafts / pending flows. """ import logging from app import drafts as draft_store from app.runtime import LinkedUser from app.texts import CANCELLED, UNLINKED, esc from . import quote, supplier, material, project, decisions logger = logging.getLogger("bot.callbacks") async def handle_callback(ctx, cq: dict) -> None: chat_id = ((cq.get("message") or {}).get("chat") or {}).get("id") data = cq.get("data") or "" if chat_id is None: return try: await _dispatch(ctx, chat_id, data, cq) except Exception: logger.exception("callback failed: %s", data) await ctx.send(chat_id, "خطا در پردازش دکمه؛ دوباره تلاش کن.") finally: await ctx.bale.answer_callback_query(cq.get("id"), None) async def _gate(ctx, chat_id: int) -> LinkedUser | None: from app.defaults import AUTHORIZED_USERS if chat_id in AUTHORIZED_USERS: u = AUTHORIZED_USERS[chat_id] return LinkedUser( chat_id=chat_id, user_id=u.get("user_id"), display_name=u.get("display_name"), role=u.get("role", "procurement_manager"), is_admin=u.get("is_admin", True), ) await ctx.send(chat_id, "دسترسی شما هنوز تعریف نشده است. درخواست شما جهت بررسی ثبت شد.") return None async def _dispatch(ctx, chat_id: int, data: str, cq: dict) -> None: user = await _gate(ctx, chat_id) if user is None: return parts = data.split(":") # ---- pending-flow buttons (accept / arrive / quote pick) --------------- if parts[0] == "qc" and len(parts) == 2: flow = ctx.pending.get(chat_id) if flow and flow.kind == "quote_pick": await decisions.apply_quote_pick(ctx, chat_id, user, flow, int(parts[1])) return if parts[0] == "ac": await decisions.handle_accept_callback(ctx, chat_id, user, parts) return if parts[0] == "ar": await decisions.handle_arrive_callback(ctx, chat_id, user, parts) return # ---- ambiguity sub-messages (Requirement 2.a) ------------------------- if parts[0] == "amb" and len(parts) >= 4: action, prefix, amb_id = parts[1], parts[2], parts[3] draft = await draft_store.get_by_prefix(prefix, chat_id) if draft is None: await ctx.send(chat_id, "این پیش‌نویس دیگر معتبر نیست.") return payload = draft.payload or {} ambiguities = payload.get("ambiguities") or [] target_amb = next((a for a in ambiguities if a.get("id") == amb_id), None) if not target_amb: return if action == "ok": proposed = target_amb.get("proposed_patch") or {} draft_store.merge_quote_patch(payload, proposed) target_amb["status"] = "applied" await quote.resolve_payload(ctx, payload) await draft_store.save_payload(draft, mark_bale_synced=True) # In-place edit main review card await quote.render(ctx, chat_id, draft, fresh=False) # Delete ONLY this ambiguity message if target_amb.get("message_id"): try: await ctx.delete(chat_id, target_amb["message_id"]) except Exception: pass elif action == "no": target_amb["status"] = "rejected" await draft_store.save_payload(draft, mark_bale_synced=True) # Delete ONLY this ambiguity message if target_amb.get("message_id"): try: await ctx.delete(chat_id, target_amb["message_id"]) except Exception: pass return # ---- pick a candidate for a draft field ------------------------------- if parts[0] == "pk" and len(parts) == 4: draft = await draft_store.get_by_prefix(parts[1], chat_id) if draft is None: await ctx.send(chat_id, "این پیش‌نویس دیگر معتبر نیست.") return await _apply_pick(ctx, chat_id, user, draft, parts[2], int(parts[3])) return # ---- draft commit / cancel -------------------------------------------- op, arg, prefix = parts[0], parts[1], parts[2] if len(parts) > 2 else "" if op in ("q", "s", "m", "p") and arg in ("ok", "ok2", "no"): kind = { "q": draft_store.KIND_QUOTE, "s": draft_store.KIND_SUPPLIER, "m": draft_store.KIND_MATERIAL, "p": draft_store.KIND_PROJECT, }[op] draft = await draft_store.get_by_prefix(prefix, chat_id) if draft is None or draft.kind != kind: await ctx.send(chat_id, "این پیش‌نویس دیگر معتبر نیست.") return if arg == "no": await draft_store.set_status(draft.id, "cancelled") ctx.pending.pop(chat_id, None) if draft.review_message_id: try: await ctx.bale.edit_message_text(chat_id, draft.review_message_id, "❌ این پیش‌نویس لغو شد.", reply_markup=None) await draft_store.set_bale_updated_at(draft.id) except Exception: pass await ctx.send(chat_id, CANCELLED) return if kind == draft_store.KIND_QUOTE: await quote.commit(ctx, chat_id, user, draft, only_resolved=(arg == "ok2")) elif kind == draft_store.KIND_SUPPLIER: await supplier.commit(ctx, chat_id, user, draft) elif kind == draft_store.KIND_MATERIAL: await material.commit(ctx, chat_id, user, draft) elif kind == draft_store.KIND_PROJECT: await project.commit(ctx, chat_id, user, draft) async def _apply_pick(ctx, chat_id: int, user: LinkedUser, draft, field: str, idx: int) -> None: options = (draft.payload.get("options") or {}).get(field) or [] if idx < 0 or idx >= len(options): await ctx.send(chat_id, "گزینه نامعتبر است.") return choice = options[idx] if draft.kind == draft_store.KIND_QUOTE: await quote.apply_pick(ctx, chat_id, user, draft, field, choice) elif draft.kind == draft_store.KIND_SUPPLIER: await supplier.apply_pick(ctx, chat_id, user, draft, field, choice) elif draft.kind == draft_store.KIND_MATERIAL: await material.apply_pick(ctx, chat_id, user, draft, field, choice)