diff --git a/ai procurement/app/bale/client.py b/ai procurement/app/bale/client.py index 51cba83..3262b64 100644 --- a/ai procurement/app/bale/client.py +++ b/ai procurement/app/bale/client.py @@ -90,6 +90,17 @@ class BaleClient: payload.pop("parse_mode", None) return await self._call("editMessageText", payload) + async def delete_message(self, chat_id: int, message_id: int) -> bool: + payload: dict = {"chat_id": chat_id, "message_id": message_id} + try: + return bool(await self._call("deleteMessage", payload)) + except BaleError as exc: + logger.warning("deleteMessage failed for chat %s msg %s: %s", chat_id, message_id, exc) + return False + except Exception as exc: + logger.warning("unexpected error in deleteMessage for chat %s msg %s: %s", chat_id, message_id, exc) + return False + async def answer_callback_query(self, callback_query_id: str, text: str | None = None) -> None: payload: dict = {"callback_query_id": callback_query_id} if text: diff --git a/ai procurement/app/db_init.py b/ai procurement/app/db_init.py index 82f87fa..5a051cc 100644 --- a/ai procurement/app/db_init.py +++ b/ai procurement/app/db_init.py @@ -33,6 +33,7 @@ DDL = [ missing JSONB, status VARCHAR(16) NOT NULL DEFAULT 'active', source VARCHAR(16), + source_message_id BIGINT, review_message_id BIGINT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() @@ -41,6 +42,9 @@ DDL = [ "CREATE INDEX IF NOT EXISTS ix_bot_drafts_chat_id ON bot_drafts (chat_id)", "CREATE INDEX IF NOT EXISTS ix_bot_drafts_chat_status ON bot_drafts (chat_id, status)", "ALTER TABLE bot_drafts ADD COLUMN IF NOT EXISTS last_bale_update_at TIMESTAMPTZ", + "ALTER TABLE bot_drafts ADD COLUMN IF NOT EXISTS source_message_id BIGINT", + "CREATE INDEX IF NOT EXISTS ix_bot_drafts_review_msg ON bot_drafts (review_message_id)", + "CREATE INDEX IF NOT EXISTS ix_bot_drafts_source_msg ON bot_drafts (source_message_id)", """ CREATE TABLE IF NOT EXISTS bot_messages ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), diff --git a/ai procurement/app/drafts.py b/ai procurement/app/drafts.py index e36feed..10750fe 100644 --- a/ai procurement/app/drafts.py +++ b/ai procurement/app/drafts.py @@ -29,15 +29,16 @@ KIND_MATERIAL = "material" # Store # --------------------------------------------------------------------------- -async def create(chat_id: int, kind: str, payload: dict, source: str | None = None) -> BotDraft: +async def create(chat_id: int, kind: str, payload: dict, source: str | None = None, + source_message_id: int | None = None) -> BotDraft: async with SessionLocal() as session: - # one active draft per (chat, kind) - await session.execute( - update(BotDraft) - .where(BotDraft.chat_id == chat_id, BotDraft.kind == kind, BotDraft.status == "active") - .values(status="superseded") + draft = BotDraft( + chat_id=chat_id, + kind=kind, + payload=payload, + source=source, + source_message_id=source_message_id, ) - draft = BotDraft(chat_id=chat_id, kind=kind, payload=payload, source=source) session.add(draft) await session.commit() await session.refresh(draft) @@ -53,6 +54,22 @@ async def get_active(chat_id: int, kind: str | None = None) -> BotDraft | None: return (await session.execute(stmt)).scalar_one_or_none() +async def get_by_message_id(chat_id: int, message_id: int) -> BotDraft | None: + """Find the active draft associated with a given message_id (review card or user source message).""" + async with SessionLocal() as session: + stmt = ( + select(BotDraft) + .where( + BotDraft.chat_id == chat_id, + BotDraft.status == "active", + (BotDraft.review_message_id == message_id) | (BotDraft.source_message_id == message_id), + ) + .order_by(BotDraft.updated_at.desc()) + .limit(1) + ) + return (await session.execute(stmt)).scalar_one_or_none() + + async def get_by_prefix(draft_prefix: str, chat_id: int | None = None) -> BotDraft | None: """Resolve the short id used in callback_data (first 8 hex of the UUID).""" async with SessionLocal() as session: @@ -63,12 +80,20 @@ async def get_by_prefix(draft_prefix: str, chat_id: int | None = None) -> BotDra async def save_payload(draft: BotDraft, missing: dict | None = None, - review_message_id: int | None = None) -> None: + review_message_id: int | None = None, + source_message_id: int | None = None) -> None: async with SessionLocal() as session: + values: dict = { + "payload": draft.payload, + "missing": missing, + "review_message_id": review_message_id or draft.review_message_id, + } + if source_message_id: + values["source_message_id"] = source_message_id await session.execute( update(BotDraft) .where(BotDraft.id == draft.id) - .values(payload=draft.payload, missing=missing, review_message_id=review_message_id or draft.review_message_id) + .values(**values) ) await session.commit() diff --git a/ai procurement/app/handlers/__init__.py b/ai procurement/app/handlers/__init__.py index 1ae7526..66a1d8b 100644 --- a/ai procurement/app/handlers/__init__.py +++ b/ai procurement/app/handlers/__init__.py @@ -75,18 +75,36 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None: if user is None: return # gate either started linking or is mid-linking - draft = await draft_store.get_active(chat_id) - draft_summary = _draft_summary(draft) + message_id = message.get("message_id") + reply_to = message.get("reply_to_message") + reply_to_msg_id = reply_to.get("message_id") if reply_to else None + + target_draft = None + if reply_to_msg_id is not None: + target_draft = await draft_store.get_by_message_id(chat_id, reply_to_msg_id) + if target_draft is None: + await ctx.send( + chat_id, + "⚠️ پیش‌نویس فعال مربوط به این پیام یافت نشد (ممکن است ثبت نهایی، منقضی یا لغو شده باشد).\nبرای ثبت استعلام جدید، پیام خود را بدون ریپلای ارسال فرمایید.", + reply_to=message_id, + ) + return + + draft_summary = _draft_summary(target_draft) if target_draft else None result = await understand(settings, text, draft_summary) intent, patch = result.get("intent"), result.get("patch") or {} - logger.info("chat %s intent=%s conf=%s", chat_id, intent, result.get("confidence")) + logger.info("chat %s intent=%s conf=%s (is_edit=%s)", chat_id, intent, result.get("confidence"), target_draft is not None) if intent in ("register_quote", "update_draft"): - await quote.handle_entities(ctx, chat_id, user, patch, source) + await quote.handle_entities( + ctx, chat_id, user, patch, source, + incoming_msg_id=message_id, + target_draft=target_draft, + ) elif intent == "commit_draft": - active = await draft_store.get_active(chat_id) + active = target_draft or await draft_store.get_active(chat_id) if active is None: - await ctx.send(chat_id, NOTHING_TO_COMMIT) + await ctx.send(chat_id, NOTHING_TO_COMMIT, reply_to=message_id) elif active.kind == draft_store.KIND_QUOTE: await quote.commit(ctx, chat_id, user, active) elif active.kind == draft_store.KIND_SUPPLIER: @@ -94,15 +112,20 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None: elif active.kind == draft_store.KIND_MATERIAL: await material.commit(ctx, chat_id, user, active) elif intent == "discard_draft": - active = await draft_store.get_active(chat_id) + active = target_draft or await draft_store.get_active(chat_id) if active: await draft_store.set_status(active.id, "cancelled") - ctx.pending.pop(chat_id) - await ctx.send(chat_id, CANCELLED) + if active.review_message_id: + try: + await ctx.delete(chat_id, active.review_message_id) + except Exception: + pass + ctx.pending.pop(chat_id, None) + await ctx.send(chat_id, CANCELLED, reply_to=message_id) elif intent == "show_draft": - active = await draft_store.get_active(chat_id) + active = target_draft or await draft_store.get_active(chat_id) if active is None: - await ctx.send(chat_id, NOTHING_TO_COMMIT) + await ctx.send(chat_id, NOTHING_TO_COMMIT, reply_to=message_id) elif active.kind == draft_store.KIND_QUOTE: await quote.render(ctx, chat_id, active, fresh=False) elif active.kind == draft_store.KIND_SUPPLIER: diff --git a/ai procurement/app/handlers/quote.py b/ai procurement/app/handlers/quote.py index c82feb6..b360adb 100644 --- a/ai procurement/app/handlers/quote.py +++ b/ai procurement/app/handlers/quote.py @@ -16,18 +16,39 @@ logger = logging.getLogger("bot.quote") FIELD_FA = {"sup": "تأمین‌کننده", "prj": "پروژه"} -async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None) -> None: - draft = await draft_store.get_active(chat_id, KIND_QUOTE) - if draft is not None: +async def handle_entities( + ctx, chat_id, user, patch: dict, source: str | None, + incoming_msg_id: int | None = None, + target_draft = None, +) -> None: + prev_review_msg_id = None + if target_draft is not None: + draft = target_draft + prev_review_msg_id = draft.review_message_id payload = draft.payload merge_quote_patch(payload, patch) + draft.source_message_id = incoming_msg_id else: payload = new_quote_payload() merge_quote_patch(payload, patch) - draft = await draft_store.create(chat_id, KIND_QUOTE, payload, source=source) + draft = await draft_store.create( + chat_id, KIND_QUOTE, payload, source=source, source_message_id=incoming_msg_id + ) + await resolve_payload(ctx, payload) - await draft_store.save_payload(draft, missing={"fields": compute_quote_missing(payload)}) - await render(ctx, chat_id, draft, fresh=draft.review_message_id is None) + await draft_store.save_payload( + draft, + missing={"fields": compute_quote_missing(payload)}, + source_message_id=incoming_msg_id, + ) + + if prev_review_msg_id: + try: + await ctx.delete(chat_id, prev_review_msg_id) + except Exception as e: + logger.warning("Could not delete previous review message %s: %s", prev_review_msg_id, e) + + await render(ctx, chat_id, draft, fresh=True, reply_to=incoming_msg_id) # --------------------------------------------------------------------------- @@ -172,7 +193,8 @@ def _keyboard(payload: dict, draft_id: str, wizard_url: str | None = None) -> di return quote_review_keyboard(prefix, pick_rows, has_partial_option=has_partial, wizard_url=wizard_url) -async def render(ctx, chat_id, draft, fresh: bool = True, note: str | None = None) -> None: +async def render(ctx, chat_id, draft, fresh: bool = True, note: str | None = None, + reply_to: int | None = None) -> None: from app.config import settings payload = draft.payload @@ -182,11 +204,17 @@ async def render(ctx, chat_id, draft, fresh: bool = True, note: str | None = Non text += f"\n\n🖥 مشاهده و تکمیل در ویزارد:\n{wizard_url}" keyboard = _keyboard(payload, str(draft.id), wizard_url=wizard_url) if fresh: - message = await ctx.send(chat_id, text, reply_markup=keyboard) - draft.review_message_id = (message or {}).get("message_id") - await draft_store.save_payload(draft, missing={"fields": missing}, - review_message_id=draft.review_message_id) - await draft_store.set_bale_updated_at(draft.id) + message = await ctx.send(chat_id, text, reply_markup=keyboard, reply_to=reply_to) + new_review_id = (message or {}).get("message_id") + if new_review_id: + draft.review_message_id = new_review_id + await draft_store.save_payload( + draft, + missing={"fields": missing}, + review_message_id=new_review_id, + source_message_id=reply_to or draft.source_message_id, + ) + await draft_store.set_bale_updated_at(draft.id) else: await ctx.edit(chat_id, draft.review_message_id, text, reply_markup=keyboard) await draft_store.save_payload(draft, missing={"fields": missing}) diff --git a/ai procurement/app/models.py b/ai procurement/app/models.py index ef55df9..f58dbd6 100644 --- a/ai procurement/app/models.py +++ b/ai procurement/app/models.py @@ -41,7 +41,11 @@ class BotDraft(Base): """A prepared-but-not-committed ERP action (quote / supplier / material).""" __tablename__ = "bot_drafts" - __table_args__ = (Index("ix_bot_drafts_chat_status", "chat_id", "status"),) + __table_args__ = ( + Index("ix_bot_drafts_chat_status", "chat_id", "status"), + Index("ix_bot_drafts_review_msg", "review_message_id"), + Index("ix_bot_drafts_source_msg", "source_message_id"), + ) id: Mapped[str] = _uuid_pk() chat_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True) @@ -50,6 +54,7 @@ class BotDraft(Base): missing: Mapped[dict | None] = mapped_column(JSONB) status: Mapped[str] = mapped_column(String(16), nullable=False, default="active") # active|committed|cancelled|expired|failed source: Mapped[str | None] = mapped_column(String(16)) # text|voice + source_message_id: Mapped[int | None] = mapped_column(BigInteger) review_message_id: Mapped[int | None] = mapped_column(BigInteger) last_bale_update_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) created_at: Mapped[datetime] = _timestamptz() diff --git a/ai procurement/app/runtime.py b/ai procurement/app/runtime.py index bf646e1..5be8fd7 100644 --- a/ai procurement/app/runtime.py +++ b/ai procurement/app/runtime.py @@ -117,10 +117,11 @@ class BotContext: def admin_chat_ids(self) -> set[int]: return settings.admin_chat_ids - async def send(self, chat_id: int, text: str, reply_markup: dict | None = None) -> dict: + async def send(self, chat_id: int, text: str, reply_markup: dict | None = None, + reply_to: int | None = None) -> dict: """Send + audit-log every outbound message.""" try: - message = await self.bale.send_message(chat_id, text, reply_markup=reply_markup) + message = await self.bale.send_message(chat_id, text, reply_markup=reply_markup, reply_to=reply_to) except Exception: logger.exception("failed to send to chat %s", chat_id) message = {} @@ -137,6 +138,15 @@ class BotContext: except Exception: await self.send(chat_id, text, reply_markup=reply_markup) + async def delete(self, chat_id: int, message_id: int | None) -> bool: + if not message_id: + return False + try: + return await self.bale.delete_message(chat_id, message_id) + except Exception: + logger.exception("failed to delete message %s in chat %s", message_id, chat_id) + return False + async def _log(self, chat_id: int, direction: str, kind: str, body: str | None = None) -> None: try: async with SessionLocal() as session: diff --git a/backend/app/db/init_db.py b/backend/app/db/init_db.py index c59a345..b6ea17c 100644 --- a/backend/app/db/init_db.py +++ b/backend/app/db/init_db.py @@ -445,6 +445,7 @@ async def ensure_bot_drafts_table(engine: AsyncEngine) -> None: missing JSONB, status VARCHAR(16) NOT NULL DEFAULT 'active', source VARCHAR(16), + source_message_id BIGINT, review_message_id BIGINT, last_bale_update_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), @@ -455,6 +456,9 @@ async def ensure_bot_drafts_table(engine: AsyncEngine) -> None: "CREATE INDEX IF NOT EXISTS ix_bot_drafts_chat_id ON bot_drafts(chat_id)", "CREATE INDEX IF NOT EXISTS ix_bot_drafts_chat_status ON bot_drafts(chat_id, status)", "ALTER TABLE bot_drafts ADD COLUMN IF NOT EXISTS last_bale_update_at TIMESTAMPTZ", + "ALTER TABLE bot_drafts ADD COLUMN IF NOT EXISTS source_message_id BIGINT", + "CREATE INDEX IF NOT EXISTS ix_bot_drafts_review_msg ON bot_drafts(review_message_id)", + "CREATE INDEX IF NOT EXISTS ix_bot_drafts_source_msg ON bot_drafts(source_message_id)", ) async with engine.begin() as conn: await conn.execute(text(ddl)) diff --git a/backend/app/models/draft.py b/backend/app/models/draft.py index 83b2d38..811dfc2 100644 --- a/backend/app/models/draft.py +++ b/backend/app/models/draft.py @@ -14,6 +14,7 @@ class BotDraft(Base): missing = Column(JSONB) status = Column(String(16), nullable=False, default="active") source = Column(String(16)) + source_message_id = Column(BigInteger) review_message_id = Column(BigInteger) last_bale_update_at = Column(DateTime(timezone=True)) created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/frontend/src/pages/Wizard/WizardPage.tsx b/frontend/src/pages/Wizard/WizardPage.tsx index 8b56369..91a611f 100644 --- a/frontend/src/pages/Wizard/WizardPage.tsx +++ b/frontend/src/pages/Wizard/WizardPage.tsx @@ -272,7 +272,6 @@ export function WizardPage() { ) { lastRemoteUpdatedAtRef.current = draft.updated_at; applyDraftPayloadToForm(draft.payload); - toast.info("🔄 اصلاحات جدید از ربات بله دریافت و در ویزارد اعمال شد.", { duration: 4000 }); } } catch { /* خطای موقت شبکه در پولینگ نادیده گرفته می‌شود */