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:
@@ -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
|
||||
Reference in New Issue
Block a user