refactor(bot)!: rename 'ai procurement' -> 'bot/' so the compose build context is committed

The bot service in docker-compose.yml builds from ./bot, but the agent code lived in 'ai procurement/' and ./bot was only a Windows junction on reza.g's machine (and was gitignored) — exactly as the team reported: 'bot/ was never committed — prod-v7's bot is un-buildable from the repo.'

- junction removed; 'ai procurement/' renamed to 'bot/' via git mv (all 41 files keep their history as pure renames)
- 'bot/' dropped from .gitignore (voice/ stays ignored — runtime voice notes; bot/.env stays ignored by bot/.gitignore)
- compose needs no change: context: ./bot and ./bot/app now resolve to committed files
- bot/INTEGRATION-REPORT.md added: full story (problem, root cause, git state, security note on the committed BALE_BOT_TOKEN / AI_API_KEY compose defaults, and usage instructions for the team)

Verified: 41 renames staged with zero secret/cache files; voice/ and bot/.env still ignored.
This commit is contained in:
2026-10-02 12:52:25 +03:30
parent 07643dbdf5
commit 638076dbaa
43 changed files with 150 additions and 1 deletions
+170
View File
@@ -0,0 +1,170 @@
"""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