"""Shared runtime: BotContext wiring + per-chat pending interaction flows. Pending flows (Casdoor linking, quote picking, accept/arrive confirmations) are short-lived and single-instance, so they live in memory; anything that must survive restarts (drafts, links) lives in postgres. """ import logging import time from dataclasses import dataclass, field from sqlalchemy import select, update from app.api.client import ErpClient from app.bale.client import BaleClient from app.config import settings from app.db import SessionLocal from app.models import BotLink, BotMessage from app.resolve import Resolver logger = logging.getLogger("bot.runtime") # --------------------------------------------------------------------------- # Links (postgres) # --------------------------------------------------------------------------- class LinkStore: async def get(self, chat_id: int) -> BotLink | None: async with SessionLocal() as session: return (await session.execute( select(BotLink).where(BotLink.chat_id == chat_id) )).scalar_one_or_none() async def upsert(self, chat_id: int, user_id: str | None, username: str | None, display_name: str | None, role: str | None) -> BotLink: async with SessionLocal() as session: link = (await session.execute(select(BotLink).where(BotLink.chat_id == chat_id))).scalar_one_or_none() if link is None: link = BotLink(chat_id=chat_id) session.add(link) link.user_id = user_id link.casdoor_username = username link.display_name = display_name link.role = role await session.commit() await session.refresh(link) return link async def delete(self, chat_id: int) -> bool: async with SessionLocal() as session: result = await session.execute( update(BotLink).where(BotLink.chat_id == chat_id).values(user_id=None, role=None, casdoor_username=None) ) await session.commit() return (result.rowcount or 0) > 0 ROLE_FA = {"pm": "مدیر پروژه", "procurement_manager": "مدیر تدارکات", "admin": "ادمین"} # --------------------------------------------------------------------------- # Pending flows (memory) — one per chat # --------------------------------------------------------------------------- @dataclass class PendingFlow: kind: str # link_username | link_password | quote_pick | accept | arrive_qty | arrive_confirm data: dict = field(default_factory=dict) created: float = field(default_factory=time.monotonic) @property def age_seconds(self) -> float: return time.monotonic() - self.created class PendingFlows: TTL_SECONDS = 1800 def __init__(self): self._flows: dict[int, PendingFlow] = {} def set(self, chat_id: int, kind: str, data: dict | None = None) -> PendingFlow: flow = PendingFlow(kind=kind, data=data or {}) self._flows[chat_id] = flow return flow def get(self, chat_id: int) -> PendingFlow | None: flow = self._flows.get(chat_id) if flow and flow.age_seconds > self.TTL_SECONDS: self._flows.pop(chat_id, None) return None return flow def pop(self, chat_id: int, default=None): return self._flows.pop(chat_id, default) def sweep(self) -> None: for chat_id, flow in list(self._flows.items()): if flow.age_seconds > self.TTL_SECONDS: self._flows.pop(chat_id, None) # --------------------------------------------------------------------------- # Context # --------------------------------------------------------------------------- class BotContext: def __init__(self, bale: BaleClient, api: ErpClient): self.bale = bale self.api = api self.resolver = Resolver(api) self.links = LinkStore() self.pending = PendingFlows() @property 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, 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, reply_to=reply_to) except Exception: logger.exception("failed to send to chat %s", chat_id) message = {} await self._log(chat_id, "out", "text", text) return message async def edit(self, chat_id: int, message_id: int | None, text: str, reply_markup: dict | None = None) -> bool: if not message_id: return False try: await self.bale.edit_message_text(chat_id, message_id, text, reply_markup=reply_markup) return True except Exception as exc: logger.warning("failed to edit message %s in chat %s: %s", message_id, chat_id, exc) return False 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: session.add(BotMessage(chat_id=chat_id, direction=direction, kind=kind, body=body)) await session.commit() except Exception: logger.exception("failed to log bot message") @dataclass class LinkedUser: chat_id: int user_id: str | None display_name: str | None role: str | None is_admin: bool @property def actor_id(self) -> str | None: return self.user_id