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,28 @@
|
||||
# Bale Bot API — token from Bale's BotFather (required)
|
||||
PROCUREMENT_BOT_BOT_TOKEN=
|
||||
PROCUREMENT_BOT_BALE_API_BASE=https://tapi.bale.ai
|
||||
|
||||
# ERP procurement API (in Docker: http://api:8000/api/v1)
|
||||
PROCUREMENT_BOT_API_BASE_URL=http://localhost:8000/api/v1
|
||||
PROCUREMENT_BOT_DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/procurement
|
||||
|
||||
# LLM provider — any OpenAI-compatible endpoint (JSON-mode chat completions)
|
||||
PROCUREMENT_BOT_AI_BASE_URL=https://api.deepseek.com
|
||||
PROCUREMENT_BOT_AI_API_KEY=
|
||||
PROCUREMENT_BOT_LLM_MODEL=deepseek-flash
|
||||
|
||||
# STT provider — must expose POST /audio/transcriptions.
|
||||
# Falls back to AI_* when unset. DeepSeek has no audio API, so voice needs
|
||||
# its own whisper-compatible provider (OpenAI, GLM, self-hosted faster-whisper…).
|
||||
PROCUREMENT_BOT_STT_BASE_URL=
|
||||
PROCUREMENT_BOT_STT_API_KEY=
|
||||
PROCUREMENT_BOT_STT_MODEL=whisper-1
|
||||
PROCUREMENT_BOT_STT_LANGUAGE=fa
|
||||
|
||||
# Bale chat ids allowed without Casdoor linking (comma separated)
|
||||
PROCUREMENT_BOT_ADMIN_CHAT_IDS=
|
||||
# Optional bearer for the day the ERP starts enforcing auth on procurement routes
|
||||
PROCUREMENT_BOT_SERVICE_TOKEN=
|
||||
# Drafts older than this are swept into `expired`
|
||||
PROCUREMENT_BOT_DRAFT_TTL_HOURS=24
|
||||
PROCUREMENT_BOT_LOG_LEVEL=INFO
|
||||
@@ -0,0 +1,14 @@
|
||||
# ---- Env / secrets ----
|
||||
.env
|
||||
*.local
|
||||
|
||||
# ---- Python ----
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# ---- OS junk ----
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,23 @@
|
||||
# Bale procurement bot — a long-polling worker against tapi.bale.ai.
|
||||
# ffmpeg converts Bale voice notes (OGG/Opus) before they are sent to STT.
|
||||
FROM python:3.13-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /srv/bot
|
||||
|
||||
# Dependencies first (layer caching). PIP_INDEX_URL overridable like the api image.
|
||||
ARG PIP_INDEX_URL=https://pypi.org/simple
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -i ${PIP_INDEX_URL} -r requirements.txt
|
||||
|
||||
# Application
|
||||
COPY app ./app
|
||||
COPY tools ./tools
|
||||
|
||||
CMD ["python", "-m", "app.main"]
|
||||
@@ -0,0 +1,150 @@
|
||||
# گزارش ادغام ایجنت هوش مصنوعی با ریپوی ERP
|
||||
|
||||
> این سند وضعیت نهایی یکپارچهسازی بات تلگرام/بله (procurement AI agent) با پروژهی ERP را
|
||||
> توضیح میدهد: مشکلی که پیش آمده بود، چه چیزی اصلاح شد، وضعیت فعلی گیت و سرور،
|
||||
> نکات امنیتی و نحوهی استفاده. تاریخ آخرین بهروزرسانی: برنچ `prod-v7`.
|
||||
|
||||
---
|
||||
|
||||
## ۱. مشکل چی بود؟
|
||||
|
||||
سرویس `bot` در `docker-compose.yml` اینطور تعریف شده بود:
|
||||
|
||||
```yaml
|
||||
bot:
|
||||
build:
|
||||
context: ./bot # ← پوشهای که در گیت وجود نداشت!
|
||||
volumes:
|
||||
- ./bot/app:/srv/bot/app
|
||||
```
|
||||
|
||||
اما:
|
||||
|
||||
1. **پوشهی `bot/` هیچوقت کامیت نشده بود** — نه روی `prod-v7` و نه روی هیچ برنچ دیگری.
|
||||
2. کد ایجنت در پوشهای به نام `ai procurement/` (با فاصله در نام) زندگی میکرد که روی `prod-v7` کامیت شده بود، ولی compose به آن اشاره نمیکرد.
|
||||
3. روی دستگاه reza.g یک **Windows Junction** (میانبر سطح NTFS) به نام `bot` وجود داشت که به `ai procurement` اشاره میکرد — به همین دلیل `docker compose build bot` فقط روی ماشین او جواب میداد.
|
||||
4. خود برنچ `prod-v7` هم `bot/` را در `.gitignore` (خط ۳۳) داشت.
|
||||
|
||||
**نتیجه:** روی هر کلون تمیزی از مخزن، `docker compose build bot` شکست میخورد —
|
||||
همان چیزی که تیم گزارش کرد: «bot/ was never committed — prod-v7's bot is un-buildable from the repo.»
|
||||
|
||||
## ۲. راهحل اعمالشده (راهحل شماره ۲)
|
||||
|
||||
1. Junction ویندوزی `bot` حذف شد (فقط میانبر؛ فایلی از دست نرفت).
|
||||
2. پوشهی `ai procurement/` به **`bot/`** تغییر نام یافت — با `git mv`، یعنی
|
||||
**تاریخچهی گیت هر ۴۱ فایل بهصورت rename خالص حفظ شده** (`R` در گیت، نه حذف/افزودن).
|
||||
3. خط `bot/` از `.gitignore` ریشه حذف شد تا پوشهی بات دیگر ignore نشود.
|
||||
(`voice/` سر جایش ماند — نتهای صوتی رuntimes هستند و نباید کامیت شوند.)
|
||||
4. اکنون `docker-compose.yml` **بدون هیچ تغییری** درست کار میکند:
|
||||
`context: ./bot` و `./bot/app` دقیقاً به فایلهای کامیتشده اشاره میکنند.
|
||||
5. همهچیز روی برنچ **`prod-v7`** کامیت و پوش شده تا تیم به فایلها دسترسی داشته باشد.
|
||||
|
||||
### قواعد ignore فعلی (عمدی)
|
||||
|
||||
| مسیر | وضعیت | دلیل |
|
||||
|---|---|---|
|
||||
| `bot/.env` | ignore (توسط `bot/.gitignore`) | کلیدها و توکنها — هرگز کامیت نمیشوند |
|
||||
| `bot/__pycache__/` و `*.pyc` | ignore | خروجی runtime پایتون |
|
||||
| `voice/` | ignore (ریشه) | فایلهای صوتی دریافتی از بله در زمان اجرا |
|
||||
| `procurement_ai_agent.bundle` و `git_history_backup.zip` | **کامیتشده** | بکاپ تاریخچهی اصلی ایجنت (~۲۰۰KB) |
|
||||
|
||||
## ۳. وضعیت فعلی گیت (تاریخ این سند)
|
||||
|
||||
- برنچ فعال: **`prod-v7`** (محلی و `origin/prod-v7` همSHA).
|
||||
- درخت `prod-v7` = کل اپلیکیشن ERP (backend/frontend/…) **بهعلاوهی** `bot/`
|
||||
(کد ایجنت + Dockerfile + docs + integration) + `backend/app/routers/drafts.py`
|
||||
(اندپوینتهای همگامسازی draft بات ↔ ویزارد) + اسکیمای `bot_drafts`.
|
||||
- تاریخچه: از production (d69ec7f) یازده کامیت جلوتر — از «feat(drafts): bot_drafts schema»
|
||||
تا «feat(bot,wizard): smart message filtering…».
|
||||
- بقیهی برنچهای تیم (`prod-v8`، `prod-v9`، `prod_v8`، `production`) **فایلهای بات را ندارند** —
|
||||
ادغام این برنچ با کار جدید تیم، قدم بعدی جداگانه است (با تأیید تیم).
|
||||
|
||||
## ۴. ⚠️ نکتهی امنیتی (مهم — اقدام لازم)
|
||||
|
||||
در همان `docker-compose.yml`، مقادیر **واقعی** بهعنوان پیشفرض env کامیت و پوش شدهاند:
|
||||
|
||||
```yaml
|
||||
PROCUREMENT_BOT_BOT_TOKEN: ${BALE_BOT_TOKEN:-1354444845:yhVMnKZBFuKaU3UW5fP1bHmknEHx99mipWw}
|
||||
PROCUREMENT_BOT_AI_API_KEY: ${AI_API_KEY:-sk-a671eab15be846d494bf0839070a3b32}
|
||||
```
|
||||
|
||||
یعنی توکن بات بله و کلید DeepSeek الان در تاریخچهی مخزن مشترک است و **هر کسی که به ریپو
|
||||
دسترسی دارد میتواند آنها را ببیند** (`.env` کامیت نشده، ولی این پیشفرضها عملاً همان لو رفتن راز است).
|
||||
|
||||
اقدامات توصیهشده (هنوز انجام نشده — نیاز به تصمیم مالک توکنها):
|
||||
|
||||
1. **توکن بات بله را از @BotFather بله باطل و دوباره بسازید** و کلید DeepSeek را rotate کنید.
|
||||
2. مقادیر پیشفرض را از compose حذف کنید و بهجای `${VAR:-secret}` از `${VAR:?set VAR}` استفاده کنید
|
||||
(مثل `TRAEFIK_ACME_EMAIL` در compose پروडاکشن) و مقادیر واقعی فقط در `.env` محلی/سرور بمانند.
|
||||
3. فایل `bot/.env` را از روی `bot/.env.example` بسازید (نمونه در همان پوشه هست).
|
||||
|
||||
## ۵. نحوهی استفاده
|
||||
|
||||
### ۵.۱ اجرای کامل استک (شامل بات) — روی هر ماشین
|
||||
|
||||
```powershell
|
||||
git clone https://git.bennu-api-bahrestan.ir/ali_mohaghegh/ERP.git
|
||||
cd ERP
|
||||
git checkout prod-v7
|
||||
|
||||
# ۱) متغیرهای محیطی بات را بسازید (بدون این، بات توکن ندارد):
|
||||
copy bot\.env.example bot\.env # و مقادیر واقعی را داخلش بگذارید
|
||||
# BALE_BOT_TOKEN=... توکن بات از بله
|
||||
# AI_API_KEY=... کلید سرویس LLM
|
||||
# BALE_ADMIN_CHAT_IDS=... چتآیدی مدیران
|
||||
|
||||
# ۲) کل استک + بات:
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
سرویس `bot` (کانتینر `procurement-bot`) بهصورت long-polling به `tapi.bale.ai` وصل میشود،
|
||||
به `api:8000/api/v1` (همان ERP) و پایگاهدادهی `procurement` وصل است و
|
||||
draft های گفتگو را در جدول `bot_drafts` مینویسد.
|
||||
|
||||
### ۵.۲ اجرای فقط بات (وقتی استک از قبل بالا است)
|
||||
|
||||
```powershell
|
||||
docker compose build bot
|
||||
docker compose up -d bot
|
||||
docker compose logs -f bot
|
||||
```
|
||||
|
||||
### ۵.۳ جریان کاری کاربر نهایی (بات ↔ ERP)
|
||||
|
||||
1. کاربر در بله به بات پیام میدهد (متن یا وویس — وویس با STT به متن تبدیل میشود).
|
||||
2. بات با NLU موجودی/تأمینکننده/کالا را استخراج میکند و یک **draft** میسازد
|
||||
(وضعیت `active` در جدول `bot_drafts`) و کارت پیشنمایش با فیلدهای خالی میفرستد.
|
||||
3. کاربر با دکمهی ✅ تأیید میکند؛ بات draft را به ERP کامیت میکند
|
||||
(از طریق REST: `POST /api/v1/drafts/{id}/commit` با توکن سرویس).
|
||||
4. ویزارد فرانتاند همان draft را از `GET /api/v1/drafts` میخواند و پیشپر میکند —
|
||||
همگامسازی دوطرفه با ذخیرهی خودکار (debounced) بین مرورگر و بات.
|
||||
5. ویرایشِ پاسخِ کاربر به کارت قبلی بات هم پشتیبانی میشود (reply-based edit، بدون کارت تکراری).
|
||||
|
||||
### ۵.۴ تست سریع سلامت
|
||||
|
||||
```powershell
|
||||
docker compose ps bot # باید Up باشد
|
||||
docker compose logs bot | Select-Object -Last 20
|
||||
# داخل کانتینر:
|
||||
docker compose exec bot python -m tools.smoke_test # اگر توکن ست باشد
|
||||
```
|
||||
|
||||
### ۵.۵ مسیرهای مهم
|
||||
|
||||
| مسیر | محتوا |
|
||||
|---|---|
|
||||
| `bot/app/` | کد بات (handlers برای quote/material/supplier/decisions، bale client، NLU، STT، sync) |
|
||||
| `bot/Dockerfile` | ایمیج `python:3.13-slim` + ffmpeg (برای وویس) |
|
||||
| `bot/docs/SETUP.md` | راهاندازی جزئیتر ایجنت |
|
||||
| `bot/integration/` | طرح ادغام: نمونه سرویس compose + پچ `X-Actor-Id` |
|
||||
| `backend/app/routers/drafts.py` | REST drafts (سمت ERP) |
|
||||
| `bot/procurement_ai_agent.bundle` و `bot/git_history_backup.zip` | بکاپ تاریخچهی مستقل قبلی ایجنت |
|
||||
|
||||
## ۶. نکات باقیمانده برای تیم
|
||||
|
||||
- **ادغام با برنچهای جدید تیم:** فایلهای بات فقط روی `prod-v7` هستند؛ `prod-v8`/`prod-v9`
|
||||
آنها را ندارند. برای رسیدن به استقرار نهایی، این برنچ باید با کار تیم merge شود.
|
||||
- **سرویس bot در `docker-compose.prod.yml` و `docker-compose.coolify.yml` نیست** —
|
||||
برای استقرار سرور باید به overlay پروڈ اضافه شود (یا از همان base compose استفاده شود).
|
||||
- نام قدیمی `ai procurement` در برخی مستندات (`bot/docs/SETUP.md`، `bot/integration/`)
|
||||
ممکن است هنوز دیده شود — محتوایی است و عملکردی تحت تأثیر نیست.
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
# procurement_ai_agent — Bale chatbot for the procurement ERP
|
||||
|
||||
A **Bale messenger bot** that turns Persian **chat messages and voice notes**
|
||||
into procurement actions in the
|
||||
[procurement ERP](https://git.bennu-api-bahrestan.ir/ali_mohaghegh/ERP) —
|
||||
draft-first: it captures what it understood, tells you **what it did** and
|
||||
**what is still empty**, and **commits only after you approve** with a ✅ button.
|
||||
|
||||
```
|
||||
you (Bale, text/voice) ┌─────────────────────────────────┐
|
||||
│ getUpdates (long poll) │ procurement_ai_agent │
|
||||
▼ │ ffmpeg → STT (Persian) │
|
||||
┌──────────────────┐ POST {base}/bot<T> │ LLM → intent + entities │
|
||||
│ tapi.bale.ai │◄───────────────────────►│ fuzzy resolve → draft payload │
|
||||
└──────────────────┘ sendMessage / buttons │ review + «چه چیزهایی خالی مونده»│
|
||||
└───────────────┬─────────────────┘
|
||||
│ HTTP + X-Actor-Id
|
||||
▼
|
||||
┌─────────────────────────────────┐
|
||||
│ ERP api (/api/v1) │
|
||||
│ quotes · suppliers · materials│
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
## What it can do
|
||||
|
||||
| Activity | Example (voice or text) | Committed via |
|
||||
|---|---|---|
|
||||
| Register a supplier quote | «سازان نو برای پروژه برج آسمان ۵۰۰ کیلو میلگرد A3 سایز ۱۴، هر کیلو ۴۵ تومن، پرداخت ۳ فاز» | `POST /quotes` |
|
||||
| Add a supplier | «تأمینکننده جدید پروفیل بر، دسته لوله و اتصالات، تلفن ۰۹۱۲…» | `POST /suppliers` |
|
||||
| Add a material | «کالای جدید میلگرد A3 سایز ۱۸ با واحد کیلوگرم» | `POST /materials` |
|
||||
| Status queries | «استعلامهای در انتظار؟» / «کدامها بهزودی منقضی میشوند؟» | `GET /quotes?…` |
|
||||
| Accept a quote | «استعلام سازان نو را قبول کن» | `PATCH /quotes/{id}/accept` |
|
||||
| Announce a delivery | «اقلام سازان نو رسید، ۵۰۰ و ۲۰۰» | `PATCH /quotes/{id}/arrive-items` |
|
||||
|
||||
### The draft → approve loop
|
||||
|
||||
1. Send a message or voice note. Voice → ffmpeg → STT; text/STT text → LLM
|
||||
extracts a typed **intent + entities** (JSON mode, Persian digits
|
||||
normalized).
|
||||
2. Names are fuzzy-matched against the ERP. Several candidates → inline
|
||||
**pick buttons**; no match → listed as missing.
|
||||
3. The bot replies with a **review card**: everything captured + a
|
||||
✅/⚠️/❌ list of what is still empty.
|
||||
4. Keep chatting to refine («قیمت میلگرد ۴۷ شد») — the open draft updates and
|
||||
re-renders. Drafts survive bot restarts (postgres-backed, TTL-swept).
|
||||
5. **Nothing is written to the ERP until you tap ✅.** After commit you get the
|
||||
created quote id and the fields left empty.
|
||||
|
||||
### Team access & attribution
|
||||
|
||||
Each member links their Bale chat once: `/start` → Casdoor username → password
|
||||
(validated via the ERP's real `POST /auth/login`, used once, never stored).
|
||||
Every mutating call carries `X-Actor-Id: <users.id>` so `quote_events.actor`
|
||||
records the **human** who asked.
|
||||
|
||||
Commands: `/start` · `/help` · `/drafts` · `/cancel` · `/whoami` · `/unlink`
|
||||
|
||||
## Quick start (Docker)
|
||||
|
||||
```bash
|
||||
cp .env.example .env # fill PROCUREMENT_BOT_BOT_TOKEN + AI_API_KEY
|
||||
docker build -t procurement-ai-agent .
|
||||
docker run -d --name procurement-bot --env-file .env \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
procurement-ai-agent
|
||||
docker logs -f procurement-bot
|
||||
```
|
||||
|
||||
Or as part of the ERP stack — see [`integration/`](integration/README.md)
|
||||
(compose service + the one tiny ERP-side patch).
|
||||
|
||||
Full setup, env-var reference, flows and troubleshooting:
|
||||
**[docs/SETUP.md](docs/SETUP.md)**.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
app/
|
||||
├── main.py ensure bot tables → sweeper task → getUpdates long-poll loop
|
||||
├── config.py pydantic-settings, PROCUREMENT_BOT_ prefix
|
||||
├── bale/client.py Telegram-compatible Bale API (poll, send, edit, buttons, files)
|
||||
├── api/client.py ERP /api/v1 client (X-Actor-Id attribution, error envelope)
|
||||
├── stt.py ffmpeg (OGG/Opus → MP3) → OpenAI-compatible /audio/transcriptions
|
||||
├── nlu.py Persian intent+entity extraction (JSON-mode chat completions)
|
||||
├── resolve.py fuzzy name→id matching (difflib over ilike search), TTL caches
|
||||
├── drafts.py draft store · QuoteCreate payload builder · missing-field logic
|
||||
├── keyboards.py inline keyboards (callback_data ≤ 64 B op-codes)
|
||||
├── texts.py Persian message templates
|
||||
├── runtime.py context, link store, in-memory pending flows
|
||||
├── models.py bot_links / bot_drafts / bot_messages / bot_meta (own schema)
|
||||
└── handlers/ dispatch · commands · callbacks · links ·
|
||||
quote · supplier · material · queries · decisions
|
||||
```
|
||||
|
||||
Design notes:
|
||||
|
||||
- **Telegram-compatible Bale API** (`tapi.bale.ai`): getUpdates long polling —
|
||||
no public port, no webhook, resumable offset persisted across restarts.
|
||||
- **Draft-first**: `bot_drafts.payload` holds a talking-friendly JSON that is
|
||||
converted into the exact `POST /quotes` request body (temp_id
|
||||
cross-references) only on approval; the API deliberately tolerates
|
||||
incomplete quotes, so partial commits are explicit, never accidental.
|
||||
- **Accept flow echoes the termsheet's existing `schedule_steps`** back to
|
||||
`PATCH /quotes/{id}/accept` (the API replaces steps with what it receives).
|
||||
- **Persian-aware matching**: Persian/Arabic digit normalization, ZWNJ folding,
|
||||
Arabic ya/ka folding, difflib ranking on top of the API's ilike search.
|
||||
- **Voice notes** arrive as OGG/Opus; ffmpeg converts to mono 16 kHz MP3 before
|
||||
STT. The LLM provider and the STT provider are configured independently
|
||||
(e.g. DeepSeek for NLU + any whisper-compatible endpoint for voice).
|
||||
|
||||
## Testing
|
||||
|
||||
An offline end-to-end harness stubs Bale/ERP/AI and drives the real handler
|
||||
pipeline with fake updates — no token, DB or API key needed:
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
python tools/smoke_test.py # → ALL PASSED: 48 passed
|
||||
```
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Bale procurement chatbot — drafts ERP actions from chat text/voice,
|
||||
reports what it captured + what is missing, and commits only on approval."""
|
||||
@@ -0,0 +1 @@
|
||||
"""ERP API client — the only way the bot touches procurement data."""
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Async client for the procurement API (/api/v1).
|
||||
|
||||
Attribution: every call carries ``X-Actor-Id`` (the linked ERP user id) so
|
||||
``quote_events.actor`` records the human who asked, not the bot. A service
|
||||
bearer token is sent when configured (procurement endpoints are currently
|
||||
open — the slot is for the day auth lands).
|
||||
"""
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger("bot.api")
|
||||
|
||||
|
||||
class ApiError(Exception):
|
||||
def __init__(self, status: int, code: str, message: str, field: str | None = None):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.field = field
|
||||
|
||||
|
||||
class ErpClient:
|
||||
def __init__(self, base_url: str, timeout: float = 30.0, service_token: str = ""):
|
||||
self._base = base_url.rstrip("/")
|
||||
self._timeout = timeout
|
||||
self._service_token = service_token
|
||||
|
||||
def _actor_headers(self, actor_id: str | None, actor_name: str | None) -> dict:
|
||||
headers: dict[str, str] = {}
|
||||
if self._service_token:
|
||||
headers["Authorization"] = f"Bearer {self._service_token}"
|
||||
if actor_id:
|
||||
headers["X-Actor-Id"] = actor_id
|
||||
return headers
|
||||
|
||||
async def _request(self, method: str, path: str, *, actor_id: str | None = None,
|
||||
json: Any = None, params: dict | None = None) -> Any:
|
||||
url = f"{self._base}{path}"
|
||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||
try:
|
||||
resp = await client.request(
|
||||
method, url, json=json, params=params, headers=self._actor_headers(actor_id, None)
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise ApiError(0, "NETWORK", f"ERP API unreachable: {exc}") from exc
|
||||
if resp.status_code in (200, 201):
|
||||
return resp.json()
|
||||
try:
|
||||
err = resp.json().get("error") or {}
|
||||
except ValueError:
|
||||
err = {}
|
||||
raise ApiError(
|
||||
resp.status_code,
|
||||
str(err.get("code") or "INTERNAL_ERROR"),
|
||||
str(err.get("message") or f"HTTP {resp.status_code}"),
|
||||
err.get("field"),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ auth
|
||||
|
||||
async def login(self, username: str, password: str) -> dict:
|
||||
body = await self._request("POST", "/auth/login", json={"username": username, "password": password})
|
||||
return body.get("data") or {}
|
||||
|
||||
# ---------------------------------------------------------------- lookup
|
||||
|
||||
async def list_suppliers(self, q: str | None = None, page_size: int = 20) -> list[dict]:
|
||||
params: dict = {"page_size": page_size}
|
||||
if q:
|
||||
params["q"] = q
|
||||
return (await self._request("GET", "/suppliers", params=params)).get("data") or []
|
||||
|
||||
async def create_supplier(self, payload: dict, actor_id: str | None = None) -> dict:
|
||||
return (await self._request("POST", "/suppliers", json=payload, actor_id=actor_id)).get("data") or {}
|
||||
|
||||
async def list_materials(self, q: str | None = None, page_size: int = 20) -> list[dict]:
|
||||
params: dict = {"page_size": page_size}
|
||||
if q:
|
||||
params["q"] = q
|
||||
return (await self._request("GET", "/materials", params=params)).get("data") or []
|
||||
|
||||
async def create_material(self, payload: dict, actor_id: str | None = None) -> dict:
|
||||
return (await self._request("POST", "/materials", json=payload, actor_id=actor_id)).get("data") or []
|
||||
|
||||
async def list_units(self, q: str | None = None, page_size: int = 100) -> list[dict]:
|
||||
params: dict = {"page_size": page_size}
|
||||
if q:
|
||||
params["q"] = q
|
||||
return (await self._request("GET", "/units", params=params)).get("data") or []
|
||||
|
||||
async def list_projects(self, page_size: int = 100) -> list[dict]:
|
||||
return (await self._request("GET", "/projects", params={"page_size": page_size})).get("data") or []
|
||||
|
||||
async def list_categories(self, page_size: int = 100) -> list[dict]:
|
||||
return (await self._request("GET", "/material-categories", params={"page_size": page_size})).get("data") or []
|
||||
|
||||
# ---------------------------------------------------------------- quotes
|
||||
|
||||
async def list_quotes(self, *, status: str | None = None, q: str | None = None,
|
||||
expand: str | None = None, sort: str | None = None,
|
||||
page: int = 1, page_size: int = 10) -> tuple[list[dict], dict]:
|
||||
params: dict = {"page": page, "page_size": page_size}
|
||||
if status:
|
||||
params["status"] = status
|
||||
if q:
|
||||
params["q"] = q
|
||||
if expand:
|
||||
params["expand"] = expand
|
||||
if sort:
|
||||
params["sort"] = sort
|
||||
body = await self._request("GET", "/quotes", params=params)
|
||||
return body.get("data") or [], body.get("meta") or {}
|
||||
|
||||
async def get_quote(self, quote_id: str, expand: str | None = None, actor_id: str | None = None) -> dict:
|
||||
params = {"expand": expand} if expand else None
|
||||
return (await self._request("GET", f"/quotes/{quote_id}", params=params, actor_id=actor_id)).get("data") or {}
|
||||
|
||||
async def create_quote(self, payload: dict, actor_id: str | None = None) -> dict:
|
||||
return (await self._request("POST", "/quotes", json=payload, actor_id=actor_id)).get("data") or {}
|
||||
|
||||
async def accept_quote(self, quote_id: str, payload: dict, actor_id: str | None = None) -> dict:
|
||||
path = f"/quotes/{quote_id}/accept"
|
||||
return (await self._request("PATCH", path, json=payload, actor_id=actor_id)).get("data") or {}
|
||||
|
||||
async def arrive_quote(self, quote_id: str, actor_id: str | None = None) -> dict:
|
||||
return (await self._request("PATCH", f"/quotes/{quote_id}/arrive", actor_id=actor_id)).get("data") or {}
|
||||
|
||||
async def arrive_items(self, quote_id: str, payload: dict, actor_id: str | None = None) -> dict:
|
||||
return (await self._request("PATCH", f"/quotes/{quote_id}/arrive-items", json=payload, actor_id=actor_id)).get("data") or {}
|
||||
|
||||
async def reject_quote(self, quote_id: str, reason: str | None = None, actor_id: str | None = None) -> dict:
|
||||
return (await self._request(
|
||||
"PATCH", f"/quotes/{quote_id}/reject", json={"reason": reason} if reason else None, actor_id=actor_id
|
||||
)).get("data") or {}
|
||||
|
||||
async def upload_quote_file(self, quote_id: str, kind: str, file_name: str, content: bytes,
|
||||
mime_type: str = "application/pdf", actor_id: str | None = None) -> dict:
|
||||
url = f"{self._base}/quotes/{quote_id}/{kind}"
|
||||
files = {"file": (file_name, content, mime_type)}
|
||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||
try:
|
||||
resp = await client.post(url, files=files, headers=self._actor_headers(actor_id, None))
|
||||
if resp.status_code in (200, 201):
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to upload quote attachment to ERP: %s", e)
|
||||
return {}
|
||||
@@ -0,0 +1 @@
|
||||
"""Bale Bot API client (Telegram-compatible surface at tapi.bale.ai)."""
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Thin async client for the Bale Bot API.
|
||||
|
||||
Bale's bot surface is a Telegram Bot API clone: ``POST {base}/bot{token}/{method}``
|
||||
and file downloads at ``{base}/file/bot{token}/{file_path}``. Long polling via
|
||||
``getUpdates`` avoids any webhook/TLS setup on the server.
|
||||
|
||||
HTML parse_mode is requested first; if Bale rejects it for a given payload we
|
||||
transparently retry as plain text so messages never get lost.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger("bot.bale")
|
||||
|
||||
REPLY_MARKUP = "reply_markup"
|
||||
|
||||
|
||||
class BaleError(Exception):
|
||||
def __init__(self, description: str, error_code: int | None = None):
|
||||
super().__init__(description)
|
||||
self.description = description
|
||||
self.error_code = error_code
|
||||
|
||||
|
||||
class BaleClient:
|
||||
def __init__(self, base_url: str, token: str, timeout: float = 60.0):
|
||||
self._base = base_url.rstrip("/")
|
||||
self._token = token
|
||||
self._timeout = timeout
|
||||
|
||||
# ------------------------------------------------------------------ core
|
||||
|
||||
def _method_url(self, method: str) -> str:
|
||||
return f"{self._base}/bot{self._token}/{method}"
|
||||
|
||||
def _file_url(self, file_path: str) -> str:
|
||||
return f"{self._base}/file/bot{self._token}/{file_path.lstrip('/')}"
|
||||
|
||||
async def _call(self, method: str, payload: dict | None = None, timeout: float | None = None) -> dict:
|
||||
async with httpx.AsyncClient(timeout=timeout or self._timeout) as client:
|
||||
try:
|
||||
resp = await client.post(self._method_url(method), json=payload or {})
|
||||
except httpx.HTTPError as exc:
|
||||
raise BaleError(f"network error calling {method}: {exc}") from exc
|
||||
try:
|
||||
body = resp.json()
|
||||
except ValueError as exc:
|
||||
raise BaleError(f"non-JSON response for {method} (HTTP {resp.status_code})") from exc
|
||||
if not body.get("ok"):
|
||||
raise BaleError(str(body.get("description") or "unknown bale error"), resp.status_code)
|
||||
return body.get("result")
|
||||
|
||||
# ------------------------------------------------------------- receiving
|
||||
|
||||
async def get_updates(self, offset: int, timeout_seconds: int) -> list[dict]:
|
||||
"""Long poll. ``timeout_seconds=0`` returns immediately (drain mode)."""
|
||||
result = await self._call(
|
||||
"getUpdates",
|
||||
{"offset": offset, "timeout": timeout_seconds, "allowed_updates": ["message", "callback_query"]},
|
||||
timeout=timeout_seconds + 15,
|
||||
)
|
||||
return result or []
|
||||
|
||||
# -------------------------------------------------------------- sending
|
||||
|
||||
async def send_message(self, chat_id: int, text: str, reply_markup: dict | None = None,
|
||||
reply_to: int | None = None) -> dict:
|
||||
payload: dict = {"chat_id": chat_id, "text": text[:4000]}
|
||||
if reply_markup:
|
||||
payload[REPLY_MARKUP] = reply_markup
|
||||
if reply_to:
|
||||
payload["reply_to_message_id"] = reply_to
|
||||
return await self._call("sendMessage", payload)
|
||||
|
||||
async def edit_message_text(self, chat_id: int, message_id: int, text: str,
|
||||
reply_markup: dict | None = None) -> dict:
|
||||
payload: dict = {"chat_id": chat_id, "message_id": message_id, "text": text[:4000]}
|
||||
if reply_markup:
|
||||
payload[REPLY_MARKUP] = reply_markup
|
||||
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:
|
||||
payload["text"] = text[:200]
|
||||
try:
|
||||
await self._call("answerCallbackQuery", payload)
|
||||
except BaleError as exc:
|
||||
logger.warning("answerCallbackQuery failed: %s", exc)
|
||||
|
||||
async def send_chat_action(self, chat_id: int, action: str = "typing") -> None:
|
||||
try:
|
||||
await self._call("sendChatAction", {"chat_id": chat_id, "action": action})
|
||||
except BaleError as exc:
|
||||
logger.debug("sendChatAction failed: %s", exc)
|
||||
|
||||
# ---------------------------------------------------------------- files
|
||||
|
||||
async def get_file(self, file_id: str) -> dict:
|
||||
return await self._call("getFile", {"file_id": file_id})
|
||||
|
||||
async def download_file(self, file_id: str) -> bytes:
|
||||
"""Resolve a file_id to bytes (getFile → file_path → download URL)."""
|
||||
info = await self.get_file(file_id)
|
||||
file_path = info.get("file_path")
|
||||
if not file_path:
|
||||
raise BaleError("getFile returned no file_path")
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
resp = await client.get(self._file_url(file_path))
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
|
||||
|
||||
async def poll_loop(client: BaleClient, on_update, offset_key: str = "bale_offset") -> None:
|
||||
"""Long-poll supervisor: resumable offset, one update at a time, never dies.
|
||||
|
||||
``on_update`` must be an async callable receiving the raw update dict.
|
||||
"""
|
||||
from app.db import SessionLocal
|
||||
from app.models import BotMeta
|
||||
|
||||
async def load_offset() -> int:
|
||||
async with SessionLocal() as session:
|
||||
row = (await session.execute(
|
||||
__import__("sqlalchemy").select(BotMeta.value).where(BotMeta.key == offset_key)
|
||||
)).scalar()
|
||||
return int(row) if row else 0
|
||||
|
||||
async def save_offset(value: int) -> None:
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
async with SessionLocal() as session:
|
||||
stmt = pg_insert(BotMeta).values(key=offset_key, value=str(value))
|
||||
stmt = stmt.on_conflict_do_update(index_elements=[BotMeta.key], set_={"value": str(value)})
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
|
||||
offset = await load_offset()
|
||||
if offset == 0:
|
||||
# Skip any backlog accumulated while the bot was down: peek once
|
||||
# without waiting and jump straight to the newest update.
|
||||
backlog = await client.get_updates(0, 0)
|
||||
if backlog:
|
||||
offset = max(u["update_id"] for u in backlog) + 1
|
||||
|
||||
logger.info("polling started (offset=%s)", offset)
|
||||
while True:
|
||||
try:
|
||||
updates = await client.get_updates(offset, 25)
|
||||
for update in updates:
|
||||
offset = max(offset, update["update_id"] + 1)
|
||||
await save_offset(offset)
|
||||
try:
|
||||
await on_update(update)
|
||||
except Exception:
|
||||
logger.exception("handler crashed on update %s", update.get("update_id"))
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except BaleError as exc:
|
||||
logger.warning("poll error: %s", exc)
|
||||
await asyncio.sleep(3)
|
||||
except Exception:
|
||||
logger.exception("unexpected poll error")
|
||||
await asyncio.sleep(3)
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Bot configuration.
|
||||
|
||||
All settings can be overridden with environment variables prefixed with
|
||||
``PROCUREMENT_BOT_`` (e.g. ``PROCUREMENT_BOT_BOT_TOKEN=...``) or a local
|
||||
``.env`` file. Docker/compose passes them in directly (same convention as
|
||||
the api service's ``PROCUREMENT_*`` prefix).
|
||||
"""
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="PROCUREMENT_BOT_",
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# Bale Bot API — Telegram-compatible clone hosted at tapi.bale.ai.
|
||||
BALE_API_BASE: str = "https://tapi.bale.ai"
|
||||
BOT_TOKEN: str = "" # from Bale's BotFather
|
||||
POLL_TIMEOUT_SECONDS: int = 25 # getUpdates long-poll window
|
||||
POLL_ERROR_SLEEP_SECONDS: float = 3.0
|
||||
|
||||
# ERP API (in-network service URL inside Docker).
|
||||
API_BASE_URL: str = "http://localhost:8000/api/v1"
|
||||
API_TIMEOUT_SECONDS: float = 30.0
|
||||
# Optional bearer for the day procurement endpoints start enforcing auth.
|
||||
SERVICE_TOKEN: str = ""
|
||||
|
||||
# Frontend URL (for opening Wizard from Bale bot)
|
||||
FRONTEND_URL: str = "http://localhost:8081"
|
||||
|
||||
# Security token for standalone wizard review URL
|
||||
DRAFT_TOKEN_SECRET: str = "procurement-draft-secret-change-in-prod-v7"
|
||||
DRAFT_TOKEN_EXPIRE_SECONDS: int = 7200 # 2 hours
|
||||
|
||||
# Persistent storage directory for draft attachments (proformas)
|
||||
ATTACHMENTS_DIR: str = "data/attachments/proformas"
|
||||
|
||||
# OpenAI-compatible AI provider (LLM), e.g. OpenAI / GLM / DeepSeek / OpenRouter.
|
||||
AI_BASE_URL: str = "" # e.g. https://api.openai.com/v1
|
||||
AI_API_KEY: str = ""
|
||||
LLM_MODEL: str = "gpt-4o-mini"
|
||||
LLM_TIMEOUT_SECONDS: float = 60.0
|
||||
|
||||
# STT providers — support multiple models/APIs in parallel.
|
||||
# 1. JSON list: [{"name": "m1", "base_url": "...", "api_key": "...", "model": "..."}, ...]
|
||||
STT_PROVIDERS_JSON: str = ""
|
||||
|
||||
# 2. Individual provider variables (up to 3 distinct STT models)
|
||||
STT_PROVIDER_1_NAME: str = "model-1"
|
||||
STT_PROVIDER_1_BASE_URL: str = ""
|
||||
STT_PROVIDER_1_API_KEY: str = ""
|
||||
STT_PROVIDER_1_MODEL: str = ""
|
||||
STT_PROVIDER_1_LANGUAGE: str = "fa"
|
||||
|
||||
STT_PROVIDER_2_NAME: str = "model-2"
|
||||
STT_PROVIDER_2_BASE_URL: str = ""
|
||||
STT_PROVIDER_2_API_KEY: str = ""
|
||||
STT_PROVIDER_2_MODEL: str = ""
|
||||
STT_PROVIDER_2_LANGUAGE: str = "fa"
|
||||
|
||||
STT_PROVIDER_3_NAME: str = "model-3"
|
||||
STT_PROVIDER_3_BASE_URL: str = ""
|
||||
STT_PROVIDER_3_API_KEY: str = ""
|
||||
STT_PROVIDER_3_MODEL: str = ""
|
||||
STT_PROVIDER_3_LANGUAGE: str = "fa"
|
||||
|
||||
# Legacy single STT provider fallback:
|
||||
STT_BASE_URL: str = ""
|
||||
STT_API_KEY: str = ""
|
||||
STT_MODEL: str = "whisper-1"
|
||||
STT_LANGUAGE: str = "fa"
|
||||
|
||||
@property
|
||||
def stt_base_url(self) -> str:
|
||||
return self.STT_BASE_URL or self.AI_BASE_URL
|
||||
|
||||
@property
|
||||
def stt_api_key(self) -> str:
|
||||
return self.STT_API_KEY or self.AI_API_KEY
|
||||
|
||||
@property
|
||||
def stt_providers(self) -> list[dict]:
|
||||
import json
|
||||
if self.STT_PROVIDERS_JSON:
|
||||
try:
|
||||
data = json.loads(self.STT_PROVIDERS_JSON)
|
||||
if isinstance(data, list) and len(data) > 0:
|
||||
return data
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Check explicit STT_PROVIDER_1 / 2 / 3
|
||||
custom_list: list[dict] = []
|
||||
for idx in (1, 2, 3):
|
||||
b_url = getattr(self, f"STT_PROVIDER_{idx}_BASE_URL")
|
||||
key = getattr(self, f"STT_PROVIDER_{idx}_API_KEY")
|
||||
model = getattr(self, f"STT_PROVIDER_{idx}_MODEL")
|
||||
name = getattr(self, f"STT_PROVIDER_{idx}_NAME")
|
||||
lang = getattr(self, f"STT_PROVIDER_{idx}_LANGUAGE") or "fa"
|
||||
if b_url and key:
|
||||
custom_list.append({
|
||||
"name": name or f"model-{idx}",
|
||||
"base_url": b_url,
|
||||
"api_key": key,
|
||||
"model": model or self.STT_MODEL,
|
||||
"language": lang,
|
||||
"timeout": 30.0,
|
||||
})
|
||||
if custom_list:
|
||||
return custom_list
|
||||
|
||||
# Fallback to single legacy STT provider if set
|
||||
if self.stt_base_url and self.stt_api_key:
|
||||
return [{
|
||||
"name": "default",
|
||||
"base_url": self.stt_base_url,
|
||||
"api_key": self.stt_api_key,
|
||||
"model": self.STT_MODEL,
|
||||
"language": self.STT_LANGUAGE,
|
||||
"timeout": 30.0,
|
||||
}]
|
||||
return []
|
||||
|
||||
# PostgreSQL — same instance/database as the api service.
|
||||
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/procurement"
|
||||
|
||||
# Chats that may use the bot without Casdoor linking (comma separated).
|
||||
ADMIN_CHAT_IDS: str = ""
|
||||
|
||||
# Drafts older than this are marked expired by the sweeper loop.
|
||||
DRAFT_TTL_HOURS: int = 24
|
||||
|
||||
LOG_LEVEL: str = "INFO"
|
||||
|
||||
@property
|
||||
def admin_chat_ids(self) -> set[int]:
|
||||
out: set[int] = set()
|
||||
for part in self.ADMIN_CHAT_IDS.replace(";", ",").split(","):
|
||||
part = part.strip()
|
||||
if part.isdigit():
|
||||
out.add(int(part))
|
||||
return out
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Async engine/session for the bot's own tables (same postgres as the api)."""
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.config import settings
|
||||
|
||||
engine = create_async_engine(settings.DATABASE_URL, pool_pre_ping=True)
|
||||
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Idempotent DDL for the bot's own tables (mirrors the api's init_db.py
|
||||
convention — the ORM never creates tables; this script does, IF NOT EXISTS).
|
||||
"""
|
||||
import logging
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import Base
|
||||
|
||||
logger = logging.getLogger("bot.db_init")
|
||||
|
||||
DDL = [
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS bot_links (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
chat_id BIGINT UNIQUE NOT NULL,
|
||||
user_id UUID,
|
||||
casdoor_username TEXT,
|
||||
display_name TEXT,
|
||||
role VARCHAR(32),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""",
|
||||
"CREATE INDEX IF NOT EXISTS ix_bot_links_chat_id ON bot_links (chat_id)",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS bot_drafts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
chat_id BIGINT NOT NULL,
|
||||
kind VARCHAR(16) NOT NULL,
|
||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
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()
|
||||
)
|
||||
""",
|
||||
"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(),
|
||||
chat_id BIGINT NOT NULL,
|
||||
direction VARCHAR(4) NOT NULL,
|
||||
kind VARCHAR(16) NOT NULL DEFAULT 'text',
|
||||
body TEXT,
|
||||
payload JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""",
|
||||
"CREATE INDEX IF NOT EXISTS ix_bot_messages_chat_id ON bot_messages (chat_id)",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS bot_meta (
|
||||
key VARCHAR(64) PRIMARY KEY,
|
||||
value TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""",
|
||||
]
|
||||
|
||||
|
||||
async def ensure_bot_tables(session: AsyncSession) -> None:
|
||||
for stmt in DDL:
|
||||
await session.execute(text(stmt))
|
||||
await session.commit()
|
||||
logger.info("bot tables ready")
|
||||
|
||||
|
||||
async def ensure_schema() -> None:
|
||||
from app.db import SessionLocal
|
||||
|
||||
async with SessionLocal() as session:
|
||||
await ensure_bot_tables(session)
|
||||
@@ -0,0 +1,482 @@
|
||||
"""Draft store + payload/missing-field logic.
|
||||
|
||||
A draft's ``payload`` is a talking-friendly JSON the bot keeps refining. When
|
||||
the user approves, ``build_quote_payload`` converts it into the exact
|
||||
``POST /quotes`` request body (temp_ids cross-referencing line items,
|
||||
termsheets and prices).
|
||||
|
||||
Resolution state lives inside the payload:
|
||||
payload["supplier_id"], payload["project_id"],
|
||||
item["material_id"], item["unit_id"], item["unit_explicit"],
|
||||
payload["options"][field] = candidate list shown as pick-buttons.
|
||||
"""
|
||||
import difflib
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import cast, func, select, String, update
|
||||
|
||||
from app.db import SessionLocal
|
||||
from app.models import BotDraft
|
||||
from app.nlu import normalize_text
|
||||
|
||||
KIND_QUOTE = "quote"
|
||||
KIND_SUPPLIER = "supplier"
|
||||
KIND_MATERIAL = "material"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Store
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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:
|
||||
draft = BotDraft(
|
||||
chat_id=chat_id,
|
||||
kind=kind,
|
||||
payload=payload,
|
||||
source=source,
|
||||
source_message_id=source_message_id,
|
||||
)
|
||||
session.add(draft)
|
||||
await session.commit()
|
||||
await session.refresh(draft)
|
||||
return draft
|
||||
|
||||
|
||||
async def get_active(chat_id: int, kind: str | None = None) -> BotDraft | None:
|
||||
async with SessionLocal() as session:
|
||||
stmt = select(BotDraft).where(BotDraft.chat_id == chat_id, BotDraft.status == "active")
|
||||
if kind:
|
||||
stmt = stmt.where(BotDraft.kind == kind)
|
||||
stmt = stmt.order_by(BotDraft.updated_at.desc()).limit(1)
|
||||
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:
|
||||
stmt = select(BotDraft).where(cast(BotDraft.id, String).like(f"{draft_prefix}%"))
|
||||
if chat_id is not None:
|
||||
stmt = stmt.where(BotDraft.chat_id == chat_id)
|
||||
return (await session.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
|
||||
async def save_payload(draft: BotDraft, missing: dict | None = None,
|
||||
review_message_id: int | None = None,
|
||||
source_message_id: int | None = None,
|
||||
mark_bale_synced: bool = False) -> None:
|
||||
async with SessionLocal() as session:
|
||||
effective_review_msg_id = review_message_id if review_message_id is not None else draft.review_message_id
|
||||
values: dict = {
|
||||
"payload": draft.payload,
|
||||
"missing": missing,
|
||||
"review_message_id": effective_review_msg_id,
|
||||
}
|
||||
if source_message_id is not None:
|
||||
values["source_message_id"] = source_message_id
|
||||
draft.source_message_id = source_message_id
|
||||
if mark_bale_synced:
|
||||
values["last_bale_update_at"] = func.now()
|
||||
await session.execute(
|
||||
update(BotDraft)
|
||||
.where(BotDraft.id == draft.id)
|
||||
.values(**values)
|
||||
)
|
||||
await session.commit()
|
||||
draft.review_message_id = effective_review_msg_id
|
||||
|
||||
|
||||
async def set_status(draft_id: str, status: str) -> None:
|
||||
async with SessionLocal() as session:
|
||||
await session.execute(update(BotDraft).where(BotDraft.id == draft_id).values(status=status))
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def set_bale_updated_at(draft_id) -> None:
|
||||
async with SessionLocal() as session:
|
||||
await session.execute(
|
||||
update(BotDraft)
|
||||
.where(BotDraft.id == draft_id)
|
||||
.values(last_bale_update_at=func.now())
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def get_pending_bale_updates() -> list[BotDraft]:
|
||||
async with SessionLocal() as session:
|
||||
stmt = (
|
||||
select(BotDraft)
|
||||
.where(
|
||||
BotDraft.review_message_id.is_not(None),
|
||||
(BotDraft.last_bale_update_at.is_(None)) | (BotDraft.updated_at > BotDraft.last_bale_update_at),
|
||||
)
|
||||
.order_by(BotDraft.updated_at.asc())
|
||||
.limit(10)
|
||||
)
|
||||
return list((await session.execute(stmt)).scalars().all())
|
||||
|
||||
|
||||
async def expire_stale(ttl_hours: int) -> int:
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(hours=ttl_hours)
|
||||
async with SessionLocal() as session:
|
||||
result = await session.execute(
|
||||
update(BotDraft)
|
||||
.where(BotDraft.status == "active", BotDraft.updated_at < cutoff)
|
||||
.values(status="expired")
|
||||
)
|
||||
await session.commit()
|
||||
return result.rowcount or 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Quote payload
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def new_quote_payload() -> dict:
|
||||
return {
|
||||
"supplier_name": None, "supplier_id": None,
|
||||
"project_name": None, "project_id": None, "project_names": [], "project_ids": [],
|
||||
"is_official": True, "notes": None, "expiry_date": None,
|
||||
"items": [], "termsheets": [],
|
||||
"options": {}, "proforma_file": None,
|
||||
}
|
||||
|
||||
|
||||
def new_item(material_name: str | None = None) -> dict:
|
||||
return {
|
||||
"material_name": material_name, "material_id": None,
|
||||
"quantity": None, "unit_name": None, "unit_id": None, "unit_explicit": False,
|
||||
"unit_price": None, "brand_name": None, "specs": [], "notes": None,
|
||||
}
|
||||
|
||||
|
||||
def _similarity(a: str | None, b: str | None) -> float:
|
||||
na, nb = normalize_text(a or ""), normalize_text(b or "")
|
||||
if not na or not nb:
|
||||
return 0.0
|
||||
ratio = difflib.SequenceMatcher(None, na, nb).ratio()
|
||||
if na in nb or nb in na:
|
||||
ratio += 0.3
|
||||
return ratio
|
||||
|
||||
|
||||
def merge_quote_patch(payload: dict, patch: dict) -> bool:
|
||||
"""Merge an NLU patch into the payload. Returns True if anything changed."""
|
||||
changed = False
|
||||
|
||||
def set_field(key: str, value) -> None:
|
||||
nonlocal changed
|
||||
if value is None:
|
||||
return
|
||||
if payload.get(key) != value:
|
||||
payload[key] = value
|
||||
changed = True
|
||||
|
||||
if patch.get("supplier_name"):
|
||||
payload["supplier_name"] = str(patch["supplier_name"]).strip()
|
||||
payload["supplier_id"] = None # needs re-resolution
|
||||
changed = True
|
||||
if patch.get("project_names"):
|
||||
payload["project_names"] = [str(p).strip() for p in patch["project_names"] if p]
|
||||
if payload["project_names"] and not payload.get("project_name"):
|
||||
payload["project_name"] = payload["project_names"][0]
|
||||
payload["project_id"] = None
|
||||
changed = True
|
||||
elif patch.get("project_name"):
|
||||
payload["project_name"] = str(patch["project_name"]).strip()
|
||||
payload["project_id"] = None
|
||||
if not payload.get("project_names"):
|
||||
payload["project_names"] = [payload["project_name"]]
|
||||
changed = True
|
||||
set_field("is_official", patch.get("is_official"))
|
||||
if patch.get("notes"):
|
||||
payload["notes"] = f"{payload.get('notes') or ''}\n{patch['notes']}".strip()
|
||||
changed = True
|
||||
if patch.get("expiry_date"):
|
||||
set_field("expiry_date", patch["expiry_date"])
|
||||
|
||||
for raw in patch.get("items") or []:
|
||||
name = (raw.get("material_name") or "").strip() or None
|
||||
target = None
|
||||
if name:
|
||||
best, best_ratio = None, 0.0
|
||||
for item in payload["items"]:
|
||||
# If both items have distinct specs, they are different line items
|
||||
if raw.get("specs") and item.get("specs") and set(raw["specs"]) != set(item["specs"]):
|
||||
continue
|
||||
# If both items have distinct brands, they are different line items
|
||||
if raw.get("brand_name") and item.get("brand_name") and normalize_text(raw["brand_name"]) != normalize_text(item["brand_name"]):
|
||||
continue
|
||||
ratio = _similarity(name, item.get("material_name"))
|
||||
if ratio > best_ratio:
|
||||
best, best_ratio = item, ratio
|
||||
if best is not None and best_ratio >= 0.55:
|
||||
target = best
|
||||
if target is None:
|
||||
target = new_item(name)
|
||||
payload["items"].append(target)
|
||||
changed = True
|
||||
for key in ("quantity", "unit_price"):
|
||||
if raw.get(key) is not None:
|
||||
try:
|
||||
value = float(raw[key])
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if target.get(key) != value:
|
||||
target[key] = value
|
||||
changed = True
|
||||
if raw.get("specs"):
|
||||
target["specs"] = [str(s).strip() for s in raw["specs"] if s]
|
||||
spec_str = "، ".join(target["specs"])
|
||||
if spec_str and not target.get("notes"):
|
||||
target["notes"] = spec_str
|
||||
changed = True
|
||||
for key in ("unit_name", "brand_name", "notes"):
|
||||
if raw.get(key):
|
||||
val = str(raw[key]).strip()
|
||||
if target.get(key) != val:
|
||||
target[key] = val
|
||||
changed = True
|
||||
|
||||
for raw in patch.get("payment_terms") or []:
|
||||
label = (raw.get("label") or "شرایط پرداخت").strip()
|
||||
milestones = []
|
||||
for m in raw.get("milestones") or []:
|
||||
if m.get("due_date") or m.get("amount"):
|
||||
milestones.append({
|
||||
"due_date": m.get("due_date"),
|
||||
"amount": float(m.get("amount") or 0),
|
||||
"note": m.get("note"),
|
||||
})
|
||||
entry = {
|
||||
"label": label,
|
||||
"discount_type": raw.get("discount_type") or "none",
|
||||
"discount_value": float(raw.get("discount_value") or 0),
|
||||
"milestones": milestones,
|
||||
"description": (raw.get("description") or "").strip() or None,
|
||||
}
|
||||
if not any(normalize_text(t["label"]) == normalize_text(label) for t in payload["termsheets"]):
|
||||
payload["termsheets"].append(entry)
|
||||
changed = True
|
||||
|
||||
return changed
|
||||
|
||||
|
||||
def compute_quote_missing(payload: dict) -> list[dict]:
|
||||
"""What is still empty — strictly aligned with DB schema requirements."""
|
||||
out: list[dict] = []
|
||||
|
||||
def add(key: str, label: str, ok: bool, blocking: bool) -> None:
|
||||
out.append({"key": key, "label": label, "ok": ok, "blocking": blocking})
|
||||
|
||||
# In DB: supplier_id is NOT NULL in quotes table.
|
||||
sup_ok = bool(payload.get("supplier_id") or payload.get("supplier_name"))
|
||||
add("supplier", "تأمینکننده", sup_ok, True)
|
||||
|
||||
# In DB: project_id is nullable (optional)
|
||||
add("project", "پروژه", bool(payload.get("project_id") or payload.get("project_ids")), False)
|
||||
|
||||
# In DB: expiry_date is nullable (optional)
|
||||
add("expiry", "تاریخ اعتبار", bool(payload.get("expiry_date")), False)
|
||||
|
||||
items = payload.get("items") or []
|
||||
add("items", "قلم (کالا/مقدار)", bool(items), True)
|
||||
for i, item in enumerate(items, start=1):
|
||||
name = item.get("material_name") or f"قلم {i}"
|
||||
has_mat = bool(item.get("material_id") or item.get("material_name"))
|
||||
if not has_mat:
|
||||
add(f"item:{i}:material", f"نام کالا برای قلم {i}", False, True)
|
||||
|
||||
if not item.get("quantity"):
|
||||
add(f"item:{i}:qty", f"مقدار «{name}»", False, True)
|
||||
if not item.get("unit_id") and not item.get("unit_name"):
|
||||
add(f"item:{i}:unit", f"واحد کالا برای «{name}»", False, True)
|
||||
if not item.get("unit_price"):
|
||||
add(f"item:{i}:price", f"قیمت واحد برای «{name}»", False, False)
|
||||
|
||||
if items and not payload.get("termsheets"):
|
||||
add("terms", "شرایط پرداخت", False, False)
|
||||
if not payload.get("notes"):
|
||||
add("notes", "توضیحات", False, False)
|
||||
return out
|
||||
|
||||
|
||||
def quote_ready(payload: dict) -> tuple[bool, list[str]]:
|
||||
"""Blocking check before commit; returns (ok, blocking_labels)."""
|
||||
missing = compute_quote_missing(payload)
|
||||
return all(not (m["blocking"] and not m["ok"]) for m in missing), [
|
||||
m["label"] for m in missing if m["blocking"] and not m["ok"]
|
||||
]
|
||||
|
||||
|
||||
def build_quote_request(payload: dict, only_resolved_items: bool = False) -> tuple[dict | None, list[str]]:
|
||||
"""Convert the payload into a QuoteCreate body. Returns (body, problems)."""
|
||||
problems: list[str] = []
|
||||
if not payload.get("supplier_id"):
|
||||
return None, ["تأمینکننده مشخص نیست"]
|
||||
|
||||
items = payload.get("items") or []
|
||||
line_items: list[dict] = []
|
||||
for i, item in enumerate(items, start=1):
|
||||
if not item.get("material_id"):
|
||||
if only_resolved_items:
|
||||
continue
|
||||
problems.append(f"کالای قلم {i} («{item.get('material_name') or '?'}») شناسایی نشد")
|
||||
continue
|
||||
if not item.get("quantity"):
|
||||
problems.append(f"مقدار قلم {i} («{item.get('material_name') or '?'}») مشخص نیست")
|
||||
continue
|
||||
unit_id = item.get("unit_id")
|
||||
if not unit_id:
|
||||
problems.append(f"واحد قلم {i} («{item.get('material_name') or '?'}») مشخص نیست")
|
||||
continue
|
||||
notes_parts = []
|
||||
if item.get("brand_name"):
|
||||
notes_parts.append(f"برند: {item['brand_name']}")
|
||||
if item.get("specs"):
|
||||
notes_parts.append("، ".join(item["specs"]))
|
||||
if item.get("notes"):
|
||||
if item["notes"] not in notes_parts:
|
||||
notes_parts.append(item["notes"])
|
||||
notes_str = " | ".join(notes_parts) if notes_parts else None
|
||||
|
||||
line_items.append({
|
||||
"temp_id": f"t{i}",
|
||||
"material_id": item["material_id"],
|
||||
"unit_id": int(unit_id),
|
||||
"quantity": item["quantity"],
|
||||
"notes": notes_str,
|
||||
})
|
||||
|
||||
termsheets: list[dict] = []
|
||||
item_termsheets: list[dict] = []
|
||||
priced = [it for it in items if it.get("unit_price") and it.get("material_id")]
|
||||
if priced:
|
||||
raw_sheets = payload.get("termsheets") or []
|
||||
if not raw_sheets:
|
||||
raw_sheets = [{"label": "پیشنهاد قیمت"}]
|
||||
for j, ts in enumerate(raw_sheets, start=1):
|
||||
steps = []
|
||||
for s_idx, m in enumerate(ts.get("milestones") or ts.get("schedule_steps") or [], start=1):
|
||||
steps.append({
|
||||
"step_no": s_idx,
|
||||
"due_date": m.get("due_date"),
|
||||
"amount": float(m.get("amount") or 0),
|
||||
"note": m.get("note"),
|
||||
})
|
||||
termsheets.append({
|
||||
"temp_id": f"p{j}",
|
||||
"label": ts.get("label") or f"شرایط {j}",
|
||||
"discount_type": ts.get("discount_type") or "none",
|
||||
"discount_value": float(ts.get("discount_value") or 0),
|
||||
"schedule_steps": steps,
|
||||
})
|
||||
first = termsheets[0]["temp_id"]
|
||||
for i, item in enumerate(items, start=1):
|
||||
if item.get("unit_price") and item.get("material_id") and item.get("quantity") and item.get("unit_id"):
|
||||
item_termsheets.append({
|
||||
"line_item_temp_id": f"t{i}",
|
||||
"termsheet_temp_id": first,
|
||||
"unit_price": item["unit_price"],
|
||||
})
|
||||
|
||||
body: dict = {
|
||||
"supplier_id": payload["supplier_id"],
|
||||
"is_official": bool(payload.get("is_official", True)),
|
||||
"line_items": line_items,
|
||||
"termsheets": termsheets,
|
||||
"item_termsheets": item_termsheets,
|
||||
}
|
||||
if payload.get("project_ids"):
|
||||
body["project_ids"] = payload["project_ids"]
|
||||
elif payload.get("project_id"):
|
||||
body["project_ids"] = [payload["project_id"]]
|
||||
if payload.get("expiry_date"):
|
||||
body["expiry_date"] = payload["expiry_date"]
|
||||
if payload.get("notes"):
|
||||
body["notes"] = payload["notes"]
|
||||
if not line_items and problems:
|
||||
return None, problems
|
||||
return body, problems
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Supplier / material payloads
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def new_supplier_payload() -> dict:
|
||||
return {"name": None, "category_id": None, "category_name": None,
|
||||
"phone": None, "location": None, "contact_person": None, "options": {}}
|
||||
|
||||
|
||||
def merge_supplier_patch(payload: dict, patch: dict) -> bool:
|
||||
src = patch.get("supplier") or patch
|
||||
changed = False
|
||||
for key in ("name", "phone", "location", "contact_person"):
|
||||
if src.get(key) and payload.get(key) != str(src[key]).strip():
|
||||
payload[key] = str(src[key]).strip()
|
||||
changed = True
|
||||
if src.get("category_name"):
|
||||
payload["category_name"] = str(src["category_name"]).strip()
|
||||
payload["category_id"] = None
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def supplier_missing(payload: dict) -> list[dict]:
|
||||
return [
|
||||
{"key": "name", "label": "نام تأمینکننده", "ok": bool(payload.get("name")), "blocking": True},
|
||||
{"key": "category", "label": "دستهبندی", "ok": bool(payload.get("category_id")), "blocking": True},
|
||||
{"key": "phone", "label": "تلفن", "ok": bool(payload.get("phone")), "blocking": False},
|
||||
{"key": "location", "label": "موقعیت", "ok": bool(payload.get("location")), "blocking": False},
|
||||
]
|
||||
|
||||
|
||||
def new_material_payload() -> dict:
|
||||
return {"name": None, "canonical_unit_id": None, "unit_name": None, "topic": None, "options": {}}
|
||||
|
||||
|
||||
def merge_material_patch(payload: dict, patch: dict) -> bool:
|
||||
src = patch.get("material") or patch
|
||||
changed = False
|
||||
if src.get("name") and payload.get("name") != str(src["name"]).strip():
|
||||
payload["name"] = str(src["name"]).strip()
|
||||
changed = True
|
||||
if src.get("unit_name"):
|
||||
payload["unit_name"] = str(src["unit_name"]).strip()
|
||||
payload["canonical_unit_id"] = None
|
||||
changed = True
|
||||
if src.get("topic") and payload.get("topic") != str(src["topic"]).strip():
|
||||
payload["topic"] = str(src["topic"]).strip()
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def material_missing(payload: dict) -> list[dict]:
|
||||
return [
|
||||
{"key": "name", "label": "نام کالا", "ok": bool(payload.get("name")), "blocking": True},
|
||||
{"key": "unit", "label": "واحد اصلی", "ok": bool(payload.get("canonical_unit_id")), "blocking": True},
|
||||
{"key": "topic", "label": "تاپیک", "ok": bool(payload.get("topic")), "blocking": False},
|
||||
]
|
||||
|
||||
|
||||
def short_id() -> str:
|
||||
return uuid.uuid4().hex[:8]
|
||||
@@ -0,0 +1,325 @@
|
||||
"""Update dispatch — the heart of the bot.
|
||||
|
||||
Pipeline per update:
|
||||
callback_query → callbacks router (link-gated)
|
||||
message text starting "/" → command router
|
||||
voice/audio → download → STT → treated as text (source=voice)
|
||||
text → pending-flow interception (linking / accept-arrive confirmations)
|
||||
→ auth gate (link or admin)
|
||||
→ NLU → intent router
|
||||
"""
|
||||
import logging
|
||||
|
||||
from app import drafts as draft_store
|
||||
from app.config import settings
|
||||
from app.nlu import understand
|
||||
from app.runtime import BotContext, LinkedUser
|
||||
from app.storage import save_proforma_file
|
||||
from app.stt import transcribe, transcribe_multi
|
||||
from app.texts import CANCELLED, LINK_START, NOTHING_TO_COMMIT, STT_FAILED, UNKNOWN_HINT, VOICE_EMPTY, esc
|
||||
|
||||
from . import commands, callbacks, quote, supplier, material, queries, decisions
|
||||
|
||||
logger = logging.getLogger("bot.handlers")
|
||||
|
||||
|
||||
async def handle_update(ctx: BotContext, update: dict) -> None:
|
||||
if update.get("callback_query"):
|
||||
await callbacks.handle_callback(ctx, update["callback_query"])
|
||||
return
|
||||
message = update.get("message") or update.get("edited_message")
|
||||
if not message:
|
||||
return
|
||||
chat_id = message["chat"]["id"]
|
||||
try:
|
||||
await handle_message(ctx, chat_id, message)
|
||||
except Exception:
|
||||
logger.exception("message handling failed for chat %s", chat_id)
|
||||
await ctx.send(chat_id, "خطای غیرمنتظره رخ داد؛ دوباره تلاش کن.")
|
||||
|
||||
|
||||
async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
|
||||
text = (message.get("text") or message.get("caption") or "").strip()
|
||||
source = "text"
|
||||
voice_transcripts: list[dict] | None = None
|
||||
message_id = message.get("message_id")
|
||||
|
||||
if text.startswith("/"):
|
||||
await commands.handle_command(ctx, chat_id, text)
|
||||
return
|
||||
|
||||
# Pending multi-step interactions take the message first.
|
||||
flow = ctx.pending.get(chat_id)
|
||||
if flow:
|
||||
handled = await _route_to_flow(ctx, chat_id, flow, text)
|
||||
if handled:
|
||||
return
|
||||
ctx.pending.pop(chat_id) # stale/irrelevant flow → fall through
|
||||
|
||||
user = await _gate(ctx, chat_id, message)
|
||||
if user is None:
|
||||
return # gate either started linking or is mid-linking
|
||||
|
||||
reply_to = message.get("reply_to_message")
|
||||
reply_to_msg_id = reply_to.get("message_id") if reply_to else None
|
||||
|
||||
def _is_audio(msg: dict) -> bool:
|
||||
if msg.get("voice") or msg.get("audio"):
|
||||
return True
|
||||
doc = msg.get("document")
|
||||
if doc and (doc.get("mime_type") or "").lower().startswith("audio/"):
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _extract_voice() -> list[dict] | None:
|
||||
nonlocal source
|
||||
source = "voice"
|
||||
await ctx.bale.send_chat_action(chat_id, "typing")
|
||||
audio_media = message.get("voice") or message.get("audio") or message.get("document")
|
||||
file_id = audio_media["file_id"]
|
||||
try:
|
||||
audio_bytes = await ctx.bale.download_file(file_id)
|
||||
transcripts = await transcribe_multi(
|
||||
settings,
|
||||
audio_bytes,
|
||||
mime_type=audio_media.get("mime_type"),
|
||||
filename=audio_media.get("file_name"),
|
||||
)
|
||||
except Exception as exc:
|
||||
detail = "هوش مصنوعی تنظیم نشده" if "not configured" in str(exc) else str(exc)[:200]
|
||||
await ctx.send(chat_id, STT_FAILED.format(detail=esc(detail)), reply_to=message_id)
|
||||
return None
|
||||
if not transcripts:
|
||||
await ctx.send(chat_id, VOICE_EMPTY, reply_to=message_id)
|
||||
return None
|
||||
return transcripts
|
||||
|
||||
# =========================================================================
|
||||
# BRANCH 1: EDIT MODE — Strictly requires reply_to_message_id on a draft
|
||||
# =========================================================================
|
||||
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
|
||||
|
||||
# Check for proforma attachment (document or photo)
|
||||
proforma_attached = False
|
||||
saved_meta = None
|
||||
doc = message.get("document")
|
||||
photos = message.get("photo")
|
||||
|
||||
if doc and not (doc.get("mime_type") or "").lower().startswith("audio/"):
|
||||
doc_file_id = doc["file_id"]
|
||||
doc_file_name = doc.get("file_name") or f"proforma_{message_id}.pdf"
|
||||
doc_mime = doc.get("mime_type") or "application/pdf"
|
||||
elif photos:
|
||||
largest = photos[-1] if isinstance(photos, list) else photos
|
||||
doc_file_id = largest["file_id"]
|
||||
doc_file_name = f"proforma_{message_id}.jpg"
|
||||
doc_mime = "image/jpeg"
|
||||
else:
|
||||
doc_file_id = None
|
||||
doc_file_name = None
|
||||
doc_mime = None
|
||||
|
||||
if doc_file_id:
|
||||
await ctx.bale.send_chat_action(chat_id, "upload_document")
|
||||
try:
|
||||
content = await ctx.bale.download_file(doc_file_id)
|
||||
saved_meta = await save_proforma_file(
|
||||
target_draft.id,
|
||||
doc_file_name,
|
||||
content,
|
||||
mime_type=doc_mime,
|
||||
storage_dir=settings.ATTACHMENTS_DIR,
|
||||
)
|
||||
payload = dict(target_draft.payload or {})
|
||||
payload["proforma_file"] = saved_meta["file_name"]
|
||||
payload["proforma"] = saved_meta
|
||||
target_draft.payload = payload
|
||||
await draft_store.save_payload(target_draft, mark_bale_synced=True)
|
||||
proforma_attached = True
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to persist proforma")
|
||||
await ctx.send(chat_id, f"❌ خطا در ذخیره فایل پیشفاکتور: {esc(str(exc)[:100])}", reply_to=message_id)
|
||||
return
|
||||
|
||||
if _is_audio(message):
|
||||
voice_transcripts = await _extract_voice()
|
||||
if not voice_transcripts:
|
||||
return
|
||||
text = voice_transcripts[0]["text"]
|
||||
|
||||
if proforma_attached and not text and not voice_transcripts:
|
||||
await quote.render(ctx, chat_id, target_draft, fresh=False)
|
||||
await ctx.send(
|
||||
chat_id,
|
||||
f"📎 پیشفاکتور «{saved_meta['file_name']}» با موفقیت ذخیره و به استعلام ضمیمه شد ✅",
|
||||
reply_to=message_id,
|
||||
)
|
||||
return
|
||||
|
||||
if not proforma_attached and not text and not voice_transcripts:
|
||||
await ctx.send(
|
||||
chat_id,
|
||||
"⚠️ برای ویرایش پیشنویس، لطفاً تغییرات را به صورت متن یا پیام صوتی (ویس) ارسال کنید، یا فایل پیشفاکتور را بفرستید.",
|
||||
reply_to=message_id,
|
||||
)
|
||||
return
|
||||
|
||||
# =========================================================================
|
||||
# BRANCH 2: NEW QUOTE MODE — Messages without reply
|
||||
# =========================================================================
|
||||
else:
|
||||
target_draft = None
|
||||
|
||||
if _is_audio(message):
|
||||
voice_transcripts = await _extract_voice()
|
||||
if not voice_transcripts:
|
||||
return
|
||||
text = voice_transcripts[0]["text"]
|
||||
elif text:
|
||||
source = "text"
|
||||
else:
|
||||
await ctx.send(
|
||||
chat_id,
|
||||
"⚠️ برای ثبت استعلام خرید جدید، لطفاً اقلام و مشخصات را به صورت پیام متنی یا پیام صوتی (ویس) ارسال فرمایید.",
|
||||
reply_to=message_id,
|
||||
)
|
||||
return
|
||||
|
||||
draft_summary = _draft_summary(target_draft) if target_draft else None
|
||||
input_payload = voice_transcripts if voice_transcripts else text
|
||||
result = await understand(settings, input_payload, draft_summary)
|
||||
intent, patch = result.get("intent"), result.get("patch") or {}
|
||||
consensus_text = result.get("consensus_text") or text
|
||||
|
||||
if source == "voice":
|
||||
await ctx._log(chat_id, "in", "voice", consensus_text)
|
||||
|
||||
logger.info("chat %s intent=%s conf=%s consensus=%r (is_edit=%s)",
|
||||
chat_id, intent, result.get("confidence"), consensus_text[:40], target_draft is not None)
|
||||
|
||||
if intent in ("register_quote", "update_draft"):
|
||||
await quote.handle_entities(
|
||||
ctx, chat_id, user, patch, source,
|
||||
incoming_msg_id=message_id,
|
||||
target_draft=target_draft,
|
||||
)
|
||||
elif intent == "commit_draft":
|
||||
active = target_draft or await draft_store.get_active(chat_id)
|
||||
if active is None:
|
||||
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:
|
||||
await supplier.commit(ctx, chat_id, user, active)
|
||||
elif active.kind == draft_store.KIND_MATERIAL:
|
||||
await material.commit(ctx, chat_id, user, active)
|
||||
elif intent == "discard_draft":
|
||||
active = target_draft or await draft_store.get_active(chat_id)
|
||||
if active:
|
||||
await draft_store.set_status(active.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 = target_draft or await draft_store.get_active(chat_id)
|
||||
if active is None:
|
||||
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:
|
||||
await supplier.render(ctx, chat_id, active)
|
||||
elif active.kind == draft_store.KIND_MATERIAL:
|
||||
await material.render(ctx, chat_id, active)
|
||||
elif intent == "add_supplier":
|
||||
await supplier.handle_entities(ctx, chat_id, user, patch, source)
|
||||
elif intent == "add_material":
|
||||
await material.handle_entities(ctx, chat_id, user, patch, source)
|
||||
elif intent == "status_query":
|
||||
await queries.handle_query(ctx, chat_id, user, patch)
|
||||
elif intent == "accept_quote":
|
||||
await decisions.start_accept(ctx, chat_id, user, patch)
|
||||
elif intent == "arrive_items":
|
||||
await decisions.start_arrive(ctx, chat_id, user, patch)
|
||||
else:
|
||||
await ctx.send(chat_id, UNKNOWN_HINT)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _gate(ctx: BotContext, chat_id: int, message: dict) -> LinkedUser | None:
|
||||
"""Link or admin gate. Returns None when the message was consumed by linking."""
|
||||
from . import links
|
||||
|
||||
link = await ctx.links.get(chat_id)
|
||||
if link and link.user_id:
|
||||
return LinkedUser(chat_id, link.user_id, link.display_name, link.role, is_admin=False)
|
||||
if chat_id in ctx.admin_chat_ids:
|
||||
return LinkedUser(chat_id, None, None, None, is_admin=True)
|
||||
|
||||
flow = ctx.pending.get(chat_id)
|
||||
if flow and flow.kind == "link_password":
|
||||
await links.finish_link(ctx, chat_id, flow, text=None, password=message.get("text") or "")
|
||||
return None
|
||||
if flow and flow.kind == "link_username":
|
||||
await links.finish_username_step(ctx, chat_id, flow, message.get("text") or "")
|
||||
return None
|
||||
await ctx.send(chat_id, LINK_START)
|
||||
ctx.pending.set(chat_id, "link_username", {})
|
||||
return None
|
||||
|
||||
|
||||
async def _route_to_flow(ctx: BotContext, chat_id: int, flow, text: str) -> bool:
|
||||
from . import links, decisions
|
||||
|
||||
if flow.kind == "link_username":
|
||||
await links.finish_username_step(ctx, chat_id, flow, text)
|
||||
return True
|
||||
if flow.kind == "link_password":
|
||||
await links.finish_link(ctx, chat_id, flow, text=text, password=text)
|
||||
return True
|
||||
if flow.kind == "quote_pick":
|
||||
await decisions.handle_quote_pick_text(ctx, chat_id, flow, text)
|
||||
return True
|
||||
if flow.kind in ("accept", "arrive_qty", "arrive_confirm"):
|
||||
return await decisions.handle_flow_text(ctx, chat_id, flow, text)
|
||||
return False
|
||||
|
||||
|
||||
def _draft_summary(draft) -> str | None:
|
||||
if draft is None:
|
||||
return None
|
||||
import json
|
||||
|
||||
payload = draft.payload or {}
|
||||
if draft.kind == draft_store.KIND_QUOTE:
|
||||
items = ", ".join(
|
||||
f"{it.get('material_name') or '?'}"
|
||||
+ (f" {it.get('quantity')}" if it.get("quantity") else "")
|
||||
+ (f" {it.get('unit_name')}" if it.get("unit_name") else "")
|
||||
for it in payload.get("items") or []
|
||||
)
|
||||
summary = (
|
||||
f"kind=quote; supplier={payload.get('supplier_name') or '?'}; project={payload.get('project_name') or '?'}; "
|
||||
f"items=[{items}]; terms={[t.get('label') for t in payload.get('termsheets') or []]}"
|
||||
)
|
||||
elif draft.kind == draft_store.KIND_SUPPLIER:
|
||||
summary = f"kind=add_supplier; name={payload.get('name') or '?'}; category={payload.get('category_name') or '?'}"
|
||||
elif draft.kind == draft_store.KIND_MATERIAL:
|
||||
summary = f"kind=add_material; name={payload.get('name') or '?'}; unit={payload.get('unit_name') or '?'}"
|
||||
else:
|
||||
summary = json.dumps(payload, ensure_ascii=False)[:500]
|
||||
return summary
|
||||
@@ -0,0 +1,107 @@
|
||||
"""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, 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:
|
||||
link = await ctx.links.get(chat_id)
|
||||
if link and link.user_id:
|
||||
return LinkedUser(chat_id, link.user_id, link.display_name, link.role, is_admin=False)
|
||||
if chat_id in ctx.admin_chat_ids:
|
||||
return LinkedUser(chat_id, None, None, None, is_admin=True)
|
||||
await ctx.send(chat_id, UNLINKED)
|
||||
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
|
||||
|
||||
# ---- 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") and arg in ("ok", "ok2", "no"):
|
||||
kind = {"q": draft_store.KIND_QUOTE, "s": draft_store.KIND_SUPPLIER, "m": draft_store.KIND_MATERIAL}[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)
|
||||
else:
|
||||
await material.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)
|
||||
@@ -0,0 +1,82 @@
|
||||
"""/commands — /start /help /drafts /cancel /whoami /unlink."""
|
||||
import logging
|
||||
|
||||
from app import drafts as draft_store
|
||||
from app.runtime import ROLE_FA
|
||||
from app.texts import CANCELLED, DRAFTS_EMPTY, DRAFTS_LIST_HEADER, HELP, UNLINKED, WELCOME, esc
|
||||
|
||||
logger = logging.getLogger("bot.commands")
|
||||
|
||||
KIND_FA = {"quote": "استعلام", "supplier": "تأمینکننده", "material": "کالا"}
|
||||
STATUS_FA = {"active": "باز", "committed": "ثبتشده", "cancelled": "لغوشده", "expired": "منقضی"}
|
||||
|
||||
|
||||
async def handle_command(ctx, chat_id: int, text: str) -> None:
|
||||
command = text.split()[0].split("@")[0].lower()
|
||||
if command == "/start":
|
||||
await _start(ctx, chat_id)
|
||||
elif command in ("/help", "/راهنما"):
|
||||
await ctx.send(chat_id, HELP)
|
||||
elif command == "/drafts":
|
||||
await _drafts(ctx, chat_id)
|
||||
elif command in ("/cancel", "/لغو"):
|
||||
await _cancel(ctx, chat_id)
|
||||
elif command == "/whoami":
|
||||
await _whoami(ctx, chat_id)
|
||||
elif command == "/unlink":
|
||||
await ctx.links.delete(chat_id)
|
||||
await ctx.send(chat_id, "اتصال قطع شد. برای اتصال دوباره /start بزن.")
|
||||
else:
|
||||
await ctx.send(chat_id, HELP)
|
||||
|
||||
|
||||
async def _start(ctx, chat_id: int) -> None:
|
||||
await ctx.send(chat_id, WELCOME)
|
||||
link = await ctx.links.get(chat_id)
|
||||
if not link or not link.user_id:
|
||||
if chat_id not in ctx.admin_chat_ids:
|
||||
await ctx.send(chat_id, "برای شروع، حساب خود را متصل کن — نام کاربری Casdoor را بفرست:")
|
||||
ctx.pending.set(chat_id, "link_username", {})
|
||||
|
||||
|
||||
async def _drafts(ctx, chat_id: int) -> None:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db import SessionLocal
|
||||
from app.models import BotDraft
|
||||
|
||||
async with SessionLocal() as session:
|
||||
rows = (await session.execute(
|
||||
select(BotDraft)
|
||||
.where(BotDraft.chat_id == chat_id, BotDraft.status == "active")
|
||||
.order_by(BotDraft.updated_at.desc())
|
||||
)).scalars().all()
|
||||
if not rows:
|
||||
await ctx.send(chat_id, DRAFTS_EMPTY)
|
||||
return
|
||||
lines = [DRAFTS_LIST_HEADER]
|
||||
for d in rows:
|
||||
p = d.payload or {}
|
||||
title = p.get("supplier_name") or p.get("name") or "بدون عنوان"
|
||||
lines.append(f"• {KIND_FA.get(d.kind, d.kind)}: {esc(title)}")
|
||||
await ctx.send(chat_id, "\n".join(lines))
|
||||
|
||||
|
||||
async def _cancel(ctx, chat_id: int) -> None:
|
||||
ctx.pending.pop(chat_id)
|
||||
active = await draft_store.get_active(chat_id)
|
||||
if active:
|
||||
await draft_store.set_status(active.id, "cancelled")
|
||||
await ctx.send(chat_id, CANCELLED)
|
||||
|
||||
|
||||
async def _whoami(ctx, chat_id: int) -> None:
|
||||
link = await ctx.links.get(chat_id)
|
||||
if chat_id in ctx.admin_chat_ids:
|
||||
await ctx.send(chat_id, "حالت ادمین (بدون اتصال Casdoor).")
|
||||
return
|
||||
if not link or not link.user_id:
|
||||
await ctx.send(chat_id, UNLINKED)
|
||||
return
|
||||
role = ROLE_FA.get(link.role or "", link.role or "بدون نقش")
|
||||
await ctx.send(chat_id, f"اتصال: {esc(link.display_name)} — {role}")
|
||||
@@ -0,0 +1,389 @@
|
||||
"""Accept / arrive flows — always with an explicit confirmation step."""
|
||||
import logging
|
||||
import re
|
||||
|
||||
from app.api.client import ApiError
|
||||
from app.keyboards import accept_keyboard, arrive_keyboard, quote_pick_keyboard
|
||||
from app.nlu import normalize_digits
|
||||
from app.texts import (
|
||||
ACCEPT_DONE_TS, ACCEPT_REPORT, ARRIVE_REPORT, CANCELLED, ERR_GENERIC,
|
||||
QUOTE_NOT_FOUND, STATUS_FA, esc,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("bot.decisions")
|
||||
|
||||
EXPAND = "supplier,line_items,termsheets"
|
||||
CONFIRM_WORDS = {"تایید", "تأیید", "ثبت", "ثبت کن", "اوکی", "اوکیه", "بله", "yes", "ok"}
|
||||
CANCEL_WORDS = {"لغو", "بی خیال", "بیخیال", "انصراف", "نه", "cancel"}
|
||||
|
||||
|
||||
def _norm(text: str) -> str:
|
||||
return (text or "").strip().lower().replace("\u200c", " ")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# shared: pick a quote by supplier text
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def start_accept(ctx, chat_id, user, patch: dict) -> None:
|
||||
await _start_for_status(ctx, chat_id, user, patch, mode="accept", status="offered")
|
||||
|
||||
|
||||
async def start_arrive(ctx, chat_id, user, patch: dict) -> None:
|
||||
await _start_for_status(ctx, chat_id, user, patch, mode="arrive", status="accepted")
|
||||
|
||||
|
||||
async def _start_for_status(ctx, chat_id, user, patch: dict, mode: str, status: str) -> None:
|
||||
target = (patch.get("target") or {})
|
||||
q = target.get("supplier_name") or target.get("project_name") or target.get("quote_id")
|
||||
try:
|
||||
quotes, _ = await ctx.api.list_quotes(status=status, q=q, expand=EXPAND, page_size=10)
|
||||
except ApiError as exc:
|
||||
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
|
||||
return
|
||||
if not quotes:
|
||||
await ctx.send(chat_id, QUOTE_NOT_FOUND.format(q=esc(q or "?")))
|
||||
return
|
||||
if len(quotes) == 1:
|
||||
if mode == "accept":
|
||||
await begin_accept(ctx, chat_id, user, quotes[0])
|
||||
else:
|
||||
await begin_arrive(ctx, chat_id, user, quotes[0])
|
||||
return
|
||||
pairs = [(_quote_label(qq), str(qq["id"])) for qq in quotes]
|
||||
flow = ctx.pending.set(chat_id, "quote_pick", {"mode": mode, "ids": [pid for _, pid in pairs]})
|
||||
flow.data["user_id"] = user.actor_id
|
||||
message = await ctx.send(chat_id, "چند مورد پیدا شد؛ کدام را میخواهی؟", reply_markup=quote_pick_keyboard(pairs))
|
||||
flow.data["message_id"] = (message or {}).get("message_id")
|
||||
|
||||
|
||||
def _quote_label(quote: dict) -> str:
|
||||
sup = (quote.get("supplier") or {}).get("name") or "—"
|
||||
return f"#{str(quote.get('id'))[:8]} {sup}"
|
||||
|
||||
|
||||
async def apply_quote_pick(ctx, chat_id, user, flow, idx: int) -> None:
|
||||
ids = flow.data.get("ids") or []
|
||||
ctx.pending.pop(chat_id)
|
||||
if idx < 0 or idx >= len(ids):
|
||||
await ctx.send(chat_id, "گزینه نامعتبر است.")
|
||||
return
|
||||
try:
|
||||
quote = await ctx.api.get_quote(ids[idx], expand=EXPAND, actor_id=user.actor_id)
|
||||
except ApiError as exc:
|
||||
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
|
||||
return
|
||||
if flow.data.get("mode") == "arrive":
|
||||
await begin_arrive(ctx, chat_id, user, quote)
|
||||
else:
|
||||
await begin_accept(ctx, chat_id, user, quote)
|
||||
|
||||
|
||||
async def handle_quote_pick_text(ctx, chat_id, flow, text: str) -> None:
|
||||
if _norm(text) in CANCEL_WORDS:
|
||||
ctx.pending.pop(chat_id)
|
||||
await ctx.send(chat_id, CANCELLED)
|
||||
return
|
||||
numbers = re.findall(r"\d+", normalize_digits(text))
|
||||
if numbers:
|
||||
await apply_quote_pick(ctx, chat_id, _flow_user(flow), flow, int(numbers[0]) - 1)
|
||||
else:
|
||||
await ctx.send(chat_id, "شماره مورد را بفرست یا از دکمهها استفاده کن.")
|
||||
|
||||
|
||||
def _flow_user(flow):
|
||||
from app.runtime import LinkedUser
|
||||
return LinkedUser(chat_id=None, user_id=flow.data.get("user_id"), display_name=None,
|
||||
role=None, is_admin=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# accept
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _accept_summary(quote: dict, chosen: int) -> str:
|
||||
lines = [
|
||||
f"✔️ پذیرش استعلام #{esc(str(quote.get('id'))[:8])} — {esc((quote.get('supplier') or {}).get('name') or '—')}",
|
||||
"",
|
||||
"اقلام:",
|
||||
]
|
||||
for li in quote.get("line_items") or []:
|
||||
mat = (li.get("material") or {}).get("name") or li.get("material_id") or "?"
|
||||
unit = ((li.get("unit") or {}).get("name_fa")) or ""
|
||||
price = li.get("unit_price")
|
||||
parts = [f"• {esc(mat)} — مقدار {esc(li.get('quantity'))} {unit}"]
|
||||
if price:
|
||||
parts.append(f"قیمت {esc(price)}")
|
||||
lines.append(" — ".join(parts))
|
||||
lines.append("")
|
||||
lines.append("شرایط پرداخت:")
|
||||
for i, ts in enumerate(quote.get("termsheets") or []):
|
||||
mark = "👉" if i == chosen else " "
|
||||
discount = f" (تخفیف {esc(ts.get('discount_value'))})" if ts.get("discount_value") else ""
|
||||
lines.append(f"{mark} [{i + 1}] {esc(ts.get('label'))}{discount} — {len(ts.get('schedule_steps') or [])} مرحله")
|
||||
lines.append("")
|
||||
lines.append("شرایط را انتخاب کن و پذیرش را تأیید کن.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def begin_accept(ctx, chat_id, user, quote: dict) -> None:
|
||||
termsheets = quote.get("termsheets") or []
|
||||
if not termsheets:
|
||||
await ctx.send(chat_id, "این استعلام شرایط پرداخت ندارد؛ اول در برنامه ثبت کن.")
|
||||
return
|
||||
if not quote.get("line_items"):
|
||||
await ctx.send(chat_id, "این استعلام قلم ندارد.")
|
||||
return
|
||||
flow = ctx.pending.set(chat_id, "accept", {
|
||||
"quote_id": str(quote["id"]),
|
||||
"quote_snapshot": {
|
||||
"id": quote.get("id"),
|
||||
"supplier": quote.get("supplier"),
|
||||
"line_items": quote.get("line_items") or [],
|
||||
},
|
||||
"termsheets": termsheets,
|
||||
"chosen": 0,
|
||||
"user_id": user.actor_id,
|
||||
})
|
||||
text = _accept_summary(quote, 0)
|
||||
message = await ctx.send(chat_id, text, reply_markup=accept_keyboard([ts.get("label") or "?" for ts in termsheets]))
|
||||
flow.data["message_id"] = (message or {}).get("message_id")
|
||||
|
||||
|
||||
async def handle_accept_callback(ctx, chat_id, user, parts) -> None:
|
||||
flow = ctx.pending.get(chat_id)
|
||||
if not flow or flow.kind != "accept":
|
||||
await ctx.send(chat_id, "این عملیات منقضی شده؛ دوباره درخواست بده.")
|
||||
return
|
||||
op = parts[1] if len(parts) > 1 else ""
|
||||
if op == "no":
|
||||
ctx.pending.pop(chat_id)
|
||||
await ctx.send(chat_id, CANCELLED)
|
||||
return
|
||||
if op == "ts" and len(parts) == 3:
|
||||
idx = int(parts[2])
|
||||
if 0 <= idx < len(flow.data["termsheets"]):
|
||||
flow.data["chosen"] = idx
|
||||
await ctx.edit(chat_id, flow.data.get("message_id"),
|
||||
_accept_summary(flow.data["quote_snapshot"], idx),
|
||||
reply_markup=accept_keyboard([ts.get("label") or "?" for ts in flow.data["termsheets"]]))
|
||||
return
|
||||
if op == "ok":
|
||||
await _commit_accept(ctx, chat_id, user, flow)
|
||||
|
||||
|
||||
async def _commit_accept(ctx, chat_id, user, flow) -> None:
|
||||
data = flow.data
|
||||
quote_id = data["quote_id"]
|
||||
termsheets = data["termsheets"]
|
||||
ts_index = data.get("chosen", 0)
|
||||
if not 0 <= ts_index < len(termsheets):
|
||||
await ctx.send(chat_id, "شرایط پرداخت انتخاب نشده است.")
|
||||
return
|
||||
ts = termsheets[ts_index]
|
||||
steps = ts.get("schedule_steps") or []
|
||||
if not steps:
|
||||
await ctx.send(chat_id, "ترمشیت انتخابی مرحله پرداخت ندارد؛ در برنامه تکمیلش کن.")
|
||||
return
|
||||
|
||||
try:
|
||||
quote = await ctx.api.get_quote(quote_id, expand=EXPAND, actor_id=user.actor_id)
|
||||
except ApiError as exc:
|
||||
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
|
||||
return
|
||||
|
||||
decisions_payload = []
|
||||
problems = []
|
||||
for li in quote.get("line_items") or []:
|
||||
decision = li.get("decision") or {}
|
||||
if decision.get("status") == "rejected":
|
||||
continue
|
||||
price = li.get("unit_price")
|
||||
if not price:
|
||||
problems.append(f"قیمت قلم «{(li.get('material') or {}).get('name') or '?'}» ثبت نشده")
|
||||
continue
|
||||
li_projects = li.get("projects") or []
|
||||
project_id = li_projects[0]["id"] if len(li_projects) == 1 else quote.get("project_id")
|
||||
decisions_payload.append({
|
||||
"line_item_id": li["id"],
|
||||
"status": "chosen",
|
||||
"accepted_termsheet_index": 0,
|
||||
"adjusted_unit_price": price,
|
||||
"project_id": project_id,
|
||||
})
|
||||
if problems:
|
||||
await ctx.send(chat_id, "پذیرش ممکن نیست:\n" + "\n".join(f"❌ {esc(p)}" for p in problems))
|
||||
return
|
||||
if not decisions_payload:
|
||||
await ctx.send(chat_id, "قلم قابل پذیرشی نیست (همه رد شدهاند؟).")
|
||||
return
|
||||
|
||||
payload = {
|
||||
"accepted_termsheets": [{
|
||||
"id": ts["id"],
|
||||
"label": ts.get("label"),
|
||||
"discount_type": ts.get("discount_type") or "none",
|
||||
"discount_value": ts.get("discount_value") or 0,
|
||||
"schedule_steps": steps,
|
||||
}],
|
||||
"item_decisions": decisions_payload,
|
||||
}
|
||||
try:
|
||||
result = await ctx.api.accept_quote(quote_id, payload, actor_id=user.actor_id)
|
||||
except ApiError as exc:
|
||||
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
|
||||
return
|
||||
ctx.pending.pop(chat_id)
|
||||
chosen_label = ts.get("label") or ""
|
||||
await ctx.send(chat_id, ACCEPT_REPORT.format(
|
||||
id=esc(str(result.get("id"))[:8]),
|
||||
status=STATUS_FA.get(result.get("status"), result.get("status")),
|
||||
count=len(decisions_payload),
|
||||
) + "\n" + ACCEPT_DONE_TS.format(label=esc(chosen_label)))
|
||||
|
||||
|
||||
async def handle_flow_text(ctx, chat_id, flow, text: str) -> bool:
|
||||
if flow.kind == "accept":
|
||||
norm = _norm(text)
|
||||
if norm in CANCEL_WORDS:
|
||||
ctx.pending.pop(chat_id)
|
||||
await ctx.send(chat_id, CANCELLED)
|
||||
return True
|
||||
if any(word in norm for word in ("تایید", "تأیید", "ثبت", "اوکی", "ok", "yes")):
|
||||
await _commit_accept(ctx, chat_id, _flow_user(flow), flow)
|
||||
return True
|
||||
await ctx.send(chat_id, "برای پذیرش از دکمهها استفاده کن (یا /cancel).")
|
||||
return True
|
||||
if flow.kind == "arrive_qty":
|
||||
await _handle_arrive_quantities(ctx, chat_id, flow, text)
|
||||
return True
|
||||
if flow.kind == "arrive_confirm":
|
||||
norm = _norm(text)
|
||||
if any(word in norm for word in ("تایید", "تأیید", "ثبت", "اوکی", "ok", "yes")):
|
||||
await _commit_arrive(ctx, chat_id, _flow_user(flow), flow)
|
||||
return True
|
||||
if norm in CANCEL_WORDS:
|
||||
ctx.pending.pop(chat_id)
|
||||
await ctx.send(chat_id, CANCELLED)
|
||||
return True
|
||||
await ctx.send(chat_id, "با دکمهها تأیید یا لغو کن (یا /cancel).")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# arrive (partial deliveries)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _arrive_summary(items: list[dict], quantities: dict[str, float] | None = None) -> str:
|
||||
lines = ["🚚 اعلام رسیدن اقلام", ""]
|
||||
for li in items:
|
||||
mat = (li.get("material") or {}).get("name") or "?"
|
||||
ordered = li.get("quantity") or 0
|
||||
received = ((li.get("receipt") or {}).get("received_qty")) or 0
|
||||
unit = ((li.get("unit") or {}).get("name_fa")) or ""
|
||||
got = ""
|
||||
if quantities and li["id"] in quantities:
|
||||
got = f" → الان: {esc(quantities[li['id']])} {unit}"
|
||||
lines.append(f"• {esc(mat)} — سفارش {esc(ordered)} {unit} — تاکنون {esc(received)}{got}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def begin_arrive(ctx, chat_id, user, quote: dict) -> None:
|
||||
items = [li for li in quote.get("line_items") or [] if (li.get("decision") or {}).get("status") == "chosen"]
|
||||
if not items:
|
||||
await ctx.send(chat_id, "قلم پذیرفتهشدهای برای تحویل نیست.")
|
||||
return
|
||||
flow = ctx.pending.set(chat_id, "arrive_qty", {
|
||||
"quote_id": str(quote["id"]),
|
||||
"items": [{"id": li["id"], "name": (li.get("material") or {}).get("name") or "?",
|
||||
"ordered": li.get("quantity"), "unit": ((li.get("unit") or {}).get("name_fa")) or "",
|
||||
"received": ((li.get("receipt") or {}).get("received_qty")) or 0} for li in items],
|
||||
"user_id": user.actor_id,
|
||||
})
|
||||
text = (
|
||||
f"استعلام #{esc(str(quote.get('id'))[:8])} — {esc((quote.get('supplier') or {}).get('name') or '—')}\n"
|
||||
+ _arrive_summary(quote.get("line_items") or [])
|
||||
+ "\n\nمقادیر رسیده را به ترتیب اقلام بفرست (مثلاً: «500 و 200»)، یا «تحویل کامل همه» را بزن."
|
||||
)
|
||||
message = await ctx.send(chat_id, text, reply_markup=arrive_keyboard())
|
||||
flow.data["message_id"] = (message or {}).get("message_id")
|
||||
|
||||
|
||||
async def _handle_arrive_quantities(ctx, chat_id, flow, text: str) -> None:
|
||||
numbers = [float(n) for n in re.findall(r"\d+(?:\.\d+)?", normalize_digits(text))]
|
||||
items = flow.data["items"]
|
||||
if not numbers:
|
||||
await ctx.send(chat_id, "مقادیر را عددی بفرست، مثل «500 و 200»، یا از دکمهها استفاده کن.")
|
||||
return
|
||||
if len(numbers) == 1 and len(items) > 1:
|
||||
await ctx.send(chat_id, f"{len(items)} قلم است؛ برای هر قلم یک عدد بفرست (به ترتیب).")
|
||||
return
|
||||
if len(numbers) != len(items):
|
||||
await ctx.send(chat_id, "تعداد اعداد با تعداد اقلام نمیخواند؛ دوباره بفرست.")
|
||||
return
|
||||
quantities = {item["id"]: qty for item, qty in zip(items, numbers)}
|
||||
flow.kind = "arrive_confirm"
|
||||
flow.data["quantities"] = quantities
|
||||
await ctx.edit(chat_id, flow.data.get("message_id"),
|
||||
_arrive_summary(_items_with_details(flow.data["items"]), quantities)
|
||||
+ "\n\nتأیید میکنی؟",
|
||||
reply_markup=arrive_keyboard())
|
||||
|
||||
|
||||
def _items_with_details(items: list[dict]) -> list[dict]:
|
||||
return [{
|
||||
"id": it["id"],
|
||||
"material": {"name": it["name"]},
|
||||
"quantity": it["ordered"],
|
||||
"unit": {"name_fa": it["unit"]},
|
||||
"receipt": {"received_qty": it["received"]},
|
||||
} for it in items]
|
||||
|
||||
|
||||
async def handle_arrive_callback(ctx, chat_id, user, parts) -> None:
|
||||
flow = ctx.pending.get(chat_id)
|
||||
if not flow or not flow.kind.startswith("arrive"):
|
||||
await ctx.send(chat_id, "این عملیات منقضی شده؛ دوباره درخواست بده.")
|
||||
return
|
||||
op = parts[1] if len(parts) > 1 else ""
|
||||
if op == "no":
|
||||
ctx.pending.pop(chat_id)
|
||||
await ctx.send(chat_id, CANCELLED)
|
||||
return
|
||||
if op == "full":
|
||||
quantities = {item["id"]: max(float(item["ordered"] or 0) - float(item["received"] or 0), 0) for item in flow.data["items"]}
|
||||
if any(v <= 0 for v in quantities.values()):
|
||||
await ctx.send(chat_id, "برای بعضی اقلام چیزی برای تحویل نمانده؛ مقادیر را دستی بفرست.")
|
||||
return
|
||||
flow.kind = "arrive_confirm"
|
||||
flow.data["quantities"] = quantities
|
||||
await ctx.edit(chat_id, flow.data.get("message_id"),
|
||||
_arrive_summary(_items_with_details(flow.data["items"]), quantities)
|
||||
+ "\n\nتأیید میکنی؟",
|
||||
reply_markup=arrive_keyboard())
|
||||
return
|
||||
if op == "ok":
|
||||
if flow.kind != "arrive_confirm" or not flow.data.get("quantities"):
|
||||
await ctx.send(chat_id, "اول مقادیر رسیده را بفرست یا «تحویل کامل همه» را بزن.")
|
||||
return
|
||||
await _commit_arrive(ctx, chat_id, user, flow)
|
||||
|
||||
|
||||
async def _commit_arrive(ctx, chat_id, user, flow) -> None:
|
||||
quantities: dict = flow.data["quantities"]
|
||||
payload = {"items": [{"line_item_id": li_id, "received_qty": qty} for li_id, qty in quantities.items()]}
|
||||
try:
|
||||
result = await ctx.api.arrive_items(flow.data["quote_id"], payload, actor_id=user.actor_id)
|
||||
except ApiError as exc:
|
||||
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
|
||||
return
|
||||
ctx.pending.pop(chat_id)
|
||||
lines = []
|
||||
for item in result.get("items") or []:
|
||||
lines.append(f"• قلم {esc(str(item.get('line_item_id'))[:8])} — {esc(item.get('received_qty'))} ثبت شد")
|
||||
await ctx.send(chat_id, ARRIVE_REPORT.format(
|
||||
id=esc(str(result.get("id"))[:8]),
|
||||
status=STATUS_FA.get(result.get("status"), result.get("status")),
|
||||
lines="\n".join(lines) or "—",
|
||||
))
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Casdoor linking: chat ↔ ERP user (validated through the real login endpoint)."""
|
||||
import logging
|
||||
|
||||
from app.api.client import ApiError
|
||||
from app.runtime import ROLE_FA
|
||||
from app.texts import LINK_ASK_PASSWORD, LINK_FAIL, LINK_OK, esc
|
||||
|
||||
logger = logging.getLogger("bot.links")
|
||||
|
||||
|
||||
async def finish_username_step(ctx, chat_id: int, flow, text: str) -> None:
|
||||
username = (text or "").strip()
|
||||
if not username or username.startswith("/"):
|
||||
ctx.pending.pop(chat_id)
|
||||
return
|
||||
flow.kind = "link_password"
|
||||
flow.data["username"] = username
|
||||
await ctx.send(chat_id, LINK_ASK_PASSWORD)
|
||||
|
||||
|
||||
async def finish_link(ctx, chat_id: int, flow, text: str | None, password: str) -> None:
|
||||
username = flow.data.get("username") or ""
|
||||
ctx.pending.pop(chat_id)
|
||||
if not password or password.startswith("/"):
|
||||
return
|
||||
try:
|
||||
data = await ctx.api.login(username, password)
|
||||
except ApiError as exc:
|
||||
await ctx.send(chat_id, LINK_FAIL.format(detail=esc(exc.message)))
|
||||
return
|
||||
except Exception as exc: # network etc.
|
||||
logger.exception("login failed")
|
||||
await ctx.send(chat_id, LINK_FAIL.format(detail=esc(str(exc)[:200])))
|
||||
return
|
||||
|
||||
user = data.get("user") or {}
|
||||
display = " ".join(x for x in [user.get("first_name"), user.get("last_name")] if x) or username
|
||||
await ctx.links.upsert(
|
||||
chat_id=chat_id,
|
||||
user_id=user.get("id"),
|
||||
username=username,
|
||||
display_name=display,
|
||||
role=user.get("role"),
|
||||
)
|
||||
role_fa = ROLE_FA.get(user.get("role") or "", user.get("role") or "بدون نقش")
|
||||
await ctx.send(chat_id, LINK_OK.format(name=esc(display), role=role_fa))
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Add-material flow: name + canonical unit (required)."""
|
||||
import logging
|
||||
|
||||
from app import drafts as draft_store
|
||||
from app.api.client import ApiError
|
||||
from app.drafts import (
|
||||
KIND_MATERIAL, merge_material_patch, material_missing, new_material_payload,
|
||||
)
|
||||
from app.keyboards import pick_buttons, simple_review_keyboard
|
||||
from app.resolve import safe_resolve
|
||||
from app.texts import ERR_GENERIC, esc, material_committed_report, material_review_text
|
||||
|
||||
logger = logging.getLogger("bot.material")
|
||||
|
||||
|
||||
async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None) -> None:
|
||||
draft = await draft_store.get_active(chat_id, KIND_MATERIAL)
|
||||
if draft is not None:
|
||||
payload = draft.payload
|
||||
merge_material_patch(payload, patch)
|
||||
else:
|
||||
payload = new_material_payload()
|
||||
merge_material_patch(payload, patch)
|
||||
draft = await draft_store.create(chat_id, KIND_MATERIAL, payload, source=source)
|
||||
await resolve_payload(ctx, payload)
|
||||
await draft_store.save_payload(draft, missing={"fields": material_missing(payload)})
|
||||
await render(ctx, chat_id, draft, fresh=draft.review_message_id is None)
|
||||
|
||||
|
||||
async def resolve_payload(ctx, payload: dict) -> None:
|
||||
options = payload.setdefault("options", {})
|
||||
if not payload.get("canonical_unit_id") and payload.get("unit_name"):
|
||||
res = await safe_resolve(ctx.resolver, "unit", payload["unit_name"])
|
||||
if res.resolved:
|
||||
payload["canonical_unit_id"] = res.candidates[0].id
|
||||
payload["unit_name"] = res.candidates[0].name
|
||||
options.pop("unit", None)
|
||||
elif res.candidates:
|
||||
options["unit"] = [{"id": c.id, "name": c.name} for c in res.candidates]
|
||||
else:
|
||||
options.pop("unit", None)
|
||||
|
||||
|
||||
async def apply_pick(ctx, chat_id, user, draft, field: str, choice: dict) -> None:
|
||||
if field == "unit":
|
||||
draft.payload["canonical_unit_id"] = choice["id"]
|
||||
draft.payload["unit_name"] = choice["name"]
|
||||
draft.payload.setdefault("options", {}).pop("unit", None)
|
||||
await draft_store.save_payload(draft, missing={"fields": material_missing(draft.payload)})
|
||||
await render(ctx, chat_id, draft, fresh=False)
|
||||
|
||||
|
||||
def _keyboard(payload: dict, draft_id: str) -> dict:
|
||||
rows: list[list[dict]] = []
|
||||
options = (payload.get("options") or {}).get("unit") or []
|
||||
if options:
|
||||
rows += pick_buttons(draft_id, "unit", [o["name"] for o in options[:6]])
|
||||
return simple_review_keyboard(draft_id, KIND_MATERIAL, rows)
|
||||
|
||||
|
||||
async def render(ctx, chat_id, draft, fresh: bool = True) -> None:
|
||||
payload = draft.payload
|
||||
missing = material_missing(payload)
|
||||
text = material_review_text(payload, missing)
|
||||
keyboard = _keyboard(payload, draft.id)
|
||||
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)
|
||||
else:
|
||||
await ctx.edit(chat_id, draft.review_message_id, text, reply_markup=keyboard)
|
||||
await draft_store.save_payload(draft, missing={"fields": missing})
|
||||
|
||||
|
||||
async def commit(ctx, chat_id, user, draft) -> None:
|
||||
payload = draft.payload
|
||||
await resolve_payload(ctx, payload)
|
||||
missing = {m["key"]: m["ok"] for m in material_missing(payload)}
|
||||
problems = [label for key, label, ok in [
|
||||
("name", "نام کالا", missing.get("name")),
|
||||
("unit", "واحد اصلی", missing.get("unit")),
|
||||
] if not ok]
|
||||
if problems:
|
||||
await ctx.send(chat_id, "ثبت ممکن نیست؛ اول اینها را مشخص کن:\n" +
|
||||
"\n".join(f"❌ {esc(p)}" for p in problems))
|
||||
return
|
||||
body = {"name": payload["name"], "canonical_unit_id": int(payload["canonical_unit_id"])}
|
||||
if payload.get("topic"):
|
||||
body["topic"] = payload["topic"]
|
||||
try:
|
||||
created = await ctx.api.create_material(body, actor_id=user.actor_id)
|
||||
except ApiError as exc:
|
||||
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
|
||||
return
|
||||
await draft_store.set_status(draft.id, "committed")
|
||||
ctx.resolver.invalidate("materials")
|
||||
await ctx.send(chat_id, material_committed_report(created))
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Status queries: «استعلامهای در انتظار؟»، «کدامها منقضی میشوند؟» …"""
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
|
||||
from app.api.client import ApiError
|
||||
from app.texts import ERR_GENERIC, status_report, esc
|
||||
|
||||
logger = logging.getLogger("bot.queries")
|
||||
|
||||
EXPAND = "supplier,line_items"
|
||||
|
||||
|
||||
async def handle_query(ctx, chat_id, user, patch: dict) -> None:
|
||||
query = patch.get("query") or {}
|
||||
kind = query.get("kind") or "pending"
|
||||
supplier_name = query.get("supplier_name")
|
||||
q = supplier_name or query.get("project_name")
|
||||
|
||||
try:
|
||||
if kind == "expiring":
|
||||
quotes, _ = await ctx.api.list_quotes(status="offered", expand=EXPAND,
|
||||
q=q, sort="-quote_date", page_size=100)
|
||||
deadline = date.today() + timedelta(days=query.get("days") or 7)
|
||||
soon = []
|
||||
for quote in quotes:
|
||||
expiry = (quote.get("expiry_date") or "")[:10]
|
||||
if expiry and expiry <= deadline.isoformat():
|
||||
soon.append(quote)
|
||||
soon.sort(key=lambda x: x.get("expiry_date") or "9999")
|
||||
await ctx.send(chat_id, status_report("expiring", soon[:10]))
|
||||
return
|
||||
|
||||
if kind in ("accepted", "arrived", "pending", "offered"):
|
||||
status = "offered" if kind == "pending" else kind
|
||||
quotes, _ = await ctx.api.list_quotes(status=status, expand=EXPAND, q=q,
|
||||
sort="-quote_date", page_size=10)
|
||||
await ctx.send(chat_id, status_report("pending" if kind == "offered" else kind, quotes))
|
||||
return
|
||||
|
||||
# recent / anything else
|
||||
quotes, meta = await ctx.api.list_quotes(expand=EXPAND, q=q, sort="-quote_date", page_size=5)
|
||||
await ctx.send(chat_id, status_report("recent", quotes))
|
||||
except ApiError as exc:
|
||||
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
|
||||
@@ -0,0 +1,349 @@
|
||||
"""Register-quote flow: entities → draft → resolve → review (+missing) → commit."""
|
||||
import logging
|
||||
|
||||
from app import drafts as draft_store
|
||||
from app.api.client import ApiError
|
||||
from app.drafts import (
|
||||
KIND_QUOTE, build_quote_request, compute_quote_missing, merge_quote_patch,
|
||||
new_quote_payload, quote_ready,
|
||||
)
|
||||
from app.keyboards import quote_review_keyboard
|
||||
from app.resolve import safe_resolve
|
||||
from app.texts import ERR_GENERIC, quote_committed_report, quote_review_text, esc
|
||||
|
||||
logger = logging.getLogger("bot.quote")
|
||||
|
||||
FIELD_FA = {"sup": "تأمینکننده", "prj": "پروژه"}
|
||||
|
||||
|
||||
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
|
||||
draft.review_message_id = None
|
||||
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, source_message_id=incoming_msg_id
|
||||
)
|
||||
|
||||
await resolve_payload(ctx, payload)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def resolve_payload(ctx, payload: dict) -> None:
|
||||
options = payload.setdefault("options", {})
|
||||
|
||||
if not payload.get("supplier_id") and payload.get("supplier_name"):
|
||||
res = await safe_resolve(ctx.resolver, "supplier", payload["supplier_name"])
|
||||
if res.resolved:
|
||||
c = res.candidates[0]
|
||||
payload["supplier_id"] = c.id
|
||||
payload["supplier_name"] = c.name
|
||||
extra = c.extra or {}
|
||||
for k in ("location", "phone", "contact_person", "category_name"):
|
||||
if extra.get(k):
|
||||
payload[f"supplier_{k}"] = extra[k]
|
||||
options.pop("sup", None)
|
||||
elif res.candidates:
|
||||
options["sup"] = [{"id": c.id, "name": c.name} for c in res.candidates]
|
||||
else:
|
||||
options.pop("sup", None)
|
||||
|
||||
if not payload.get("project_id") and payload.get("project_name"):
|
||||
res = await safe_resolve(ctx.resolver, "project", payload["project_name"])
|
||||
if res.resolved:
|
||||
payload["project_id"] = res.candidates[0].id
|
||||
payload["project_name"] = res.candidates[0].name
|
||||
if payload["project_id"] not in payload.get("project_ids", []):
|
||||
payload.setdefault("project_ids", []).append(payload["project_id"])
|
||||
options.pop("prj", None)
|
||||
elif res.candidates:
|
||||
options["prj"] = [{"id": c.id, "name": c.name} for c in res.candidates]
|
||||
else:
|
||||
options.pop("prj", None)
|
||||
|
||||
for p_name in payload.get("project_names") or []:
|
||||
res = await safe_resolve(ctx.resolver, "project", p_name)
|
||||
if res.resolved:
|
||||
p_id = res.candidates[0].id
|
||||
if p_id not in payload.get("project_ids", []):
|
||||
payload.setdefault("project_ids", []).append(p_id)
|
||||
if not payload.get("project_id"):
|
||||
payload["project_id"] = p_id
|
||||
|
||||
for i, item in enumerate(payload.get("items") or [], start=1):
|
||||
if not item.get("material_id") and item.get("material_name"):
|
||||
res = await safe_resolve(ctx.resolver, "material", item["material_name"])
|
||||
if res.resolved:
|
||||
_set_material(item, res.candidates[0].id, res.candidates[0].name, res.candidates[0].extra)
|
||||
options.pop(f"mat{i}", None)
|
||||
elif res.candidates:
|
||||
options[f"mat{i}"] = [{"id": c.id, "name": c.name} for c in res.candidates]
|
||||
else:
|
||||
options.pop(f"mat{i}", None)
|
||||
if not item.get("unit_id"):
|
||||
await _resolve_unit(ctx, item)
|
||||
|
||||
|
||||
def _set_material(item: dict, material_id: str, name: str, extra: dict | None = None) -> None:
|
||||
item["material_id"] = material_id
|
||||
item["material_row"] = {"id": material_id, "canonical_unit_id": (extra or {}).get("canonical_unit_id")}
|
||||
|
||||
|
||||
async def _resolve_unit(ctx, item: dict) -> None:
|
||||
if item.get("unit_id"):
|
||||
return
|
||||
if item.get("unit_name"):
|
||||
res = await safe_resolve(ctx.resolver, "unit", item["unit_name"])
|
||||
if res.resolved:
|
||||
item["unit_id"] = res.candidates[0].id
|
||||
item["unit_explicit"] = True
|
||||
item["unit_name"] = res.candidates[0].name
|
||||
return
|
||||
canonical = ((item.get("material_row") or {}).get("canonical_unit_id"))
|
||||
if canonical:
|
||||
item["unit_id"] = str(canonical)
|
||||
item["unit_explicit"] = False
|
||||
|
||||
|
||||
async def apply_pick(ctx, chat_id, user, draft, field: str, choice: dict) -> None:
|
||||
payload = draft.payload
|
||||
options = payload.setdefault("options", {})
|
||||
options.pop(field, None)
|
||||
|
||||
if field == "sup":
|
||||
payload["supplier_id"] = choice["id"]
|
||||
payload["supplier_name"] = choice["name"]
|
||||
elif field == "prj":
|
||||
payload["project_id"] = choice["id"]
|
||||
payload["project_name"] = choice["name"]
|
||||
elif field.startswith("mat"):
|
||||
index = int(field[3:]) - 1
|
||||
items = payload.get("items") or []
|
||||
if 0 <= index < len(items):
|
||||
items[index]["material_name"] = choice["name"]
|
||||
_set_material(items[index], choice["id"], choice["name"])
|
||||
await _resolve_unit(ctx, items[index])
|
||||
elif field.startswith("unit"):
|
||||
index = int(field[4:]) - 1
|
||||
items = payload.get("items") or []
|
||||
if 0 <= index < len(items):
|
||||
items[index]["unit_id"] = choice["id"]
|
||||
items[index]["unit_explicit"] = True
|
||||
items[index]["unit_name"] = choice["name"]
|
||||
|
||||
await resolve_payload(ctx, payload)
|
||||
await draft_store.save_payload(draft, missing={"fields": compute_quote_missing(payload)})
|
||||
await render(ctx, chat_id, draft, fresh=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# rendering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _pick_rows(payload: dict) -> list[list[str]]:
|
||||
"""Field labels shown as pick-button rows (via keyboards.pick_buttons)."""
|
||||
rows: list[list[str]] = []
|
||||
options = payload.get("options") or {}
|
||||
if options.get("sup"):
|
||||
rows.append(["sup"] + [o["name"] for o in options["sup"]])
|
||||
if options.get("prj"):
|
||||
rows.append(["prj"] + [o["name"] for o in options["prj"]])
|
||||
for i, item in enumerate(payload.get("items") or [], start=1):
|
||||
if options.get(f"mat{i}"):
|
||||
rows.append([f"mat{i}"] + [o["name"] for o in options[f"mat{i}"]])
|
||||
if options.get(f"unit{i}"):
|
||||
rows.append([f"unit{i}"] + [o["name"] for o in options[f"unit{i}"]])
|
||||
return rows
|
||||
|
||||
|
||||
def _keyboard(payload: dict, draft_id: str, wizard_url: str | None = None) -> dict:
|
||||
from app.keyboards import pick_buttons
|
||||
|
||||
prefix = str(draft_id)[:8]
|
||||
pick_rows: list[list[dict]] = []
|
||||
for row in _pick_rows(payload):
|
||||
field, names = row[0], row[1:]
|
||||
pick_rows += pick_buttons(prefix, field, names[:6])
|
||||
ready_items = sum(
|
||||
1 for it in payload.get("items") or []
|
||||
if it.get("material_id") and it.get("quantity") and it.get("unit_id")
|
||||
)
|
||||
has_partial = 0 < ready_items < len(payload.get("items") or [])
|
||||
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,
|
||||
reply_to: int | None = None) -> None:
|
||||
from app.config import settings
|
||||
|
||||
from app.security import create_draft_token
|
||||
|
||||
payload = draft.payload
|
||||
missing = compute_quote_missing(payload)
|
||||
token = create_draft_token(
|
||||
draft.id,
|
||||
chat_id=chat_id,
|
||||
secret=settings.DRAFT_TOKEN_SECRET,
|
||||
expires_in=settings.DRAFT_TOKEN_EXPIRE_SECONDS,
|
||||
)
|
||||
wizard_url = f"{settings.FRONTEND_URL.rstrip('/')}/review?token={token}"
|
||||
text = quote_review_text(payload, missing, status_note=note)
|
||||
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, 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,
|
||||
mark_bale_synced=True,
|
||||
)
|
||||
else:
|
||||
await draft_store.save_payload(
|
||||
draft,
|
||||
missing={"fields": missing},
|
||||
source_message_id=reply_to or draft.source_message_id,
|
||||
mark_bale_synced=True,
|
||||
)
|
||||
else:
|
||||
if not draft.review_message_id:
|
||||
return
|
||||
success = await ctx.edit(chat_id, draft.review_message_id, text, reply_markup=keyboard)
|
||||
if success:
|
||||
await draft_store.save_payload(draft, missing={"fields": missing}, mark_bale_synced=True)
|
||||
else:
|
||||
logger.warning("Could not edit review message %s for draft %s", draft.review_message_id, draft.id)
|
||||
await draft_store.set_bale_updated_at(draft.id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# commit
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def commit(ctx, chat_id, user, draft, only_resolved: bool = False) -> None:
|
||||
payload = draft.payload
|
||||
await resolve_payload(ctx, payload)
|
||||
|
||||
# 1. Sequential pre-creation for new supplier if user wants to commit
|
||||
if not payload.get("supplier_id") and payload.get("supplier_name"):
|
||||
cat_id = payload.get("supplier_category_id")
|
||||
if not cat_id:
|
||||
cats = await ctx.resolver.categories()
|
||||
if cats:
|
||||
cat_id = cats[0].id
|
||||
if cat_id:
|
||||
try:
|
||||
sup_body = {
|
||||
"name": payload["supplier_name"],
|
||||
"category_id": cat_id,
|
||||
}
|
||||
if payload.get("supplier_phone"):
|
||||
sup_body["phone"] = payload["supplier_phone"]
|
||||
created_sup = await ctx.api.create_supplier(sup_body, actor_id=user.actor_id)
|
||||
payload["supplier_id"] = created_sup["id"]
|
||||
ctx.resolver.invalidate("suppliers")
|
||||
except Exception as e:
|
||||
logger.warning("Could not auto-create supplier: %s", e)
|
||||
|
||||
# 2. Sequential pre-creation for new materials
|
||||
for i, item in enumerate(payload.get("items") or [], start=1):
|
||||
if not item.get("material_id") and item.get("material_name") and item.get("unit_id"):
|
||||
try:
|
||||
created_mat = await ctx.api.create_material({
|
||||
"name": item["material_name"],
|
||||
"canonical_unit_id": int(item["unit_id"]),
|
||||
}, actor_id=user.actor_id)
|
||||
_set_material(item, created_mat["id"], created_mat.get("name") or item["material_name"])
|
||||
ctx.resolver.invalidate("materials")
|
||||
except Exception as e:
|
||||
logger.warning("Could not auto-create material: %s", e)
|
||||
|
||||
ready, blockers = quote_ready(payload)
|
||||
body, problems = build_quote_request(payload, only_resolved_items=only_resolved)
|
||||
if body is None:
|
||||
lines = ["ثبت ممکن نیست؛ اینها را اول مشخص کن:"] + [f"❌ {esc(p)}" for p in problems]
|
||||
await ctx.send(chat_id, "\n".join(lines))
|
||||
return
|
||||
if not ready and not only_resolved:
|
||||
lines = ["برای ثبت کامل، اول اینها را مشخص کن:"] + [f"❌ {esc(b)}" for b in blockers]
|
||||
lines += ["", "یا دکمه «ثبت فقط اقلام مشخص» را بزن."]
|
||||
await ctx.send(chat_id, "\n".join(lines))
|
||||
return
|
||||
|
||||
try:
|
||||
created = await ctx.api.create_quote(body, actor_id=user.actor_id)
|
||||
except ApiError as exc:
|
||||
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
|
||||
return
|
||||
except Exception:
|
||||
logger.exception("create_quote failed")
|
||||
await ctx.send(chat_id, ERR_GENERIC.format(detail="خطای شبکه"))
|
||||
return
|
||||
|
||||
await draft_store.set_status(draft.id, "committed")
|
||||
ctx.pending.pop(chat_id, None)
|
||||
ctx.resolver.invalidate()
|
||||
|
||||
quote_id = (created or {}).get("id") or ""
|
||||
# If a proforma file was attached, persist it to the created ERP quote
|
||||
proforma_info = payload.get("proforma")
|
||||
if quote_id and proforma_info and proforma_info.get("saved_path"):
|
||||
try:
|
||||
from pathlib import Path
|
||||
p_path = Path(proforma_info["saved_path"])
|
||||
if p_path.exists():
|
||||
file_bytes = p_path.read_bytes()
|
||||
await ctx.api.upload_quote_file(
|
||||
quote_id,
|
||||
"proforma",
|
||||
proforma_info.get("file_name") or "proforma.pdf",
|
||||
file_bytes,
|
||||
mime_type=proforma_info.get("mime_type") or "application/pdf",
|
||||
actor_id=user.actor_id,
|
||||
)
|
||||
logger.info("Successfully uploaded proforma to quote %s", quote_id)
|
||||
except Exception as e:
|
||||
logger.warning("Could not upload draft proforma to quote %s: %s", quote_id, e)
|
||||
|
||||
if draft.review_message_id:
|
||||
try:
|
||||
msg = f"✅ این پیشنهاد در سیستم با موفقیت ثبت نهایی شد.\nشناسه پیشنهاد: {quote_id}"
|
||||
await ctx.edit(chat_id, draft.review_message_id, msg, reply_markup=None)
|
||||
await draft_store.set_bale_updated_at(draft.id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
remaining = [
|
||||
m["label"] for m in compute_quote_missing(payload)
|
||||
if not m["blocking"] and not m["ok"]
|
||||
]
|
||||
dropped = len(payload.get("items") or []) - len(body.get("line_items") or [])
|
||||
if dropped > 0:
|
||||
remaining.append(f"{dropped} قلم ناشناس حذف شد")
|
||||
await ctx.send(chat_id, quote_committed_report(created, payload, remaining))
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Add-supplier flow: name + one of the 8 fixed Persian categories (required)."""
|
||||
import logging
|
||||
|
||||
from app import drafts as draft_store
|
||||
from app.api.client import ApiError
|
||||
from app.drafts import (
|
||||
KIND_SUPPLIER, merge_supplier_patch, new_supplier_payload, supplier_missing,
|
||||
)
|
||||
from app.keyboards import pick_buttons, simple_review_keyboard
|
||||
from app.resolve import safe_resolve
|
||||
from app.texts import ERR_GENERIC, esc, supplier_committed_report, supplier_review_text
|
||||
|
||||
logger = logging.getLogger("bot.supplier")
|
||||
|
||||
|
||||
async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None) -> None:
|
||||
draft = await draft_store.get_active(chat_id, KIND_SUPPLIER)
|
||||
if draft is not None:
|
||||
payload = draft.payload
|
||||
merge_supplier_patch(payload, patch)
|
||||
else:
|
||||
payload = new_supplier_payload()
|
||||
merge_supplier_patch(payload, patch)
|
||||
draft = await draft_store.create(chat_id, KIND_SUPPLIER, payload, source=source)
|
||||
await resolve_payload(ctx, payload)
|
||||
await draft_store.save_payload(draft, missing={"fields": supplier_missing(payload)})
|
||||
await render(ctx, chat_id, draft, fresh=draft.review_message_id is None)
|
||||
|
||||
|
||||
async def resolve_payload(ctx, payload: dict) -> None:
|
||||
options = payload.setdefault("options", {})
|
||||
if not payload.get("category_id") and payload.get("category_name"):
|
||||
res = await safe_resolve(ctx.resolver, "category", payload["category_name"])
|
||||
if res.resolved:
|
||||
payload["category_id"] = res.candidates[0].id
|
||||
payload["category_name"] = res.candidates[0].name
|
||||
options.pop("cat", None)
|
||||
elif res.candidates:
|
||||
options["cat"] = [{"id": c.id, "name": c.name} for c in res.candidates]
|
||||
else:
|
||||
options.pop("cat", None)
|
||||
|
||||
|
||||
async def apply_pick(ctx, chat_id, user, draft, field: str, choice: dict) -> None:
|
||||
if field == "cat":
|
||||
draft.payload["category_id"] = choice["id"]
|
||||
draft.payload["category_name"] = choice["name"]
|
||||
draft.payload.setdefault("options", {}).pop("cat", None)
|
||||
await draft_store.save_payload(draft, missing={"fields": supplier_missing(draft.payload)})
|
||||
await render(ctx, chat_id, draft, fresh=False)
|
||||
|
||||
|
||||
def _keyboard(payload: dict, draft_id: str) -> dict:
|
||||
rows: list[list[dict]] = []
|
||||
options = (payload.get("options") or {}).get("cat") or []
|
||||
if options:
|
||||
rows += pick_buttons(draft_id, "cat", [o["name"] for o in options[:6]])
|
||||
return simple_review_keyboard(draft_id, KIND_SUPPLIER, rows)
|
||||
|
||||
|
||||
async def render(ctx, chat_id, draft, fresh: bool = True) -> None:
|
||||
payload = draft.payload
|
||||
missing = supplier_missing(payload)
|
||||
text = supplier_review_text(payload, missing)
|
||||
keyboard = _keyboard(payload, draft.id)
|
||||
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)
|
||||
else:
|
||||
await ctx.edit(chat_id, draft.review_message_id, text, reply_markup=keyboard)
|
||||
await draft_store.save_payload(draft, missing={"fields": missing})
|
||||
|
||||
|
||||
async def commit(ctx, chat_id, user, draft) -> None:
|
||||
payload = draft.payload
|
||||
await resolve_payload(ctx, payload)
|
||||
missing = {m["key"]: m["ok"] for m in supplier_missing(payload)}
|
||||
problems = [label for key, label, ok in [
|
||||
("name", "نام تأمینکننده", missing.get("name")),
|
||||
("category", "دستهبندی", missing.get("category")),
|
||||
] if not ok]
|
||||
if problems:
|
||||
await ctx.send(chat_id, "ثبت ممکن نیست؛ اول اینها را مشخص کن:\n" +
|
||||
"\n".join(f"❌ {esc(p)}" for p in problems))
|
||||
return
|
||||
body = {
|
||||
"name": payload["name"],
|
||||
"category_id": payload["category_id"],
|
||||
"phone": payload.get("phone"),
|
||||
"location": payload.get("location"),
|
||||
"contact_person": payload.get("contact_person"),
|
||||
}
|
||||
body = {k: v for k, v in body.items() if v}
|
||||
try:
|
||||
created = await ctx.api.create_supplier(body, actor_id=user.actor_id)
|
||||
except ApiError as exc:
|
||||
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
|
||||
return
|
||||
await draft_store.set_status(draft.id, "committed")
|
||||
ctx.resolver.invalidate("suppliers")
|
||||
await ctx.send(chat_id, supplier_committed_report(created))
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Inline keyboard builders.
|
||||
|
||||
Bale keeps Telegram's 64-byte ``callback_data`` cap, so every callback is a
|
||||
short op-code plus the 8-char draft prefix; the full state lives in bot_drafts
|
||||
(or the in-memory pending-flow map) and is looked up on tap.
|
||||
"""
|
||||
|
||||
|
||||
def _kb(rows: list[list[dict]]) -> dict:
|
||||
return {"inline_keyboard": rows}
|
||||
|
||||
|
||||
def _btn(text: str, data: str) -> dict:
|
||||
return {"text": text, "callback_data": data}
|
||||
|
||||
|
||||
# callback scheme (≤64 bytes):
|
||||
# q:ok:{d8} / q:no:{d8} / q:ok2:{d8} quote commit / cancel / commit-resolved-only
|
||||
# s:ok:{d8} / s:no:{d8} supplier
|
||||
# m:ok:{d8} / m:no:{d8} material
|
||||
# pk:{d8}:{field}:{idx} pick candidate for a draft field
|
||||
# qc:{idx} / ac:{op} / ar:{op} pending-flow buttons (accept/arrive)
|
||||
|
||||
def draft_actions(prefix: str, kind: str, has_partial_option: bool = False) -> list[list[dict]]:
|
||||
op = {"quote": "q", "supplier": "s", "material": "m"}.get(kind, "q")
|
||||
rows = [[_btn("✅ ثبت نهایی", f"{op}:ok:{prefix}"), _btn("❌ انصراف", f"{op}:no:{prefix}")]]
|
||||
if has_partial_option:
|
||||
rows.insert(0, [_btn("✅ ثبت فقط اقلام مشخص", f"{op}:ok2:{prefix}")])
|
||||
return rows
|
||||
|
||||
|
||||
def pick_buttons(prefix: str, field: str, names: list[str]) -> list[list[dict]]:
|
||||
return [[_btn(name[:60], f"pk:{prefix}:{field}:{i}")] for i, name in enumerate(names)]
|
||||
|
||||
|
||||
def quote_review_keyboard(
|
||||
prefix: str,
|
||||
pick_rows: list[list[dict]],
|
||||
has_partial_option: bool = False,
|
||||
wizard_url: str | None = None,
|
||||
) -> dict:
|
||||
rows = list(pick_rows)
|
||||
if wizard_url:
|
||||
rows.append([{"text": "🖥 بازبینی و تکمیل در ویزارد", "url": wizard_url}])
|
||||
rows += draft_actions(prefix, "quote", has_partial_option=has_partial_option)
|
||||
return _kb(rows)
|
||||
|
||||
|
||||
def simple_review_keyboard(prefix: str, kind: str, pick_rows: list[list[dict]]) -> dict:
|
||||
rows = list(pick_rows)
|
||||
rows += draft_actions(prefix, kind)
|
||||
return _kb(rows)
|
||||
|
||||
|
||||
def quote_pick_keyboard(pairs: list[tuple[str, str]]) -> dict:
|
||||
"""Pick among candidate quotes: pairs of (label, quote_id)."""
|
||||
return _kb([[_btn(label[:60], f"qc:{i}")] for i, (label, _) in enumerate(pairs)])
|
||||
|
||||
|
||||
def accept_keyboard(termsheet_labels: list[str]) -> dict:
|
||||
rows = [[_btn(f"شرایط: {label[:40]}", f"ac:ts:{i}")] for i, label in enumerate(termsheet_labels)]
|
||||
rows.append([_btn("✅ تأیید پذیرش", "ac:ok"), _btn("❌ انصراف", "ac:no")])
|
||||
return _kb(rows)
|
||||
|
||||
|
||||
def arrive_keyboard() -> dict:
|
||||
return _kb([
|
||||
[_btn("📦 تحویل کامل همه اقلام", "ar:full")],
|
||||
[_btn("✅ ثبت مقادیر اعلامشده", "ar:ok"), _btn("❌ انصراف", "ar:no")],
|
||||
])
|
||||
|
||||
|
||||
def cancel_keyboard() -> dict:
|
||||
return _kb([[_btn("❌ انصراف", "ac:no")]])
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Bot entrypoint: ensure tables → sweeper task → Bale long-poll loop.
|
||||
|
||||
Mirrors the api's pattern of a plain asyncio background loop (no Celery/Redis).
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from app.bale.client import BaleClient, poll_loop
|
||||
from app.config import settings
|
||||
from app.db_init import ensure_schema
|
||||
from app.drafts import expire_stale
|
||||
from app.handlers import handle_update
|
||||
from app.runtime import BotContext
|
||||
from app.api.client import ErpClient
|
||||
from app.sync import sync_drafts_loop
|
||||
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO),
|
||||
format="%(asctime)s %(levelname)s %(name)s — %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("bot.main")
|
||||
|
||||
|
||||
async def sweeper_loop() -> None:
|
||||
"""Expire stale drafts + sweep dead pending flows every 10 minutes."""
|
||||
while True:
|
||||
try:
|
||||
expired = await expire_stale(settings.DRAFT_TTL_HOURS)
|
||||
if expired:
|
||||
logger.info("expired %s stale drafts", expired)
|
||||
except Exception:
|
||||
logger.exception("sweeper failed")
|
||||
await asyncio.sleep(600)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
if not settings.BOT_TOKEN:
|
||||
raise SystemExit("PROCUREMENT_BOT_BOT_TOKEN is not set — get one from Bale's BotFather")
|
||||
logger.info("starting bale procurement bot (api=%s, ai=%s)",
|
||||
settings.API_BASE_URL, "configured" if settings.AI_BASE_URL else "NOT configured")
|
||||
await ensure_schema()
|
||||
|
||||
bale = BaleClient(settings.BALE_API_BASE, settings.BOT_TOKEN)
|
||||
api = ErpClient(settings.API_BASE_URL, settings.API_TIMEOUT_SECONDS, settings.SERVICE_TOKEN)
|
||||
ctx = BotContext(bale, api)
|
||||
|
||||
sweeper = asyncio.create_task(sweeper_loop())
|
||||
sync_task = asyncio.create_task(sync_drafts_loop(ctx))
|
||||
try:
|
||||
await poll_loop(bale, lambda update: handle_update(ctx, update))
|
||||
finally:
|
||||
sweeper.cancel()
|
||||
sync_task.cancel()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Bot-owned tables (bot_* prefix, idempotent DDL in db_init.py).
|
||||
|
||||
These are separate from the ERP schema on purpose: the bot is an autonomous
|
||||
container and owns its own state (links, drafts, message audit log).
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, Index, Integer, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def _uuid_pk() -> Mapped[str]:
|
||||
return mapped_column(UUID(as_uuid=False), primary_key=True, server_default=func.gen_random_uuid())
|
||||
|
||||
|
||||
def _timestamptz(**kwargs) -> Mapped[datetime]:
|
||||
return mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False, **kwargs)
|
||||
|
||||
|
||||
class BotLink(Base):
|
||||
"""Bale chat ↔ ERP user mapping (established via Casdoor login)."""
|
||||
|
||||
__tablename__ = "bot_links"
|
||||
|
||||
id: Mapped[str] = _uuid_pk()
|
||||
chat_id: Mapped[int] = mapped_column(BigInteger, unique=True, nullable=False, index=True)
|
||||
user_id: Mapped[str | None] = mapped_column(UUID(as_uuid=False)) # users.id
|
||||
casdoor_username: Mapped[str | None] = mapped_column(Text)
|
||||
display_name: Mapped[str | None] = mapped_column(Text)
|
||||
role: Mapped[str | None] = mapped_column(String(32))
|
||||
created_at: Mapped[datetime] = _timestamptz()
|
||||
updated_at: Mapped[datetime] = _timestamptz(onupdate=func.now())
|
||||
|
||||
|
||||
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"),
|
||||
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)
|
||||
kind: Mapped[str] = mapped_column(String(16), nullable=False) # quote|supplier|material
|
||||
payload: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
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()
|
||||
updated_at: Mapped[datetime] = _timestamptz(onupdate=func.now())
|
||||
|
||||
|
||||
class BotMessage(Base):
|
||||
"""Audit log of every inbound/outbound message (who asked for what)."""
|
||||
|
||||
__tablename__ = "bot_messages"
|
||||
|
||||
id: Mapped[str] = _uuid_pk()
|
||||
chat_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
|
||||
direction: Mapped[str] = mapped_column(String(4), nullable=False) # in|out
|
||||
kind: Mapped[str] = mapped_column(String(16), nullable=False, default="text") # text|voice|callback|error
|
||||
body: Mapped[str | None] = mapped_column(Text)
|
||||
payload: Mapped[dict | None] = mapped_column(JSONB)
|
||||
created_at: Mapped[datetime] = _timestamptz()
|
||||
|
||||
|
||||
class BotMeta(Base):
|
||||
"""Tiny KV store (e.g. last processed Bale update_id across restarts)."""
|
||||
|
||||
__tablename__ = "bot_meta"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
value: Mapped[str | None] = mapped_column(Text)
|
||||
updated_at: Mapped[datetime] = _timestamptz(onupdate=func.now())
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
"""Natural-language understanding: Persian free text → typed intent + entities.
|
||||
|
||||
One chat/completions call per message with JSON-mode output. The prompt embeds
|
||||
the current draft summary so the model can decide between "start a new
|
||||
activity" and "refine the open draft" (update_draft with a partial patch).
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
logger = logging.getLogger("bot.nlu")
|
||||
|
||||
PERSIAN_DIGITS = str.maketrans("۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩", "01234567890123456789")
|
||||
|
||||
|
||||
def normalize_digits(text: str) -> str:
|
||||
return (text or "").translate(PERSIAN_DIGITS)
|
||||
|
||||
|
||||
ZWNJ = "\u200c"
|
||||
|
||||
|
||||
def normalize_text(text: str) -> str:
|
||||
"""Normalize Persian text for fuzzy matching (ZWNJ, arabic ya/ka, spaces)."""
|
||||
t = normalize_digits(text or "")
|
||||
t = t.replace(ZWNJ, " ").replace("ي", "ی").replace("ك", "ک")
|
||||
t = re.sub(r"\s+", " ", t).strip().lower()
|
||||
return t
|
||||
|
||||
|
||||
INTENTS = [
|
||||
"register_quote", # ثبت استعلام/پیشنهاد جدید از یک تأمینکننده
|
||||
"update_draft", # تکمیل/اصلاح پیشنویس باز (patch روی آن)
|
||||
"commit_draft", # «ثبت کن» / «تأیید» — commit the open draft
|
||||
"discard_draft", # «بیخیال» / «لغو»
|
||||
"show_draft", # «وضعیت پیشنویس؟»
|
||||
"add_supplier",
|
||||
"add_material",
|
||||
"status_query", # پرسوجو: استعلامهای در انتظار / بهزودی منقضی / ...
|
||||
"accept_quote", # پذیرش یک استعلام
|
||||
"arrive_items", # اعلام رسیدن اقلام
|
||||
"help",
|
||||
"unknown",
|
||||
]
|
||||
|
||||
SYSTEM_PROMPT = """تو دستیار تدارکات یک ERP فارسی هستی. ورودی تو پیام صوتی/متنی کاربر است.
|
||||
یک JSON و فقط JSON برگردان (بدون توضیح اضافه) با این شکل:
|
||||
|
||||
{
|
||||
"consensus_text": "متن تجمیعشده و اصلاحشده کاربر (فارسی روان و بدون غلط شنیداری)",
|
||||
"intent": یکی از {intents},
|
||||
"patch": {شکل partial هر فعالیتی که کاربر داده},
|
||||
"confidence": 0.0..1.0
|
||||
}
|
||||
|
||||
قوانین:
|
||||
- اگر ورودی شامل چند نسخه تبدیل گفتار به متن (STT) از یک پیام صوتی کاربر است:
|
||||
۱. این نسخهها از چند مدل مختلف برای افزایش دقت به دست آمدهاند. آنها را با هم مقایسه و مقابله کن.
|
||||
۲. خطاهای شنیداری هر نسخه را بر مبنای منطق تدارکات (نام کالاها، برندها، مشخصات فنی و واحدهای اندازهگیری) تصحیح و حل تعارض کن.
|
||||
۳. متن نهایی اصلاح و تجمیعشده را در فیلد consensus_text قرار بده.
|
||||
۴. استخراج قصد (intent) و موجودیتها (patch) را بر اساس همین متن دقیق انجام بده.
|
||||
- اگر ورودی یک متن عادی است، همان متن را در consensus_text قرار بده.
|
||||
- اگر پیام کاربر اطلاعات جدیدی به پیشنویسِ باز اضافه میکند یا اصلاح میکند → intent="update_draft" و patch شامل فقط فیلدهای جدید/تغییریافته.
|
||||
- اگر عبارت تأیید نهایی است (مثل «ثبت کن»، «همینه، ثبتش کن») → intent="commit_draft".
|
||||
- اگر لغو است («بیخیال»، «لغو») → intent="discard_draft".
|
||||
- اگر سؤال درباره وضعیت است → intent="status_query".
|
||||
- اگر درباره پذیرش استعلام است → intent="accept_quote" و در patch.target نام تأمینکننده/استعلام.
|
||||
- اگر درباره اعلام رسیدن اقلام است → intent="arrive_items" و در patch.target نام تأمینکننده/استعلام و در patch.arrivals مقادیر.
|
||||
- برای کالاها: material_name فقط نام کلی کالا باشد (مانند «میلگرد» یا «لوله»)؛ کارخانه یا برند در brand_name؛ و مشخصات فنی (مثل سایز یا گرید) در آرایه specs قرار گیرد.
|
||||
- برای شرایط پرداخت: در صورت ذکر اقساط/موعد و مبالغ، در milestones: [{"due_date": "YYYY-MM-DD", "amount": number}] قرار بده.
|
||||
|
||||
شکل patch برای intent="register_quote"/"update_draft":
|
||||
{"supplier_name": str, "project_name": str, "project_names": [str], "is_official": bool, "notes": str, "expiry_date": "YYYY-MM-DD",
|
||||
"items": [{"material_name": str, "brand_name": str, "specs": [str], "quantity": number, "unit_name": str, "unit_price": number, "notes": str}],
|
||||
"payment_terms": [{"label": str, "milestones": [{"due_date": "YYYY-MM-DD", "amount": number}], "discount_type": "none|percent|fixed", "discount_value": number}]}
|
||||
|
||||
شکل patch برای intent="add_supplier":
|
||||
{"supplier": {"name": str, "category_name": str, "phone": str, "location": str, "contact_person": str}}
|
||||
|
||||
شکل patch برای intent="add_material":
|
||||
{"material": {"name": str, "unit_name": str, "topic": str}}
|
||||
|
||||
شکل patch برای intent="status_query":
|
||||
{"query": {"kind": "pending|accepted|arrived|expiring|recent", "supplier_name": str, "project_name": str, "days": int}}
|
||||
|
||||
شکل patch برای intent="accept_quote"/"arrive_items":
|
||||
{"target": {"supplier_name": str, "project_name": str, "quote_id": str},
|
||||
"arrivals": [{"material_name": str, "quantity": number, "quality_note": str}]}
|
||||
|
||||
{draft_block}
|
||||
"""
|
||||
|
||||
|
||||
def _build_system_prompt(draft_summary: str | None) -> str:
|
||||
intents = ", ".join(INTENTS)
|
||||
prompt = SYSTEM_PROMPT.replace("{intents}", intents).replace("{draft_block}", "")
|
||||
if draft_summary:
|
||||
prompt += (
|
||||
"\n\nپیشنویسِ باز فعلی کاربر (اگر مرتبط است به آن تکیه کن و فیلدهای تکراری را بازنویسی نکن):\n"
|
||||
+ draft_summary
|
||||
)
|
||||
return prompt
|
||||
|
||||
|
||||
def _extract_json(raw: str) -> dict | None:
|
||||
raw = (raw or "").strip()
|
||||
if raw.startswith("```"):
|
||||
raw = re.sub(r"^```[a-zA-Z]*\n?", "", raw).rstrip("`").strip()
|
||||
start = raw.find("{")
|
||||
end = raw.rfind("}")
|
||||
if start == -1 or end <= start:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw[start : end + 1])
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
async def understand(settings: Settings, text: str | list[dict], draft_summary: str | None = None) -> dict:
|
||||
"""Return {"consensus_text": ..., "intent": ..., "patch": {...}, "confidence": ...}; never raises."""
|
||||
fallback = {"consensus_text": "", "intent": "unknown", "patch": {}, "confidence": 0.0}
|
||||
if not settings.AI_BASE_URL or not settings.AI_API_KEY:
|
||||
logger.warning("AI provider not configured; cannot understand message")
|
||||
return fallback
|
||||
|
||||
if isinstance(text, list):
|
||||
first_text = text[0].get("text") if text else ""
|
||||
lines = ["پیام صوتی کاربر توسط چند مدل تبدیل گفتار به متن (STT) به صورت زیر پیادهسازی شده است:"]
|
||||
for idx, item in enumerate(text, start=1):
|
||||
name = item.get("name") or f"مدل {idx}"
|
||||
t = normalize_digits(item.get("text") or "")
|
||||
lines.append(f"- نسخه {idx} ({name}): «{t}»")
|
||||
lines.append("\nبا تلفیق این نسخهها و تصحیح خطاهای شنیداری، پیام را تحلیل کن و consensus_text را به همراه intent و patch بساز.")
|
||||
user_content = "\n".join(lines)
|
||||
else:
|
||||
user_content = normalize_digits(str(text))
|
||||
first_text = str(text)
|
||||
|
||||
fallback["consensus_text"] = first_text
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=settings.LLM_TIMEOUT_SECONDS) as client:
|
||||
resp = await client.post(
|
||||
f"{settings.AI_BASE_URL.rstrip('/')}/chat/completions",
|
||||
headers={"Authorization": f"Bearer {settings.AI_API_KEY}"},
|
||||
json={
|
||||
"model": settings.LLM_MODEL,
|
||||
"messages": [
|
||||
{"role": "system", "content": _build_system_prompt(draft_summary)},
|
||||
{"role": "user", "content": user_content},
|
||||
],
|
||||
"temperature": 0.1,
|
||||
"response_format": {"type": "json_object"},
|
||||
},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
logger.error("LLM HTTP %s: %s", resp.status_code, resp.text[:300])
|
||||
return fallback
|
||||
content = ((resp.json() or {}).get("choices") or [{}])[0].get("message", {}).get("content", "")
|
||||
parsed = _extract_json(content)
|
||||
if not parsed or parsed.get("intent") not in INTENTS:
|
||||
logger.warning("unparseable LLM output: %r", content[:200])
|
||||
return fallback
|
||||
parsed.setdefault("patch", {})
|
||||
if not parsed.get("consensus_text"):
|
||||
parsed["consensus_text"] = first_text
|
||||
return parsed
|
||||
except (httpx.HTTPError, json.JSONDecodeError, KeyError, TypeError):
|
||||
logger.exception("NLU failure")
|
||||
return fallback
|
||||
@@ -0,0 +1,282 @@
|
||||
"""Name → ERP id resolution with fuzzy matching (Persian-aware) + tiny caches.
|
||||
|
||||
The ERP search endpoints are ilike-based; we fetch candidates with `q=` and
|
||||
rank them with difflib so "میلگرد A3 14" still matches "میلگرد A3 سایز ۱۴".
|
||||
"""
|
||||
import difflib
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sqlalchemy import text
|
||||
from app.api.client import ApiError, ErpClient
|
||||
from app.db import SessionLocal
|
||||
from app.nlu import normalize_text
|
||||
|
||||
|
||||
@dataclass
|
||||
class Candidate:
|
||||
id: str
|
||||
name: str
|
||||
extra: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Resolution:
|
||||
status: str # "one" | "many" | "none"
|
||||
candidates: list[Candidate]
|
||||
|
||||
@property
|
||||
def resolved(self) -> bool:
|
||||
return self.status == "one"
|
||||
|
||||
|
||||
def _rank(query: str, items: list[Candidate], limit: int = 6) -> list[Candidate]:
|
||||
q = normalize_text(query)
|
||||
if not q:
|
||||
return items[:limit]
|
||||
scored = []
|
||||
for item in items:
|
||||
name = normalize_text(item.name)
|
||||
ratio = difflib.SequenceMatcher(None, q, name).ratio()
|
||||
if q == name:
|
||||
ratio = 1.0
|
||||
elif q in name:
|
||||
ratio += 0.35
|
||||
scored.append((ratio, item))
|
||||
scored.sort(key=lambda pair: (-pair[0], pair[1].name))
|
||||
return [item for ratio, item in scored[:limit] if ratio >= 0.40]
|
||||
|
||||
|
||||
async def _db_units() -> list[Candidate]:
|
||||
try:
|
||||
async with SessionLocal() as s:
|
||||
rows = (await s.execute(text("SELECT id, code, name_fa FROM units"))).mappings().all()
|
||||
return [
|
||||
Candidate(
|
||||
id=str(u["id"]),
|
||||
name=u.get("name_fa") or u.get("code") or str(u["id"]),
|
||||
extra={"code": u.get("code")},
|
||||
)
|
||||
for u in rows
|
||||
]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
async def _db_categories() -> list[Candidate]:
|
||||
try:
|
||||
async with SessionLocal() as s:
|
||||
rows = (await s.execute(text("SELECT id, name, slug FROM material_categories"))).mappings().all()
|
||||
return [
|
||||
Candidate(
|
||||
id=str(c["id"]),
|
||||
name=c.get("name") or c.get("slug") or str(c["id"]),
|
||||
)
|
||||
for c in rows
|
||||
]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
async def _db_projects() -> list[Candidate]:
|
||||
try:
|
||||
async with SessionLocal() as s:
|
||||
rows = (await s.execute(text("SELECT id, name FROM projects"))).mappings().all()
|
||||
return [
|
||||
Candidate(
|
||||
id=str(p["id"]),
|
||||
name=p.get("name") or str(p["id"]),
|
||||
)
|
||||
for p in rows
|
||||
]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
async def _db_suppliers(q: str | None = None) -> list[Candidate]:
|
||||
try:
|
||||
async with SessionLocal() as s:
|
||||
if q:
|
||||
rows = (await s.execute(
|
||||
text("SELECT s.id, s.name, s.location, s.phone, s.contact_person, c.name as category_name "
|
||||
"FROM suppliers s LEFT JOIN material_categories c ON s.category_id = c.id "
|
||||
"WHERE s.name ILIKE :q LIMIT 20"),
|
||||
{"q": f"%{q}%"}
|
||||
)).mappings().all()
|
||||
else:
|
||||
rows = (await s.execute(
|
||||
text("SELECT s.id, s.name, s.location, s.phone, s.contact_person, c.name as category_name "
|
||||
"FROM suppliers s LEFT JOIN material_categories c ON s.category_id = c.id LIMIT 20")
|
||||
)).mappings().all()
|
||||
return [
|
||||
Candidate(
|
||||
id=str(r["id"]),
|
||||
name=r.get("name") or str(r["id"]),
|
||||
extra={
|
||||
"location": r.get("location"),
|
||||
"phone": r.get("phone"),
|
||||
"contact_person": r.get("contact_person"),
|
||||
"category_name": r.get("category_name"),
|
||||
},
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
async def _db_materials(q: str | None = None) -> list[Candidate]:
|
||||
try:
|
||||
async with SessionLocal() as s:
|
||||
if q:
|
||||
rows = (await s.execute(
|
||||
text("SELECT id, name, topic, canonical_unit_id, status FROM materials WHERE name ILIKE :q LIMIT 20"),
|
||||
{"q": f"%{q}%"}
|
||||
)).mappings().all()
|
||||
else:
|
||||
rows = (await s.execute(
|
||||
text("SELECT id, name, topic, canonical_unit_id, status FROM materials LIMIT 20")
|
||||
)).mappings().all()
|
||||
return [
|
||||
Candidate(
|
||||
id=str(r["id"]),
|
||||
name=r.get("name") or str(r["id"]),
|
||||
extra=dict(r),
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
class Resolver:
|
||||
"""Caches catalog lists (units/categories/projects are stable; suppliers/materials queried on demand)."""
|
||||
|
||||
def __init__(self, api: ErpClient, ttl_seconds: float = 1800):
|
||||
self._api = api
|
||||
self._ttl = ttl_seconds
|
||||
self._cache: dict[str, tuple[float, list[Candidate]]] = {}
|
||||
|
||||
async def _cached(self, key: str, fetch) -> list[Candidate]:
|
||||
entry = self._cache.get(key)
|
||||
now = time.monotonic()
|
||||
if entry and now - entry[0] < self._ttl:
|
||||
return entry[1]
|
||||
items = await fetch()
|
||||
self._cache[key] = (now, items)
|
||||
return items
|
||||
|
||||
def invalidate(self, key: str | None = None) -> None:
|
||||
if key:
|
||||
self._cache.pop(key, None)
|
||||
else:
|
||||
self._cache.clear()
|
||||
|
||||
# ---------------------------------------------------------------- sources
|
||||
|
||||
async def units(self) -> list[Candidate]:
|
||||
async def fetch():
|
||||
try:
|
||||
rows = await self._api.list_units()
|
||||
return [Candidate(id=str(u["id"]), name=u.get("name_fa") or u.get("code") or str(u["id"]),
|
||||
extra={"code": u.get("code")}) for u in rows]
|
||||
except Exception:
|
||||
return await _db_units()
|
||||
return await self._cached("units", fetch)
|
||||
|
||||
async def categories(self) -> list[Candidate]:
|
||||
async def fetch():
|
||||
try:
|
||||
rows = await self._api.list_categories()
|
||||
return [Candidate(id=str(c["id"]), name=c.get("name") or c.get("slug") or str(c["id"])) for c in rows]
|
||||
except Exception:
|
||||
return await _db_categories()
|
||||
return await self._cached("categories", fetch)
|
||||
|
||||
async def projects(self) -> list[Candidate]:
|
||||
async def fetch():
|
||||
try:
|
||||
rows = await self._api.list_projects()
|
||||
return [Candidate(id=str(p["id"]), name=p.get("name") or str(p["id"])) for p in rows]
|
||||
except Exception:
|
||||
return await _db_projects()
|
||||
return await self._cached("projects", fetch)
|
||||
|
||||
async def suppliers(self, q: str | None = None) -> list[Candidate]:
|
||||
try:
|
||||
rows = await self._api.list_suppliers(q=q, page_size=20)
|
||||
items = [
|
||||
Candidate(
|
||||
id=str(s["id"]),
|
||||
name=s.get("name") or str(s["id"]),
|
||||
extra={
|
||||
"location": s.get("location"),
|
||||
"phone": s.get("phone"),
|
||||
"contact_person": s.get("contact_person"),
|
||||
"category_name": (s.get("category") or {}).get("name") if isinstance(s.get("category"), dict) else None,
|
||||
},
|
||||
)
|
||||
for s in rows
|
||||
]
|
||||
except Exception:
|
||||
items = await _db_suppliers(q=q)
|
||||
return _rank(q, items) if q else items
|
||||
|
||||
async def materials(self, q: str | None = None) -> list[Candidate]:
|
||||
try:
|
||||
rows = await self._api.list_materials(q=q, page_size=20)
|
||||
extra = {str(m["id"]): m for m in rows}
|
||||
items = [Candidate(id=str(m["id"]), name=m.get("name") or str(m["id"]), extra=extra.get(str(m["id"]), {}))
|
||||
for m in rows]
|
||||
except Exception:
|
||||
items = await _db_materials(q=q)
|
||||
return _rank(q, items) if q else items
|
||||
|
||||
# ------------------------------------------------------------ resolutions
|
||||
|
||||
async def resolve(self, kind: str, name: str | None) -> Resolution:
|
||||
"""Resolve by name; exact hit → "one", several close → "many", none → "none"."""
|
||||
if not name or not name.strip():
|
||||
return Resolution("none", [])
|
||||
if kind == "supplier":
|
||||
candidates = await self.suppliers(name)
|
||||
elif kind == "material":
|
||||
candidates = await self.materials(name)
|
||||
elif kind == "project":
|
||||
candidates = _rank(name, await self.projects())
|
||||
elif kind == "unit":
|
||||
candidates = _rank(name, await self.units())
|
||||
elif kind == "category":
|
||||
candidates = _rank(name, await self.categories())
|
||||
else:
|
||||
raise ValueError(f"unknown kind: {kind}")
|
||||
if not candidates:
|
||||
return Resolution("none", [])
|
||||
norm_name = normalize_text(name)
|
||||
exact = [c for c in candidates if normalize_text(c.name) == norm_name]
|
||||
if len(exact) == 1:
|
||||
return Resolution("one", exact)
|
||||
top = candidates[0]
|
||||
top_norm = normalize_text(top.name)
|
||||
ratio = difflib.SequenceMatcher(None, norm_name, top_norm).ratio()
|
||||
if ratio >= 0.88 and (len(candidates) == 1 or ratio > 0.95):
|
||||
return Resolution("one", [top])
|
||||
return Resolution("many", candidates)
|
||||
|
||||
async def unit_for_material(self, unit_name: str | None, material: dict | None) -> tuple[str | None, bool]:
|
||||
"""Return (unit_id, explicitly_resolved). Falls back to the material's
|
||||
canonical unit when the spoken unit isn't found."""
|
||||
if unit_name:
|
||||
res = await self.resolve("unit", unit_name)
|
||||
if res.resolved:
|
||||
return res.candidates[0].id, True
|
||||
if material and material.get("canonical_unit_id"):
|
||||
return str(material["canonical_unit_id"]), False
|
||||
return None, False
|
||||
|
||||
|
||||
async def safe_resolve(resolver: Resolver, kind: str, name: str | None) -> Resolution:
|
||||
try:
|
||||
return await resolver.resolve(kind, name)
|
||||
except Exception:
|
||||
return Resolution("none", [])
|
||||
@@ -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
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Security and token generation for standalone draft review."""
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
def _b64url_encode(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _b64url_decode(s: str) -> bytes:
|
||||
padding = 4 - (len(s) % 4)
|
||||
if padding != 4:
|
||||
s += "=" * padding
|
||||
return base64.urlsafe_b64decode(s.encode("ascii"))
|
||||
|
||||
|
||||
def create_draft_token(
|
||||
draft_id: str | UUID,
|
||||
chat_id: int | None = None,
|
||||
user_id: str | None = None,
|
||||
secret: str = "procurement-draft-secret-change-in-prod-v7",
|
||||
expires_in: int = 7200,
|
||||
) -> str:
|
||||
"""Generate an HMAC-SHA256 signed token for secure draft access."""
|
||||
now = int(time.time())
|
||||
payload = {
|
||||
"draft_id": str(draft_id),
|
||||
"chat_id": chat_id,
|
||||
"user_id": str(user_id) if user_id else None,
|
||||
"iat": now,
|
||||
"exp": now + expires_in,
|
||||
}
|
||||
payload_bytes = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
payload_b64 = _b64url_encode(payload_bytes)
|
||||
|
||||
sig = hmac.new(secret.encode("utf-8"), payload_b64.encode("ascii"), hashlib.sha256).digest()
|
||||
sig_b64 = _b64url_encode(sig)
|
||||
|
||||
return f"{payload_b64}.{sig_b64}"
|
||||
|
||||
|
||||
def verify_draft_token(
|
||||
token: str,
|
||||
secret: str = "procurement-draft-secret-change-in-prod-v7",
|
||||
) -> dict[str, Any] | None:
|
||||
"""Verify signature and expiration of a draft token. Return payload or None."""
|
||||
if not token or "." not in token:
|
||||
return None
|
||||
try:
|
||||
payload_b64, sig_b64 = token.split(".", 1)
|
||||
expected_sig = hmac.new(secret.encode("utf-8"), payload_b64.encode("ascii"), hashlib.sha256).digest()
|
||||
actual_sig = _b64url_decode(sig_b64)
|
||||
|
||||
if not hmac.compare_digest(expected_sig, actual_sig):
|
||||
return None
|
||||
|
||||
payload_bytes = _b64url_decode(payload_b64)
|
||||
payload = json.loads(payload_bytes.decode("utf-8"))
|
||||
|
||||
# Check expiration
|
||||
exp = payload.get("exp")
|
||||
if exp is not None and int(exp) < int(time.time()):
|
||||
return None
|
||||
|
||||
return payload
|
||||
except Exception:
|
||||
return None
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Persistent storage for draft attachments (proforma files)."""
|
||||
from datetime import datetime, timezone
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
logger = logging.getLogger("bot.storage")
|
||||
|
||||
|
||||
def sanitize_filename(filename: str) -> str:
|
||||
"""Sanitize filename preserving Persian/Arabic/English letters, numbers, and extensions."""
|
||||
raw = (filename or "proforma.pdf").strip()
|
||||
name = Path(raw).name
|
||||
# Remove control characters and path traversal
|
||||
clean = re.sub(r'[\/\\:\*\?"<>\|\r\n\t]', "_", name)
|
||||
clean = clean.strip(". ")
|
||||
return clean or f"proforma_{uuid4().hex[:8]}.pdf"
|
||||
|
||||
|
||||
async def save_proforma_file(
|
||||
draft_id: str | UUID,
|
||||
file_name: str,
|
||||
content: bytes,
|
||||
mime_type: str | None = None,
|
||||
storage_dir: str = "data/attachments/proformas",
|
||||
) -> dict:
|
||||
"""Save proforma bytes permanently to disk.
|
||||
|
||||
Returns dict metadata for persisting in draft payload.
|
||||
"""
|
||||
clean_name = sanitize_filename(file_name)
|
||||
target_dir = Path(storage_dir) / str(draft_id)
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
unique_prefix = uuid4().hex[:8]
|
||||
saved_filename = f"{unique_prefix}_{clean_name}"
|
||||
target_path = target_dir / saved_filename
|
||||
|
||||
# Write bytes to disk
|
||||
target_path.write_bytes(content)
|
||||
logger.info("Persisted proforma (%d bytes) to %s", len(content), target_path)
|
||||
|
||||
return {
|
||||
"file_name": clean_name,
|
||||
"saved_filename": saved_filename,
|
||||
"saved_path": str(target_path.resolve()),
|
||||
"relative_path": str(target_path.as_posix()),
|
||||
"size_bytes": len(content),
|
||||
"mime_type": mime_type or "application/octet-stream",
|
||||
"saved_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
"""Speech-to-text via an OpenAI-compatible /audio/transcriptions endpoint.
|
||||
|
||||
Bale voice notes arrive as OGG/Opus which not every provider accepts, so the
|
||||
audio is first converted to mono 16 kHz MP3 with ffmpeg (installed in the bot
|
||||
image). Any OpenAI-compatible provider works (OpenAI, GLM, OpenRouter proxies,
|
||||
self-hosted whisper servers exposing the same contract).
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
logger = logging.getLogger("bot.stt")
|
||||
|
||||
_CONVERT_TIMEOUT = 60
|
||||
_STT_TIMEOUT = 120
|
||||
|
||||
|
||||
def _to_mp3(raw: bytes, suffix: str) -> bytes:
|
||||
"""Convert arbitrary audio bytes to mono 16 kHz MP3 using ffmpeg."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
src = Path(tmp) / f"in{suffix or '.ogg'}"
|
||||
dst = Path(tmp) / "out.mp3"
|
||||
src.write_bytes(raw)
|
||||
proc = subprocess.run(
|
||||
["ffmpeg", "-y", "-i", str(src), "-vn", "-ac", "1", "-ar", "16000", "-b:a", "64k", str(dst)],
|
||||
capture_output=True,
|
||||
timeout=_CONVERT_TIMEOUT,
|
||||
)
|
||||
if proc.returncode != 0 or not dst.exists():
|
||||
raise RuntimeError(f"ffmpeg failed: {proc.stderr[-400:]!r}")
|
||||
return dst.read_bytes()
|
||||
|
||||
|
||||
async def _call_single_stt(client: httpx.AsyncClient, provider: dict, converted_mp3: bytes) -> dict | None:
|
||||
name = provider.get("name") or "stt"
|
||||
base_url = (provider.get("base_url") or "").rstrip("/")
|
||||
api_key = provider.get("api_key") or ""
|
||||
model = provider.get("model") or "whisper-1"
|
||||
language = provider.get("language") or "fa"
|
||||
timeout = float(provider.get("timeout") or _STT_TIMEOUT)
|
||||
|
||||
url = f"{base_url}/audio/transcriptions"
|
||||
try:
|
||||
resp = await client.post(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
files={"file": ("voice.mp3", converted_mp3, "audio/mpeg")},
|
||||
data={"model": model, "language": language},
|
||||
timeout=timeout,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
txt = str((resp.json() or {}).get("text") or "").strip()
|
||||
if txt:
|
||||
logger.info("STT provider %s succeeded: %r", name, txt[:60])
|
||||
return {"name": name, "text": txt, "model": model}
|
||||
else:
|
||||
logger.warning("STT provider %s returned HTTP %s: %s", name, resp.status_code, resp.text[:200])
|
||||
except Exception as exc:
|
||||
logger.warning("STT provider %s failed: %s", name, exc)
|
||||
return None
|
||||
|
||||
|
||||
async def transcribe_multi(settings: Settings, audio: bytes, mime_type: str | None = None,
|
||||
filename: str | None = None) -> list[dict]:
|
||||
"""Transcribe audio with all configured STT providers in parallel.
|
||||
|
||||
Returns a list of candidate dicts: [{"name": str, "text": str, "model": str}, ...]
|
||||
Empty list if all failed or silence.
|
||||
"""
|
||||
providers = settings.stt_providers
|
||||
if not providers:
|
||||
raise RuntimeError("No STT providers are configured (PROCUREMENT_BOT_STT_*)")
|
||||
|
||||
suffix = Path(filename or "").suffix
|
||||
if not suffix and mime_type:
|
||||
suffix = {"audio/ogg": ".ogg", "audio/opus": ".ogg", "audio/mpeg": ".mp3",
|
||||
"audio/mp4": ".m4a", "audio/wav": ".wav"}.get(mime_type.split(";")[0].strip(), ".ogg")
|
||||
converted = await asyncio.to_thread(_to_mp3, audio, suffix)
|
||||
|
||||
async with httpx.AsyncClient(timeout=_STT_TIMEOUT) as client:
|
||||
tasks = [_call_single_stt(client, p, converted) for p in providers]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
valid_transcripts: list[dict] = []
|
||||
for r in results:
|
||||
if isinstance(r, dict) and r.get("text"):
|
||||
valid_transcripts.append(r)
|
||||
|
||||
return valid_transcripts
|
||||
|
||||
|
||||
async def transcribe(settings: Settings, audio: bytes, mime_type: str | None = None,
|
||||
filename: str | None = None) -> str:
|
||||
"""Return the Persian transcript for a voice note (empty string on silence)."""
|
||||
transcripts = await transcribe_multi(settings, audio, mime_type=mime_type, filename=filename)
|
||||
return transcripts[0]["text"] if transcripts else ""
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Background sync loop: watches bot_drafts for updates from Web Wizard or external API.
|
||||
|
||||
If a draft was updated in the Wizard:
|
||||
- If status == 'active': re-renders review text & keyboard in Bale so chat reflects edits.
|
||||
- If status == 'committed': edits Bale message to show it was committed and removes buttons.
|
||||
- If status == 'cancelled': edits Bale message to show it was cancelled and removes buttons.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from app import drafts as draft_store
|
||||
from app.handlers import quote
|
||||
|
||||
logger = logging.getLogger("bot.sync")
|
||||
|
||||
|
||||
async def sync_drafts_loop(ctx) -> None:
|
||||
while True:
|
||||
try:
|
||||
pending = await draft_store.get_pending_bale_updates()
|
||||
for draft in pending:
|
||||
try:
|
||||
if not draft.review_message_id:
|
||||
await draft_store.set_bale_updated_at(draft.id)
|
||||
continue
|
||||
|
||||
if draft.status == "committed":
|
||||
payload = draft.payload or {}
|
||||
quote_id = payload.get("committed_quote_id") or ""
|
||||
msg = "✅ این پیشنهاد با موفقیت در سیستم ثبت نهایی شد."
|
||||
if quote_id:
|
||||
msg += f"\nشناسه پیشنهاد: {quote_id}"
|
||||
await ctx.bale.edit_message_text(draft.chat_id, draft.review_message_id, msg, reply_markup=None)
|
||||
await draft_store.set_bale_updated_at(draft.id)
|
||||
logger.info("Synced committed draft %s to Bale message %s", draft.id, draft.review_message_id)
|
||||
|
||||
elif draft.status == "cancelled":
|
||||
msg = "❌ این پیشنویس لغو شد."
|
||||
await ctx.bale.edit_message_text(draft.chat_id, draft.review_message_id, msg, reply_markup=None)
|
||||
await draft_store.set_bale_updated_at(draft.id)
|
||||
logger.info("Synced cancelled draft %s to Bale message %s", draft.id, draft.review_message_id)
|
||||
|
||||
elif draft.status == "active" and draft.kind == draft_store.KIND_QUOTE:
|
||||
await quote.render(ctx, draft.chat_id, draft, fresh=False)
|
||||
await draft_store.set_bale_updated_at(draft.id)
|
||||
logger.info("Synced active draft %s edits to Bale message %s", draft.id, draft.review_message_id)
|
||||
|
||||
else:
|
||||
await draft_store.set_bale_updated_at(draft.id)
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to sync draft %s to Bale: %s", draft.id, exc)
|
||||
await draft_store.set_bale_updated_at(draft.id)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception:
|
||||
logger.exception("Error in sync_drafts_loop")
|
||||
|
||||
await asyncio.sleep(1.5)
|
||||
@@ -0,0 +1,432 @@
|
||||
"""Persian message templates (the ERP's user-facing layer is Persian)."""
|
||||
import html
|
||||
from datetime import date
|
||||
|
||||
STATUS_FA = {
|
||||
"offered": "🔵 در انتظار بررسی",
|
||||
"accepted": "🟢 پذیرفتهشده",
|
||||
"arrived": "✅ رسیده",
|
||||
"rejected": "🔴 ردشده",
|
||||
"expired": "⚪️ منقضی",
|
||||
}
|
||||
|
||||
MISSING_MARKS = {True: "✅", False: "❌"}
|
||||
WARNING_MARK = "⚠️"
|
||||
|
||||
|
||||
def esc(value) -> str:
|
||||
return str(value if value is not None and value != "" else "—")
|
||||
|
||||
|
||||
def g2j(gy: int, gm: int, gd: int) -> tuple[int, int, int]:
|
||||
g_d_m = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334]
|
||||
gy2 = gy if gm > 2 else gy - 1
|
||||
days = 355666 + (365 * gy) + ((gy2 + 3) // 4) - ((gy2 + 99) // 100) + ((gy2 + 399) // 400) + gd + g_d_m[gm - 1]
|
||||
jy = -1595 + (33 * (days // 12053))
|
||||
days %= 12053
|
||||
jy += 4 * (days // 1461)
|
||||
days %= 1461
|
||||
if days > 365:
|
||||
jy += (days - 1) // 365
|
||||
days = (days - 1) % 365
|
||||
if days < 186:
|
||||
jm = 1 + (days // 31)
|
||||
jd = 1 + (days % 31)
|
||||
else:
|
||||
jm = 7 + ((days - 186) // 30)
|
||||
jd = 1 + ((days - 186) % 30)
|
||||
return jy, jm, jd
|
||||
|
||||
|
||||
def fa_num(val, use_fa_digits: bool = True) -> str:
|
||||
"""Format integer/float with thousands separator and Persian digits."""
|
||||
if val is None or val == "":
|
||||
return "—"
|
||||
try:
|
||||
num = float(val)
|
||||
if num.is_integer():
|
||||
s = f"{int(num):,}"
|
||||
else:
|
||||
s = f"{num:,.2f}".rstrip("0").rstrip(".")
|
||||
if use_fa_digits:
|
||||
trans = str.maketrans("0123456789,", "۰۱۲۳۴۵۶۷۸۹٬")
|
||||
return s.translate(trans)
|
||||
return s
|
||||
except (ValueError, TypeError):
|
||||
return str(val)
|
||||
|
||||
|
||||
def fa_digits(val) -> str:
|
||||
"""Convert ASCII digits to Persian digits without thousands separators."""
|
||||
if not val:
|
||||
return ""
|
||||
trans = str.maketrans("0123456789", "۰۱۲۳۴۵۶۷۸۹")
|
||||
return str(val).translate(trans)
|
||||
|
||||
|
||||
def fa_date(value, use_fa_digits: bool = True) -> str:
|
||||
if not value:
|
||||
return "—"
|
||||
s = str(value)[:10]
|
||||
try:
|
||||
parts = [int(p) for p in s.replace("/", "-").split("-")]
|
||||
if len(parts) == 3 and parts[0] > 1900:
|
||||
jy, jm, jd = g2j(parts[0], parts[1], parts[2])
|
||||
res = f"{jy:04d}/{jm:02d}/{jd:02d}"
|
||||
if use_fa_digits:
|
||||
trans = str.maketrans("0123456789", "۰۱۲۳۴۵۶۷۸۹")
|
||||
return res.translate(trans)
|
||||
return res
|
||||
except Exception:
|
||||
pass
|
||||
if use_fa_digits:
|
||||
trans = str.maketrans("0123456789", "۰۱۲۳۴۵۶۷۸۹")
|
||||
return s.translate(trans)
|
||||
return s
|
||||
|
||||
|
||||
WELCOME = (
|
||||
"سلام! 👋 من دستیار تدارکات هستم.\n\n"
|
||||
"میتوانی با پیام متنی یا صوتی فعالیتی تعریف کنی — مثلاً:\n"
|
||||
"«سازان نو برای پروژه برج آسمان، ۵۰۰ کیلو میلگرد A3، هر کیلو ۴۵ تومن، پرداخت ۳ فاز»\n\n"
|
||||
"من هر چه را فهمیدم بهصورت پیشنویس آماده میکنم، چیزی که فهمیدم و چیزهای خالی را نشان "
|
||||
"میدهم و فقط با تأیید تو در سیستم ثبت میکنم.\n\n"
|
||||
"دستورها:\n"
|
||||
"/drafts — پیشنویسهای باز\n"
|
||||
"/cancel — لغو پیشنویس/عملیات جاری\n"
|
||||
"/whoami — اتصال من\n"
|
||||
"/unlink — قطع اتصال\n"
|
||||
"/help — راهنما"
|
||||
)
|
||||
|
||||
HELP = (
|
||||
"چه کارهایی میتوانم بکنم؟\n\n"
|
||||
"🧾 ثبت استعلام — تأمینکننده، پروژه، اقلام (کالا/مقدار/واحد)، قیمت و شرایط پرداخت را از گفتگو درمیآورم.\n"
|
||||
"🏢 افزودن تأمینکننده — «تأمینکننده جدید فلانی، دسته لوله و اتصالات، تلفن ...»\n"
|
||||
"📦 افزودن کالا — «کالای جدید میلگرد A3 با واحد کیلوگرم»\n"
|
||||
"📊 پرسوجو — «استعلامهای در انتظار؟»، «کدامها بهزودی منقضی میشوند؟»\n"
|
||||
"✔️ پذیرش استعلام — «استعلام سازان نو را قبول کن»\n"
|
||||
"🚚 اعلام رسیدن — «اقلام سازان نو رسید، ۵۰۰ کیلو»\n\n"
|
||||
"هر ثبت فقط با دکمه ✅ انجام میشود. با /cancel هر چیز نیمهکاره را لغو کن."
|
||||
)
|
||||
|
||||
LINK_START = (
|
||||
"برای اتصال حساب Bale به حساب ERP، نام کاربری Casdoor خود را بفرست "
|
||||
"(یا اگر رمز جدا داری همان نام کاربری سامانه). برای لغو: /cancel"
|
||||
)
|
||||
LINK_ASK_PASSWORD = "ممنون. حالا رمز عبور را بفرست (فقط برای یک بار ورود؛ ذخیره نمیشود). /cancel برای لغو"
|
||||
LINK_OK = "✅ اتصال انجام شد: {name} ({role})\nحالا میتوانی فعالیتها را بفرستی. /help را ببین."
|
||||
LINK_FAIL = "ورود ناموفق بود: {detail}\nدوباره /start بزن یا نام کاربری را بفرست."
|
||||
ADMIN_NO_LINK = "حالت ادمین (بدون اتصال) فعال است؛ ثبتها بدون انتساب کاربر انجام میشود."
|
||||
|
||||
DRAFTS_LIST_HEADER = "📋 پیشنویسهای باز:"
|
||||
DRAFTS_EMPTY = "پیشنویس بازی نداری. یک فعالیت را با متن یا وویس بفرست."
|
||||
NOTHING_TO_COMMIT = "پیشنویس بازی برای ثبت نیست؛ اول یک فعالیت بفرست."
|
||||
CANCELLED = "لغو شد. ✔️"
|
||||
UNKNOWN_HINT = (
|
||||
"متوجه منظورت نشد 🤔\n"
|
||||
"میتوانی مثلاً بگویی: «ثبت استعلام از سازان نو برای ۲۰۰ عدد شیر فلکه» یا /help را ببینی."
|
||||
)
|
||||
UNLINKED = "ابتدا حساب خود را متصل کن: /start"
|
||||
AI_NOT_CONFIGURED = (
|
||||
"پردازش هوشمند تنظیم نشده است (متغیرهای PROCUREMENT_BOT_AI_*). "
|
||||
"با مدیر سیستم تماس بگیر."
|
||||
)
|
||||
STT_FAILED = "نتوانستم پیام صوتی را تبدیل کنم: {detail}"
|
||||
VOICE_EMPTY = "از پیام صوتی متنی شنیده نشد؛ دوباره بگو یا با متن بفرست."
|
||||
ERR_GENERIC = "خطا: {detail}"
|
||||
PICK_QUOTE = "چند استعلام پیدا شد؛ کدام را میخواهی؟"
|
||||
QUOTE_NOT_FOUND = "استعلامی برای «{q}» پیدا نکردم."
|
||||
ACCEPT_REPORT = (
|
||||
"✅ پذیرش انجام شد\n"
|
||||
"استعلام #{id}\n"
|
||||
"وضعیت: {status}\n"
|
||||
"اقلام تصمیمگیریشده: {count}"
|
||||
)
|
||||
ARRIVE_REPORT = (
|
||||
"🚚 ثبت رسید انجام شد\n"
|
||||
"استعلام #{id} — وضعیت: {status}\n{lines}"
|
||||
)
|
||||
ACCEPT_DONE_TS = "شرایط انتخابشده: {label}"
|
||||
|
||||
|
||||
def missing_block(missing: list[dict]) -> str:
|
||||
"""Checklist of missing vs. captured fields."""
|
||||
if not missing:
|
||||
return "همه فیلدها کامل است ✅"
|
||||
lines = []
|
||||
for m in missing:
|
||||
if m.get("ok"):
|
||||
mark = "✅"
|
||||
elif m.get("blocking"):
|
||||
mark = "❌"
|
||||
else:
|
||||
mark = "⚠️"
|
||||
lines.append(f"{mark} {m.get('label')}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def quote_review_text(payload: dict, missing: list[dict], status_note: str | None = None) -> str:
|
||||
lines = ["📋 پیشنویس استعلام"]
|
||||
|
||||
has_blocking = any(m.get("blocking") and not m.get("ok") for m in missing)
|
||||
has_warnings = any(not m.get("blocking") and not m.get("ok") for m in missing)
|
||||
|
||||
if not has_blocking and not has_warnings:
|
||||
lines.append("✅ همهی اطلاعات کامل است، آماده ثبت.")
|
||||
elif has_blocking:
|
||||
lines.append("⛔️ به دلیل وارد نکردن موارد اجباری (❌) امکان ثبت نهایی وجود ندارد.")
|
||||
else:
|
||||
lines.append("⚠️ پیشنویس ناقص است (موارد اختیاری خالی است)، اما امکان ثبت وجود دارد.")
|
||||
|
||||
if status_note:
|
||||
lines.append(status_note)
|
||||
|
||||
lines.append("━━━━━━━━━━━━━━\n")
|
||||
|
||||
# 🏢 تأمینکننده
|
||||
lines.append("🏢 تأمینکننده")
|
||||
sup_name = payload.get("supplier_name")
|
||||
is_official = payload.get("is_official", True)
|
||||
official_badge = " (رسمی)" if is_official else " (غیررسمی)"
|
||||
|
||||
if payload.get("supplier_id"):
|
||||
lines.append(f"{sup_name}{official_badge} ✅")
|
||||
if payload.get("supplier_location"):
|
||||
lines.append(f"📍 {payload['supplier_location']}")
|
||||
contact_parts = []
|
||||
if payload.get("supplier_phone"):
|
||||
contact_parts.append(fa_digits(payload["supplier_phone"]))
|
||||
if payload.get("supplier_contact_person"):
|
||||
contact_parts.append(str(payload["supplier_contact_person"]))
|
||||
if contact_parts:
|
||||
lines.append(f"📞 {' — '.join(contact_parts)}")
|
||||
elif sup_name:
|
||||
lines.append(f"{sup_name}{official_badge} ✨ (تأمینکننده جدید)")
|
||||
else:
|
||||
lines.append("— (مشخص نشده)")
|
||||
|
||||
cat_name = payload.get("supplier_category_name")
|
||||
if cat_name:
|
||||
lines.append(f"\n🏷️ موضوع: {cat_name}")
|
||||
|
||||
# 📦 اقلام
|
||||
items = payload.get("items") or []
|
||||
total_amount = 0.0
|
||||
has_any_price = False
|
||||
|
||||
if items:
|
||||
lines.append(f"\n📦 اقلام ({fa_num(len(items))})")
|
||||
default_proj = payload.get("project_name")
|
||||
for i, it in enumerate(items, start=1):
|
||||
name = it.get("material_name") or f"قلم {i}"
|
||||
title_parts = [str(name)]
|
||||
if it.get("brand_name"):
|
||||
title_parts.append(str(it["brand_name"]))
|
||||
if it.get("specs"):
|
||||
title_parts.append("، ".join(str(s) for s in it["specs"]))
|
||||
if not it.get("material_id"):
|
||||
title_parts.append("✨ (کالای جدید)")
|
||||
lines.append(f"{fa_num(i)}. {' — '.join(title_parts)}")
|
||||
|
||||
# Project
|
||||
item_proj = it.get("project_name") or default_proj
|
||||
if item_proj:
|
||||
lines.append(f" 🏗️ {item_proj}")
|
||||
else:
|
||||
lines.append(" 🏗️ پروژه مشخص نشده")
|
||||
|
||||
# Qty, Unit, Price, Total
|
||||
qty = it.get("quantity")
|
||||
unit = it.get("unit_name") or ("(واحد کالا)" if it.get("unit_id") else None)
|
||||
unit_price = it.get("unit_price")
|
||||
|
||||
calc_line = " "
|
||||
if qty is not None:
|
||||
calc_line += f"{fa_num(qty)} {unit or ''}".strip()
|
||||
else:
|
||||
calc_line += "مقدار نامشخص"
|
||||
|
||||
if unit_price:
|
||||
has_any_price = True
|
||||
calc_line += f" × {fa_num(unit_price)}"
|
||||
if qty is not None:
|
||||
try:
|
||||
line_sum = float(qty) * float(unit_price)
|
||||
total_amount += line_sum
|
||||
calc_line += f" = {fa_num(line_sum)} تومان"
|
||||
except (ValueError, TypeError):
|
||||
calc_line += " تومان"
|
||||
else:
|
||||
calc_line += " تومان"
|
||||
else:
|
||||
calc_line += " × — = — تومان"
|
||||
|
||||
lines.append(calc_line)
|
||||
|
||||
if has_any_price and total_amount > 0:
|
||||
lines.append(f"\n💰 مجموع مبلغ: {fa_num(total_amount)} تومان")
|
||||
elif not has_any_price:
|
||||
lines.append("\n💰 مجموع مبلغ: محاسبهنشده (قیمت اقلام وارد نشده)")
|
||||
else:
|
||||
lines.append("\n📦 اقلام: هنوز چیزی ثبت نشده ❌")
|
||||
|
||||
# 💳 شرایط پرداخت
|
||||
sheets = payload.get("termsheets") or []
|
||||
if sheets:
|
||||
lines.append(f"\n💳 شرایط پرداخت ({fa_num(len(sheets))})")
|
||||
for s_idx, t in enumerate(sheets, start=1):
|
||||
lbl = t.get("label") or f"گزینه {s_idx}"
|
||||
milestones = t.get("milestones") or t.get("schedule_steps") or []
|
||||
if milestones:
|
||||
lines.append(f"▫️ {lbl} — {fa_num(len(milestones))} مرحله")
|
||||
step_sum = 0.0
|
||||
for m in milestones:
|
||||
due = fa_date(m.get("due_date"))
|
||||
amt = m.get("amount")
|
||||
amt_str = f"{fa_num(amt)} تومان" if amt else "مبلغ توافقی"
|
||||
if amt:
|
||||
try:
|
||||
step_sum += float(amt)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
note = f" ({m['note']})" if m.get("note") else ""
|
||||
lines.append(f" • {due} — {amt_str}{note}")
|
||||
|
||||
if total_amount > 0 and step_sum > 0:
|
||||
diff = abs(total_amount - step_sum)
|
||||
if diff < 1.0:
|
||||
lines.append(" ✅ جمع مراحل با مجموع مبلغ برابر است")
|
||||
else:
|
||||
lines.append(f" ⚠️ اختلاف جمع مراحل با مجموع فاکتور: {fa_num(diff)} تومان")
|
||||
if len(items) > 0:
|
||||
lines.append(f" شامل: هر {fa_num(len(items))} قلم")
|
||||
else:
|
||||
disc = ""
|
||||
if t.get("discount_value"):
|
||||
disc = f" (تخفیف {fa_num(t['discount_value'])})"
|
||||
lines.append(f"▫️ {lbl}{disc}")
|
||||
elif items:
|
||||
lines.append("\n💳 شرایط پرداخت: نقدی / توافقی (پیشفرض)")
|
||||
|
||||
# Metadata
|
||||
meta_lines = []
|
||||
if payload.get("quote_date"):
|
||||
meta_lines.append(f"🗓️ تاریخ: {fa_date(payload['quote_date'])}")
|
||||
if payload.get("expiry_date"):
|
||||
meta_lines.append(f"⏳ اعتبار: {fa_date(payload['expiry_date'])}")
|
||||
if payload.get("proforma_file"):
|
||||
meta_lines.append(f"📎 پیشفاکتور: {payload['proforma_file']}")
|
||||
if payload.get("notes"):
|
||||
meta_lines.append(f"📝 توضیحات: {payload['notes']}")
|
||||
|
||||
if meta_lines:
|
||||
lines.append("")
|
||||
lines.extend(meta_lines)
|
||||
|
||||
# 📋 وضعیت فیلدها (اگر نقصی وجود داشته باشد)
|
||||
if has_blocking or has_warnings:
|
||||
lines.append("\n📋 وضعیت فیلدها:")
|
||||
lines.append(missing_block(missing))
|
||||
|
||||
# Footer
|
||||
lines.append("\n━━━━━━━━━━━━━━")
|
||||
if has_blocking:
|
||||
lines.append("برای تغییر، پیام بفرست (مثلاً «قیمت قلم ۲ شد ۴۶٬۰۰۰»).")
|
||||
lines.append("با پیام بعدی میتوانی چیزهای خالی را تکمیل کنی، یا با دکمهها ثبت/لغو کنی.")
|
||||
elif has_warnings:
|
||||
lines.append("برای تغییر، پیام بفرست (مثلاً «قیمت قلم ۲ شد ۴۶٬۰۰۰»).")
|
||||
lines.append("میتوانی چیزهای خالی را تکمیل کنی، یا با دکمهها ثبت/لغو کنی.")
|
||||
else:
|
||||
lines.append("برای تغییر، پیام بفرست (مثلاً «قیمت قلم ۲ شد ۴۶٬۰۰۰»).")
|
||||
lines.append("با پیام بعدی میتوانی چیزهای خالی را تکمیل کنی، یا با دکمهها ثبت/لغو کنی.")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def supplier_review_text(payload: dict, missing: list[dict]) -> str:
|
||||
lines = ["🏢 پیشنویس تأمینکننده جدید", f"نام: {esc(payload.get('name'))}"]
|
||||
lines.append(f"دسته: {esc(payload.get('category_name'))}")
|
||||
if payload.get("phone"):
|
||||
lines.append(f"تلفن: {esc(payload['phone'])}")
|
||||
if payload.get("location"):
|
||||
lines.append(f"موقعیت: {esc(payload['location'])}")
|
||||
if payload.get("contact_person"):
|
||||
lines.append(f"رابط: {esc(payload['contact_person'])}")
|
||||
lines.append("\nوضعیت فیلدها:")
|
||||
lines.append(missing_block(missing))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def material_review_text(payload: dict, missing: list[dict]) -> str:
|
||||
lines = ["📦 پیشنویس کالای جدید", f"نام: {esc(payload.get('name'))}"]
|
||||
lines.append(f"واحد اصلی: {esc(payload.get('unit_name'))}")
|
||||
if payload.get("topic"):
|
||||
lines.append(f"تاپیک: {esc(payload['topic'])}")
|
||||
lines.append("\nوضعیت فیلدها:")
|
||||
lines.append(missing_block(missing))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def quote_committed_report(created: dict, payload: dict, remaining: list[str]) -> str:
|
||||
lines = [f"✅ ثبت شد — استعلام #{esc(created.get('id'))}"]
|
||||
sup = (created.get("supplier") or {}).get("name") or payload.get("supplier_name")
|
||||
if sup:
|
||||
lines.append(f"تأمینکننده: {esc(sup)}")
|
||||
items = created.get("line_items") or []
|
||||
if items:
|
||||
lines.append(f"اقلام: {len(items)}")
|
||||
sheets = created.get("termsheets") or []
|
||||
if sheets:
|
||||
lines.append(f"شرایط پرداخت: {len(sheets)}")
|
||||
if payload.get("project_id"):
|
||||
lines.append("پروژه: پیوست شد")
|
||||
if remaining:
|
||||
lines.append("\nخالی ماندهها (بعداً میتوانی در برنامه تکمیل کنی):")
|
||||
lines += [f"⚠️ {esc(r)}" for r in remaining]
|
||||
lines.append("\nمرحله بعد: وقتی تصمیم گرفتی، بگو «استعلام را قبول کن» تا پذیرش را با هم انجام دهیم.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def supplier_committed_report(created: dict) -> str:
|
||||
return (
|
||||
f"✅ تأمینکننده ثبت شد — {esc(created.get('name'))}\n"
|
||||
f"دسته: {esc((created.get('category') or {}).get('name'))}"
|
||||
)
|
||||
|
||||
|
||||
def material_committed_report(created: dict) -> str:
|
||||
return f"✅ کالا ثبت شد — {esc(created.get('name'))}"
|
||||
|
||||
|
||||
def quote_line(quote: dict) -> str:
|
||||
sup = (quote.get("supplier") or {}).get("name") or quote.get("supplier_id") or "—"
|
||||
items = quote.get("line_items") or []
|
||||
item_names = []
|
||||
for it in items:
|
||||
mat = (it.get("material") or {}).get("name")
|
||||
if mat:
|
||||
qty = it.get("quantity")
|
||||
item_names.append(f"{mat} ({qty})" if qty else mat)
|
||||
tail = f" — {'، '.join(item_names[:3])}" + ("…" if len(item_names) > 3 else "") if item_names else ""
|
||||
return (
|
||||
f"#{str(quote.get('id'))[:8]} — {esc(sup)} — {STATUS_FA.get(quote.get('status'), quote.get('status'))} — "
|
||||
f"{fa_date(quote.get('quote_date'))}{tail}"
|
||||
)
|
||||
|
||||
|
||||
def status_report(kind: str, quotes: list[dict], extra_note: str | None = None) -> str:
|
||||
titles = {
|
||||
"pending": "🔵 استعلامهای در انتظار بررسی",
|
||||
"accepted": "🟢 پذیرفتهشده (در انتظار تحویل)",
|
||||
"arrived": "✅ رسیده",
|
||||
"expiring": "⏳ بهزودی منقضی میشوند (۷ روز آینده)",
|
||||
"recent": "🕘 آخرین استعلامها",
|
||||
}
|
||||
if not quotes:
|
||||
return f"{titles.get(kind, 'نتیجهای')}\nموردی نیست."
|
||||
lines = [titles.get(kind, "استعلامها")]
|
||||
if extra_note:
|
||||
lines.append(extra_note)
|
||||
lines += [f"• {quote_line(q)}" for q in quotes[:10]]
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,172 @@
|
||||
# Setup & Operations Guide
|
||||
|
||||
A Bale messenger bot that turns Persian **chat messages and voice notes** into
|
||||
procurement actions in the ERP. It works **draft-first**: it captures what it
|
||||
understood, tells you exactly **what it did** and **what is still empty**, and
|
||||
**commits only after you approve** with a ✅ button.
|
||||
|
||||
Bale's Bot API is a Telegram-compatible clone hosted at `tapi.bale.ai`, so the
|
||||
bot uses the familiar `getUpdates` long-polling — **no public port, no webhook,
|
||||
no TLS setup**.
|
||||
|
||||
---
|
||||
|
||||
## What it can do (v1)
|
||||
|
||||
| Activity | Example (voice or text) | Committed via |
|
||||
|---|---|---|
|
||||
| Register a supplier quote | «سازان نو برای پروژه برج آسمان ۵۰۰ کیلو میلگرد A3 سایز ۱۴، هر کیلو ۴۵ تومن، پرداخت ۳ فاز» | `POST /quotes` (nested create) |
|
||||
| Add a supplier | «تأمینکننده جدید پروفیل بر، دسته لوله و اتصالات، تلفن ۰۹۱۲…» | `POST /suppliers` |
|
||||
| Add a material | «کالای جدید میلگرد A3 سایز ۱۸ با واحد کیلوگرم» | `POST /materials` |
|
||||
| Status queries | «استعلامهای در انتظار؟» / «کدامها بهزودی منقضی میشوند؟» | `GET /quotes?…` |
|
||||
| Accept a quote | «استعلام سازان نو را قبول کن» | `PATCH /quotes/{id}/accept` |
|
||||
| Announce a delivery | «اقلام سازان نو رسید، ۵۰۰ و ۲۰۰» | `PATCH /quotes/{id}/arrive-items` |
|
||||
|
||||
### The draft → approve loop (the core idea)
|
||||
|
||||
1. You send a message (or voice note). The bot converts voice to text
|
||||
(STT) and extracts an **intent + entities** (LLM).
|
||||
2. Names are fuzzy-matched against the ERP (supplier / material / unit /
|
||||
project). Multiple candidates → inline **pick buttons**; no match → it is
|
||||
listed as missing.
|
||||
3. The bot replies with a **review card**: everything it captured, plus a
|
||||
✅/⚠️/❌ **list of what is still empty** («یادآوری چیزهای خالی»).
|
||||
4. You keep chatting to refine («قیمت میلگرد ۴۷ شد», «پروژه برج آسمان») — the
|
||||
open draft is updated and re-rendered.
|
||||
5. **Nothing is written to the ERP until you tap ✅ ثبت نهایی.** Partial
|
||||
commits are possible («ثبت فقط اقلام مشخص») because the API deliberately
|
||||
tolerates incomplete quotes. After commit you get the created quote id and
|
||||
the list of fields that were left empty.
|
||||
|
||||
### Team access & attribution
|
||||
|
||||
- Every user links their Bale chat to their ERP account once: `/start` →
|
||||
Casdoor **username** → **password** (validated through the real
|
||||
`POST /auth/login`; the password is used once and never stored).
|
||||
- Mutating API calls carry `X-Actor-Id: <users.id>` so `quote_events.actor`
|
||||
records the **human** who asked, not the bot. (The bot's container talks to
|
||||
the api in-network: `http://api:8000/api/v1`.)
|
||||
- `BALE_ADMIN_CHAT_IDS` may list chat ids that bypass linking.
|
||||
|
||||
### Commands
|
||||
|
||||
`/start` · `/help` · `/drafts` (open drafts) · `/cancel` (abort draft/flow) ·
|
||||
`/whoami` · `/unlink`
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
1. **Create the bot** in Bale's BotFather and copy the token.
|
||||
2. **Configure an AI provider** — any OpenAI-compatible endpoint works for
|
||||
both STT (`POST {base}/audio/transcriptions`, Persian) and the LLM
|
||||
(`POST {base}/chat/completions`, JSON mode). Examples: OpenAI `whisper-1`
|
||||
+ `gpt-4o-mini`, or any proxy/provider you already use.
|
||||
3. **Compose** (dev):
|
||||
|
||||
```powershell
|
||||
# env passed at compose level (or a .env next to docker-compose.yml)
|
||||
$env:BALE_BOT_TOKEN = "<token>"
|
||||
$env:AI_BASE_URL = "https://api.openai.com/v1"
|
||||
$env:AI_API_KEY = "<key>"
|
||||
|
||||
docker compose up -d --build bot
|
||||
docker compose logs -f bot
|
||||
```
|
||||
|
||||
For Coolify (`docker-compose.coolify.yml`) set the same variables as
|
||||
service-level env vars; the bot container needs no domain/labels.
|
||||
|
||||
### Environment variables (prefix `PROCUREMENT_BOT_`)
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `BOT_TOKEN` | — (required) | token from Bale's BotFather |
|
||||
| `BALE_API_BASE` | `https://tapi.bale.ai` | Bot API base (overridable for tests) |
|
||||
| `API_BASE_URL` | `http://localhost:8000/api/v1` | ERP api (in Docker: `http://api:8000/api/v1`) |
|
||||
| `DATABASE_URL` | same postgres as the api | bot's own tables (`bot_*`) |
|
||||
| `AI_BASE_URL` / `AI_API_KEY` | — | OpenAI-compatible STT+LLM provider |
|
||||
| `STT_MODEL` / `LLM_MODEL` | `whisper-1` / `gpt-4o-mini` | model names at the provider |
|
||||
| `ADMIN_CHAT_IDS` | — | comma-separated Bale chat ids allowed without linking |
|
||||
| `SERVICE_TOKEN` | — | optional bearer for the day procurement endpoints enforce auth |
|
||||
| `DRAFT_TTL_HOURS` | `24` | drafts older than this are marked expired |
|
||||
|
||||
In compose these are fed from `BALE_BOT_TOKEN`, `BALE_API_BASE`, `AI_BASE_URL`,
|
||||
`AI_API_KEY`, `AI_STT_MODEL`, `AI_LLM_MODEL`, `BALE_ADMIN_CHAT_IDS`,
|
||||
`BALE_SERVICE_TOKEN`.
|
||||
|
||||
---
|
||||
|
||||
## How it's built
|
||||
|
||||
```
|
||||
bot/
|
||||
├── Dockerfile python:3.13-slim + ffmpeg (voice conversion)
|
||||
├── app/
|
||||
│ ├── main.py ensure tables → sweeper task → poll loop
|
||||
│ ├── config.py pydantic-settings, PROCUREMENT_BOT_ prefix
|
||||
│ ├── bale/client.py getUpdates/sendMessage/edit/callbacks/getFile+download
|
||||
│ ├── api/client.py ERP /api/v1 client (X-Actor-Id attribution)
|
||||
│ ├── stt.py ffmpeg (OGG/Opus → MP3) → /audio/transcriptions
|
||||
│ ├── nlu.py Persian intent+entity extraction (JSON mode)
|
||||
│ ├── resolve.py fuzzy name→id matching (difflib + ilike search)
|
||||
│ ├── drafts.py draft store + QuoteCreate payload builder + missing-field logic
|
||||
│ ├── keyboards.py inline keyboards (callback_data ≤ 64 B op-codes)
|
||||
│ ├── texts.py Persian message templates
|
||||
│ ├── runtime.py context, link store, in-memory pending flows
|
||||
│ ├── models.py / db_init.py bot_links / bot_drafts / bot_messages / bot_meta (idempotent DDL)
|
||||
│ └── handlers/ dispatch, commands, callbacks, links,
|
||||
│ quote, supplier, material, queries, decisions
|
||||
└── tools/smoke_test.py offline end-to-end harness (fake Bale/ERP/AI, in-memory store)
|
||||
```
|
||||
|
||||
Design notes:
|
||||
|
||||
- **Draft state lives in postgres** (`bot_drafts.payload` JSONB in the shape of
|
||||
the eventual API request), so drafts survive bot restarts; short-lived
|
||||
interactions (linking, quote picking, accept/arrive confirmations) are
|
||||
in-memory with a 30-minute TTL.
|
||||
- **Callback grammar** fits Bale's 64-byte `callback_data`:
|
||||
`q:ok:{draft8}` (commit), `pk:{draft8}:{field}:{idx}` (pick candidate),
|
||||
`ac:ts:{i}` (choose payment terms), `ar:full` / `ar:ok` (delivery)…
|
||||
- **Persian handling**: Persian/Arabic digits are normalized before parsing;
|
||||
fuzzy matching folds ZWNJ and Arabic ya/ka; all user-facing text is Persian.
|
||||
- **Voice notes** arrive as OGG/Opus; ffmpeg converts to mono 16 kHz MP3
|
||||
before hitting the STT endpoint (accepted by essentially all providers).
|
||||
- The **accept flow echoes the termsheet's existing `schedule_steps`** back to
|
||||
`PATCH /quotes/{id}/accept` (the API replaces steps with what you send, so
|
||||
the bot must send the current ones).
|
||||
|
||||
### Attribution tweak in the api (backward compatible)
|
||||
|
||||
`backend/app/utils/security.py::get_actor` now prefers an `X-Actor-Id` header
|
||||
over the raw bearer token when attributing `quote_events.actor`. Requests
|
||||
without that header behave exactly as before.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Offline harness (no Bale token, no DB, no AI key — stubs everything and drives
|
||||
the real handler pipeline with fake updates):
|
||||
|
||||
```powershell
|
||||
docker compose run --rm bot python tools/smoke_test.py
|
||||
# or, with the requirements installed locally:
|
||||
cd bot && python tools/smoke_test.py
|
||||
```
|
||||
|
||||
Covers: linking, voice→draft, ambiguous-material pick buttons, unit fallback,
|
||||
draft refinement, commit payload shape (temp_id cross-references), supplier /
|
||||
material commits, status digest, accept payload (steps echoed, per-item price),
|
||||
arrival quantities («۵۰۰ و ۲۰۰»), cancel and unknown-intent handling.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause |
|
||||
|---|---|
|
||||
| Bot silent | wrong `BALE_BOT_TOKEN`; check `docker compose logs bot` |
|
||||
| «پردازش هوشمند تنظیم نشده است» | `AI_BASE_URL` / `AI_API_KEY` missing |
|
||||
| Voice → «نتوانستم پیام صوتی را تبدیل کنم» | provider rejected the model name (`STT_MODEL`) or key |
|
||||
| «ERP API unreachable» | api container not up / wrong `API_BASE_URL` |
|
||||
| Draft disappears | TTL expiry (`DRAFT_TTL_HOURS`) or someone pressed ❌ / `/cancel` |
|
||||
Binary file not shown.
@@ -0,0 +1,44 @@
|
||||
# ERP integration pack
|
||||
|
||||
The agent is a **standalone container** — it talks to the ERP exclusively over
|
||||
HTTP and keeps its own state in its own `bot_*` tables. Integrating it into the
|
||||
[ERP stack](https://git.bennu-api-bahrestan.ir/ali_mohaghegh/ERP) needs exactly
|
||||
two things:
|
||||
|
||||
## 1. Compose service
|
||||
|
||||
Add [`erp-compose-service.yml`](erp-compose-service.yml) to the ERP's
|
||||
`docker-compose.yml` (and the Coolify variant). The bot long-polls Bale, so it
|
||||
needs **no ports, no webhook, no TLS** — it joins the compose `default` network
|
||||
and reaches `http://api:8000/api/v1` in-network.
|
||||
|
||||
Credentials/env are passed at compose level from a `.env` next to the compose
|
||||
file: `BALE_BOT_TOKEN`, `AI_BASE_URL`, `AI_API_KEY`, `AI_LLM_MODEL`
|
||||
(see [`../.env.example`](../.env.example) for the full list).
|
||||
|
||||
> If your stack is started with multiple `-f` files (e.g. a prod_v2 checkout +
|
||||
> port-remap override), keep using the same chain — the `bot` service merges in
|
||||
> like any other service and the override's host-port remaps don't affect it.
|
||||
|
||||
## 2. Per-user attribution in the API
|
||||
|
||||
Apply [`x-actor-id.patch`](x-actor-id.patch) — a ~6-line, fully
|
||||
backward-compatible change to `backend/app/utils/security.py`:
|
||||
|
||||
- before: `get_actor` returns the raw `Authorization: Bearer …` string and
|
||||
`quote_events.actor` stores the bot's token;
|
||||
- after: an `X-Actor-Id: <users.id>` header (sent by the bot for every mutating
|
||||
call, carrying the **linked ERP user's id**) takes precedence, so the audit
|
||||
trail records the human who asked, not the bot.
|
||||
|
||||
Requests without the header behave exactly as before.
|
||||
|
||||
## What the agent does NOT touch
|
||||
|
||||
- No ERP code is imported by the bot (HTTP-only, own postgres tables
|
||||
`bot_links` / `bot_drafts` / `bot_messages` / `bot_meta`, created idempotently
|
||||
at startup with `CREATE TABLE IF NOT EXISTS`).
|
||||
- No Alembic migration needed — the bot owns its schema.
|
||||
- The ERP's data model stays untouched; committed drafts flow through the
|
||||
ordinary public endpoints (`POST /quotes`, `PATCH /quotes/{id}/accept`,
|
||||
`PATCH /quotes/{id}/arrive-items`, `POST /suppliers`, `POST /materials`).
|
||||
@@ -0,0 +1,39 @@
|
||||
# ==========================================================================
|
||||
# Add this service to the ERP stack's docker-compose.yml (and mirror it in
|
||||
# docker-compose.coolify.yml with `container_name: procurement-bot`).
|
||||
#
|
||||
# It expects THIS repository checked out (or vendored) next to the ERP repo
|
||||
# as `./bot` — either `git clone … bot` inside the ERP checkout, or change
|
||||
# `context:` to an absolute/relative path to this repo.
|
||||
#
|
||||
# In-container URLs are unaffected by host port remaps (postgres/api are
|
||||
# reached over the compose network, not host ports).
|
||||
# ==========================================================================
|
||||
services:
|
||||
bot:
|
||||
build:
|
||||
context: ./bot # ← path to this repository
|
||||
dockerfile: Dockerfile
|
||||
container_name: procurement-bot
|
||||
|
||||
environment:
|
||||
PROCUREMENT_BOT_BOT_TOKEN: ${BALE_BOT_TOKEN:-}
|
||||
PROCUREMENT_BOT_BALE_API_BASE: ${BALE_API_BASE:-https://tapi.bale.ai}
|
||||
PROCUREMENT_BOT_API_BASE_URL: http://api:8000/api/v1
|
||||
PROCUREMENT_BOT_DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/procurement
|
||||
PROCUREMENT_BOT_AI_BASE_URL: ${AI_BASE_URL:-}
|
||||
PROCUREMENT_BOT_AI_API_KEY: ${AI_API_KEY:-}
|
||||
PROCUREMENT_BOT_STT_MODEL: ${AI_STT_MODEL:-whisper-1}
|
||||
PROCUREMENT_BOT_STT_BASE_URL: ${AI_STT_BASE_URL:-}
|
||||
PROCUREMENT_BOT_STT_API_KEY: ${AI_STT_API_KEY:-}
|
||||
PROCUREMENT_BOT_LLM_MODEL: ${AI_LLM_MODEL:-gpt-4o-mini}
|
||||
PROCUREMENT_BOT_ADMIN_CHAT_IDS: ${BALE_ADMIN_CHAT_IDS:-}
|
||||
PROCUREMENT_BOT_SERVICE_TOKEN: ${BALE_SERVICE_TOKEN:-}
|
||||
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
api:
|
||||
condition: service_started
|
||||
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,18 @@
|
||||
diff --git a/backend/app/utils/security.py b/backend/app/utils/security.py
|
||||
index a78c1cb..75e7349 100644
|
||||
--- a/backend/app/utils/security.py
|
||||
+++ b/backend/app/utils/security.py
|
||||
@@ -7,7 +7,13 @@ def get_actor(request: Request) -> str | None:
|
||||
|
||||
This is the reserved placeholder from the contract — no authentication
|
||||
or authorization is enforced yet.
|
||||
+
|
||||
+ Services acting on behalf of a user (the Bale bot) pass ``X-Actor-Id``
|
||||
+ (users.id); it wins over the raw token so the audit trail shows the human.
|
||||
"""
|
||||
+ actor_id = request.headers.get("X-Actor-Id")
|
||||
+ if actor_id:
|
||||
+ return actor_id.strip()
|
||||
auth = request.headers.get("Authorization")
|
||||
if auth and auth.lower().startswith("bearer "):
|
||||
return auth[7:].strip() or None
|
||||
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
httpx>=0.27.0
|
||||
sqlalchemy[asyncio]>=2.0.30
|
||||
asyncpg>=0.29.0
|
||||
pydantic>=2.7.0
|
||||
pydantic-settings>=2.2.0
|
||||
@@ -0,0 +1,448 @@
|
||||
"""Offline smoke test for the Bale bot — no Bale network, no postgres, no AI keys.
|
||||
|
||||
It stubs the Bale client, the ERP API client, STT and the NLU module, replaces
|
||||
the draft store with an in-memory fake, then drives the real handler pipeline
|
||||
end-to-end through fake updates (text, voice, inline-button callbacks).
|
||||
|
||||
Run inside the bot container (or anywhere the requirements are installed):
|
||||
|
||||
python tools/smoke_test.py
|
||||
|
||||
Exit code 0 = all checks passed.
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
# asyncpg is only needed when a real connection opens; stub it so app.db imports.
|
||||
try:
|
||||
import asyncpg # noqa: F401
|
||||
except ImportError:
|
||||
sys.modules.setdefault("asyncpg", types.ModuleType("asyncpg"))
|
||||
|
||||
CHAT = 100200300
|
||||
|
||||
_passed = _failed = 0
|
||||
|
||||
|
||||
def check(name: str, ok: bool, detail: str = "") -> None:
|
||||
global _passed, _failed
|
||||
ok = bool(ok)
|
||||
mark = "PASS" if ok else "FAIL"
|
||||
print(f"[{mark}] {name}" + (f" — {detail}" if detail and not ok else ""))
|
||||
_passed += ok
|
||||
_failed += (not ok)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# fakes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class FakeBale:
|
||||
def __init__(self):
|
||||
self.sent: list[dict] = []
|
||||
self.edited: list[dict] = []
|
||||
self.answered: list[str] = []
|
||||
self._mid = 0
|
||||
|
||||
async def send_message(self, chat_id, text, reply_markup=None, reply_to=None):
|
||||
self._mid += 1
|
||||
self.sent.append({"chat_id": chat_id, "text": text, "reply_markup": reply_markup})
|
||||
return {"message_id": self._mid, "chat": {"id": chat_id}}
|
||||
|
||||
async def edit_message_text(self, chat_id, message_id, text, reply_markup=None):
|
||||
self.edited.append({"chat_id": chat_id, "message_id": message_id, "text": text,
|
||||
"reply_markup": reply_markup})
|
||||
return {"message_id": message_id}
|
||||
|
||||
async def answer_callback_query(self, callback_query_id, text=None):
|
||||
self.answered.append(callback_query_id)
|
||||
|
||||
async def send_chat_action(self, chat_id, action="typing"):
|
||||
pass
|
||||
|
||||
async def download_file(self, file_id):
|
||||
return b"fake-ogg-bytes"
|
||||
|
||||
def last_text(self) -> str:
|
||||
return self.sent[-1]["text"] if self.sent else ""
|
||||
|
||||
def last_markup(self) -> dict | None:
|
||||
return self.sent[-1]["reply_markup"] if self.sent else None
|
||||
|
||||
|
||||
class FakeApi:
|
||||
"""Canned ERP responses + captured mutation payloads."""
|
||||
|
||||
def __init__(self):
|
||||
self.created_quotes: list[dict] = []
|
||||
self.created_suppliers: list[dict] = []
|
||||
self.created_materials: list[dict] = []
|
||||
self.accept_payloads: list[dict] = []
|
||||
self.arrive_payloads: list[dict] = []
|
||||
self.login_calls: list[dict] = []
|
||||
|
||||
async def login(self, username, password):
|
||||
self.login_calls.append({"username": username, "password": password})
|
||||
if username == "mohaghegh" and password == "secret":
|
||||
return {"access_token": "jwt", "user": {"id": "user-1", "first_name": "علی",
|
||||
"last_name": "محقق", "role": "procurement_manager"}}
|
||||
raise RuntimeError("invalid credentials")
|
||||
|
||||
async def list_suppliers(self, q=None, page_size=20):
|
||||
return [{"id": "sup-1", "name": "سازان نو", "category_id": "cat-2"}]
|
||||
|
||||
async def create_supplier(self, payload, actor_id=None):
|
||||
self.created_suppliers.append(payload)
|
||||
return {"id": "sup-new", "name": payload["name"],
|
||||
"category": {"id": payload["category_id"], "name": "لوله و اتصالات"}}
|
||||
|
||||
async def list_materials(self, q=None, page_size=20):
|
||||
return [
|
||||
{"id": "mat-1", "name": "میلگرد A3 سایز ۱۴", "canonical_unit_id": 3},
|
||||
{"id": "mat-2", "name": "میلگرد A3 سایز ۱۶", "canonical_unit_id": 3},
|
||||
{"id": "mat-3", "name": "شیر فلکه چهارطرفه", "canonical_unit_id": 2},
|
||||
]
|
||||
|
||||
async def create_material(self, payload, actor_id=None):
|
||||
self.created_materials.append(payload)
|
||||
return {"id": "mat-new", "name": payload["name"]}
|
||||
|
||||
async def list_units(self, q=None, page_size=100):
|
||||
return [{"id": 2, "code": "adad", "name_fa": "عدد"},
|
||||
{"id": 3, "code": "kg", "name_fa": "کیلوگرم"}]
|
||||
|
||||
async def list_projects(self, page_size=100):
|
||||
return [{"id": "prj-1", "name": "برج آسمان"}]
|
||||
|
||||
async def list_categories(self, page_size=100):
|
||||
return [{"id": "cat-1", "name": "تأسیسات مکانیکی و آبی"},
|
||||
{"id": "cat-2", "name": "لوله و اتصالات"}]
|
||||
|
||||
def _expanded(self, quote_id, status):
|
||||
"""Realistic GET /quotes?expand=line_items,termsheets,supplier shape."""
|
||||
return {
|
||||
"id": quote_id, "supplier": {"name": "سازان نو"}, "project_id": None, "status": status,
|
||||
"quote_date": "2026-09-10T00:00:00Z", "expiry_date": "2026-09-20",
|
||||
"line_items": [
|
||||
{"id": "li-1", "material": {"name": "میلگرد A3 سایز ۱۴"}, "unit": {"name_fa": "کیلوگرم"},
|
||||
"quantity": 500, "unit_price": 45000, "projects": [],
|
||||
"decision": {"status": "chosen"} if status == "accepted" else None, "receipt": None},
|
||||
{"id": "li-2", "material": {"name": "شیر فلکه چهارطرفه"}, "unit": {"name_fa": "عدد"},
|
||||
"quantity": 200, "unit_price": 90000, "projects": [],
|
||||
"decision": {"status": "chosen"} if status == "accepted" else None, "receipt": None},
|
||||
],
|
||||
"termsheets": [{"id": "ts-1", "label": "۳ فاز", "discount_type": "none", "discount_value": 0,
|
||||
"schedule_steps": [{"step_no": 1, "due_date": "2026-10-01", "amount": 1000}]}],
|
||||
}
|
||||
|
||||
async def list_quotes(self, *, status=None, q=None, expand=None, sort=None, page=1, page_size=10):
|
||||
if status == "offered":
|
||||
return [self._expanded("q-aaaa1111", "offered")], {"total": 1}
|
||||
if status == "accepted":
|
||||
return [self._expanded("q-bbbb2222", "accepted")], {"total": 1}
|
||||
return [], {"total": 0}
|
||||
|
||||
async def get_quote(self, quote_id, expand=None, actor_id=None):
|
||||
return self._expanded(quote_id, "accepted" if quote_id.startswith("q-bbbb") else "offered")
|
||||
|
||||
async def create_quote(self, payload, actor_id=None):
|
||||
self.created_quotes.append(payload)
|
||||
return {"id": "q-new", "supplier": {"name": "سازان نو"},
|
||||
"line_items": payload.get("line_items") or [],
|
||||
"termsheets": payload.get("termsheets") or []}
|
||||
|
||||
async def accept_quote(self, quote_id, payload, actor_id=None):
|
||||
self.accept_payloads.append({"quote_id": quote_id, "payload": payload})
|
||||
return {"id": quote_id, "status": "accepted", "line_items": [{"id": "li-1", "decision": {}}]}
|
||||
|
||||
async def arrive_items(self, quote_id, payload, actor_id=None):
|
||||
self.arrive_payloads.append({"quote_id": quote_id, "payload": payload})
|
||||
return {"id": quote_id, "status": "arrived",
|
||||
"items": [{"line_item_id": it["line_item_id"], "received_qty": float(it["received_qty"])}
|
||||
for it in payload["items"]]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# in-memory draft store + link store
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MEM: dict = {"drafts": {}, "links": {}}
|
||||
|
||||
|
||||
def _install_fake_stores() -> None:
|
||||
import uuid as _uuid
|
||||
|
||||
import app.drafts as drafts
|
||||
import app.runtime as runtime
|
||||
|
||||
async def create(chat_id, kind, payload, source=None):
|
||||
draft_id = _uuid.uuid4()
|
||||
draft = types.SimpleNamespace(id=str(draft_id), chat_id=chat_id, kind=kind, payload=payload,
|
||||
missing=None, status="active", source=source,
|
||||
review_message_id=None)
|
||||
MEM["drafts"][str(draft_id)] = draft
|
||||
return draft
|
||||
|
||||
async def get_active(chat_id, kind=None):
|
||||
found = [d for d in MEM["drafts"].values()
|
||||
if d.chat_id == chat_id and d.status == "active" and (kind is None or d.kind == kind)]
|
||||
return max(found, key=lambda d: d.id) if found else None
|
||||
|
||||
async def get_by_prefix(prefix, chat_id=None):
|
||||
for draft in MEM["drafts"].values():
|
||||
if draft.id.startswith(prefix) and (chat_id is None or draft.chat_id == chat_id):
|
||||
return draft
|
||||
return None
|
||||
|
||||
async def save_payload(draft, missing=None, review_message_id=None):
|
||||
draft.missing = missing
|
||||
if review_message_id:
|
||||
draft.review_message_id = review_message_id
|
||||
|
||||
async def set_status(draft_id, status):
|
||||
if draft_id in MEM["drafts"]:
|
||||
MEM["drafts"][draft_id].status = status
|
||||
|
||||
async def expire_stale(ttl_hours):
|
||||
return 0
|
||||
|
||||
drafts.create, drafts.get_active, drafts.get_by_prefix = create, get_active, get_by_prefix
|
||||
drafts.save_payload, drafts.set_status, drafts.expire_stale = save_payload, set_status, expire_stale
|
||||
|
||||
async def link_get(self, chat_id):
|
||||
return MEM["links"].get(chat_id)
|
||||
|
||||
async def link_upsert(self, chat_id, user_id, username, display_name, role):
|
||||
MEM["links"][chat_id] = types.SimpleNamespace(chat_id=chat_id, user_id=user_id,
|
||||
casdoor_username=username,
|
||||
display_name=display_name, role=role)
|
||||
return MEM["links"][chat_id]
|
||||
|
||||
async def link_delete(self, chat_id):
|
||||
return MEM["links"].pop(chat_id, None) is not None
|
||||
|
||||
runtime.LinkStore.get, runtime.LinkStore.upsert, runtime.LinkStore.delete = link_get, link_upsert, link_delete
|
||||
|
||||
|
||||
async def fake_log(self, chat_id, direction, kind, body=None):
|
||||
pass
|
||||
|
||||
|
||||
NLU_QUEUE: list[dict] = []
|
||||
|
||||
|
||||
async def fake_understand(settings, text, draft_summary=None):
|
||||
return NLU_QUEUE.pop(0) if NLU_QUEUE else {"intent": "unknown", "patch": {}, "confidence": 0}
|
||||
|
||||
|
||||
async def fake_transcribe(settings, audio, mime_type=None, filename=None):
|
||||
return ("سازان نو برای پروژه برج آسمان ۵۰۰ کیلو میلگرد A3 سایز ۱۴ هر کیلو ۴۵ هزار تومن "
|
||||
"پرداخت سه فاز")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# driver
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_ctx():
|
||||
import app.handlers as H
|
||||
from app.resolve import Resolver
|
||||
from app.runtime import BotContext
|
||||
|
||||
bale, api = FakeBale(), FakeApi()
|
||||
ctx = BotContext(bale, api)
|
||||
ctx.bale = bale
|
||||
ctx.api = api
|
||||
ctx.resolver = Resolver(api)
|
||||
ctx._log = types.MethodType(fake_log, ctx)
|
||||
H.understand = fake_understand
|
||||
H.transcribe = fake_transcribe
|
||||
return ctx, bale, api
|
||||
|
||||
|
||||
def text_update(update_id, text):
|
||||
return {"update_id": update_id, "message": {"chat": {"id": CHAT}, "text": text, "from": {"id": CHAT}}}
|
||||
|
||||
|
||||
def voice_update(update_id):
|
||||
return {"update_id": update_id,
|
||||
"message": {"chat": {"id": CHAT}, "voice": {"file_id": "v1", "mime_type": "audio/ogg", "duration": 6}}}
|
||||
|
||||
|
||||
def callback_update(update_id, data):
|
||||
return {"update_id": update_id, "callback_query": {"id": f"cb{update_id}", "data": data,
|
||||
"message": {"chat": {"id": CHAT}}}}
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
_install_fake_stores()
|
||||
from app.drafts import build_quote_request, compute_quote_missing, merge_quote_patch, new_quote_payload
|
||||
import app.handlers as H
|
||||
from app.nlu import _extract_json, normalize_digits
|
||||
|
||||
# ---- pure logic -------------------------------------------------------
|
||||
check("digits: ۵۰۰→500", normalize_digits("۵۰۰ کیلو") == "500 کیلو")
|
||||
check("json extract: fenced", (_extract_json('```json\n{"a": 1}\n```') or {}) == {"a": 1})
|
||||
|
||||
payload = new_quote_payload()
|
||||
merge_quote_patch(payload, {
|
||||
"supplier_name": "سازان نو",
|
||||
"items": [{"material_name": "میلگرد A3", "quantity": 500, "unit_price": 45000}],
|
||||
"payment_terms": [{"label": "۳ فاز"}],
|
||||
})
|
||||
merge_quote_patch(payload, {"items": [{"material_name": "میلگرد A3", "quantity": 600}]})
|
||||
check("merge: no dup item", len(payload["items"]) == 1)
|
||||
check("merge: qty updated", payload["items"][0]["quantity"] == 600)
|
||||
check("merge: terms added", payload["termsheets"][0]["label"] == "۳ فاز")
|
||||
missing = compute_quote_missing(payload)
|
||||
check("missing: supplier empty", any(m["key"] == "supplier" and not m["ok"] for m in missing))
|
||||
body, problems = build_quote_request(payload)
|
||||
check("build: blocked without supplier", body is None)
|
||||
|
||||
# ---- pipeline: linking gate -------------------------------------------
|
||||
ctx, bale, api = build_ctx()
|
||||
await H.handle_update(ctx, text_update(1, "سلام"))
|
||||
check("gate: unlinked asked username", "نام کاربری" in bale.last_text())
|
||||
|
||||
await H.handle_update(ctx, text_update(2, "mohaghegh"))
|
||||
await H.handle_update(ctx, text_update(3, "secret"))
|
||||
check("link: login called", api.login_calls and api.login_calls[0]["username"] == "mohaghegh")
|
||||
check("link: linked greeting", "اتصال انجام شد" in bale.last_text())
|
||||
|
||||
# ---- voice → draft quote ----------------------------------------------
|
||||
NLU_QUEUE.append({"intent": "register_quote", "confidence": 0.9, "patch": {
|
||||
"supplier_name": "سازان نو", "project_name": "برج آسمان",
|
||||
"items": [{"material_name": "میلگرد", "quantity": 500, "unit_name": "کیلوگرم",
|
||||
"unit_price": 45000}],
|
||||
"payment_terms": [{"label": "۳ فاز", "description": "سه قسط"}],
|
||||
}})
|
||||
await H.handle_update(ctx, voice_update(4))
|
||||
draft = await _active("quote")
|
||||
check("voice: draft created", draft is not None)
|
||||
check("voice: supplier resolved", draft.payload.get("supplier_id") == "sup-1")
|
||||
check("voice: project resolved", draft.payload.get("project_id") == "prj-1")
|
||||
item = (draft.payload.get("items") or [{}])[0]
|
||||
check("voice: material ambiguous → pick buttons", "mat1" in (draft.payload.get("options") or {}))
|
||||
check("voice: review shows missing list", "وضعیت فیلدها" in bale.last_text())
|
||||
check("voice: review mentions unit", "کیلوگرم" in bale.last_text())
|
||||
|
||||
# pick the material via inline button
|
||||
markup = bale.last_markup() or {}
|
||||
pick_data = _first_callback(markup, "pk:")
|
||||
check("voice: pick callback present", pick_data is not None and ":mat1:0" in pick_data)
|
||||
await H.handle_update(ctx, callback_update(5, pick_data))
|
||||
item = (draft.payload.get("items") or [{}])[0]
|
||||
check("pick: material resolved", item.get("material_id") == "mat-1")
|
||||
check("pick: unit resolved from spoken name", str(item.get("unit_id")) == "3" and item.get("unit_explicit") is True)
|
||||
|
||||
# refine price via follow-up text
|
||||
NLU_QUEUE.append({"intent": "update_draft", "confidence": 0.8, "patch": {
|
||||
"items": [{"material_name": "میلگرد", "unit_price": 47000}]}})
|
||||
await H.handle_update(ctx, text_update(6, "قیمت میلگرد ۴۷ شد"))
|
||||
draft = await _active("quote")
|
||||
check("refine: price updated", (draft.payload["items"][0]["unit_price"]) == 47000)
|
||||
|
||||
# commit via ✅
|
||||
await H.handle_update(ctx, callback_update(7, f"q:ok:{draft.id[:8]}"))
|
||||
check("commit: quote posted", len(api.created_quotes) == 1)
|
||||
body = api.created_quotes[0]
|
||||
check("commit: supplier id", body.get("supplier_id") == "sup-1")
|
||||
check("commit: line item temp/refs", body["line_items"][0]["temp_id"] == "t1"
|
||||
and body["item_termsheets"][0]["line_item_temp_id"] == "t1")
|
||||
check("commit: price linked", body["item_termsheets"][0]["unit_price"] == 47000)
|
||||
check("commit: termsheet carried", body["termsheets"][0]["label"] == "۳ فاز")
|
||||
check("commit: report sent", "ثبت شد" in bale.last_text())
|
||||
check("commit: draft committed", (await _active("quote")) is None)
|
||||
|
||||
# ---- add supplier ------------------------------------------------------
|
||||
NLU_QUEUE.append({"intent": "add_supplier", "confidence": 0.9, "patch": {
|
||||
"supplier": {"name": "پروفیل بر", "category_name": "لوله و اتصالات", "phone": "0912"}}})
|
||||
await H.handle_update(ctx, text_update(8, "تأمینکننده جدید پروفیل بر دسته لوله و اتصالات تلفن 0912"))
|
||||
draft = await _active("supplier")
|
||||
check("supplier: draft created", draft is not None)
|
||||
check("supplier: category resolved", draft.payload.get("category_id") == "cat-2")
|
||||
await H.handle_update(ctx, callback_update(9, f"s:ok:{draft.id[:8]}"))
|
||||
check("supplier: posted", api.created_suppliers and api.created_suppliers[0]["name"] == "پروفیل بر")
|
||||
check("supplier: report", "تأمینکننده ثبت شد" in bale.last_text())
|
||||
|
||||
# ---- status query ------------------------------------------------------
|
||||
NLU_QUEUE.append({"intent": "status_query", "confidence": 0.9,
|
||||
"patch": {"query": {"kind": "pending"}}})
|
||||
await H.handle_update(ctx, text_update(10, "استعلامهای در انتظار چیه؟"))
|
||||
check("query: pending digest", "در انتظار بررسی" in bale.last_text() and "#q-aaaa11" in bale.last_text())
|
||||
|
||||
# ---- accept flow -------------------------------------------------------
|
||||
NLU_QUEUE.append({"intent": "accept_quote", "confidence": 0.9,
|
||||
"patch": {"target": {"supplier_name": "سازان نو"}}})
|
||||
await H.handle_update(ctx, text_update(11, "استعلام سازان نو را قبول کن"))
|
||||
check("accept: summary shown", "پذیرش استعلام" in bale.last_text())
|
||||
flow = ctx.pending.get(CHAT)
|
||||
check("accept: pending flow", flow is not None and flow.kind == "accept")
|
||||
await H.handle_update(ctx, callback_update(12, "ac:ok"))
|
||||
check("accept: payload posted", len(api.accept_payloads) == 1)
|
||||
payload = api.accept_payloads[0]["payload"]
|
||||
check("accept: termsheet echoed with steps",
|
||||
payload["accepted_termsheets"][0]["id"] == "ts-1" and payload["accepted_termsheets"][0]["schedule_steps"])
|
||||
check("accept: decision price", payload["item_decisions"][0]["adjusted_unit_price"] == 45000)
|
||||
check("accept: decision index", payload["item_decisions"][0]["accepted_termsheet_index"] == 0)
|
||||
check("accept: report", "پذیرش انجام شد" in bale.last_text())
|
||||
|
||||
# ---- arrive flow -------------------------------------------------------
|
||||
NLU_QUEUE.append({"intent": "arrive_items", "confidence": 0.9,
|
||||
"patch": {"target": {"supplier_name": "سازان نو"}}})
|
||||
await H.handle_update(ctx, text_update(13, "اقلام سازان نو رسید"))
|
||||
check("arrive: items shown", "اعلام رسیدن" in bale.last_text())
|
||||
await H.handle_update(ctx, text_update(14, "۵۰۰ و ۲۰۰"))
|
||||
flow = ctx.pending.get(CHAT)
|
||||
check("arrive: quantities parsed", flow is not None and flow.kind == "arrive_confirm"
|
||||
and flow.data["quantities"] == {"li-1": 500.0, "li-2": 200.0})
|
||||
await H.handle_update(ctx, callback_update(15, "ar:ok"))
|
||||
check("arrive: payload posted", len(api.arrive_payloads) == 1
|
||||
and api.arrive_payloads[0]["payload"]["items"][0]["received_qty"] == 500)
|
||||
check("arrive: report", "ثبت رسید انجام شد" in bale.last_text())
|
||||
|
||||
# ---- add material ------------------------------------------------------
|
||||
NLU_QUEUE.append({"intent": "add_material", "confidence": 0.9,
|
||||
"patch": {"material": {"name": "میلگرد A3 سایز ۱۸", "unit_name": "کیلوگرم"}}})
|
||||
await H.handle_update(ctx, text_update(16, "کالای جدید میلگرد A3 سایز ۱۸ با واحد کیلوگرم"))
|
||||
draft = await _active("material")
|
||||
check("material: draft created", draft is not None)
|
||||
check("material: unit resolved", str(draft.payload.get("canonical_unit_id")) == "3")
|
||||
await H.handle_update(ctx, callback_update(17, f"m:ok:{draft.id[:8]}"))
|
||||
check("material: posted", api.created_materials
|
||||
and api.created_materials[0]["canonical_unit_id"] == 3)
|
||||
|
||||
# ---- cancel / unknown --------------------------------------------------
|
||||
NLU_QUEUE.append({"intent": "register_quote", "confidence": 0.9, "patch": {"supplier_name": "سازان نو"}})
|
||||
await H.handle_update(ctx, text_update(18, "استعلام از سازان نو"))
|
||||
await H.handle_update(ctx, text_update(19, "/cancel"))
|
||||
check("cancel: draft dropped", (await _active("quote")) is None and "لغو شد" in bale.last_text())
|
||||
|
||||
NLU_QUEUE.append({"intent": "unknown", "confidence": 0.0, "patch": {}})
|
||||
await H.handle_update(ctx, text_update(20, "اسپاگتی بپز"))
|
||||
check("unknown: hint sent", "متوجه منظورت نشد" in bale.last_text())
|
||||
|
||||
print(f"\n{['FAILED', 'ALL PASSED'][int(_failed == 0)]}: {_passed} passed, {_failed} failed")
|
||||
return 1 if _failed else 0
|
||||
|
||||
|
||||
async def _active(kind):
|
||||
from app import drafts as draft_store
|
||||
return await draft_store.get_active(CHAT, kind)
|
||||
|
||||
|
||||
def _first_callback(markup: dict | None, prefix: str) -> str | None:
|
||||
for row in (markup or {}).get("inline_keyboard") or []:
|
||||
for btn in row:
|
||||
if btn.get("callback_data", "").startswith(prefix):
|
||||
return btn["callback_data"]
|
||||
return None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(asyncio.run(main()))
|
||||
Reference in New Issue
Block a user