feat(bot): add strict whitelist auth bypass, multi-STT consensus pipeline, and applicant logging
This commit is contained in:
@@ -250,3 +250,24 @@ def propose_project_distribution(project_names: list[str], items: list[dict]) ->
|
||||
mat_name = it.get("material_name") or f"قلم {idx + 1}"
|
||||
proposals.append((mat_name, assigned_p))
|
||||
return proposals
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Authorized Whitelist Users (Static Role Configuration)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
AUTHORIZED_USERS: dict[int, dict] = {
|
||||
1461166742: {
|
||||
"user_id": "03091956-99ad-4729-923a-a6aa1c5c13be",
|
||||
"display_name": "مدیر تدارکات",
|
||||
"role": "procurement_manager",
|
||||
"is_admin": True,
|
||||
},
|
||||
1839839922: {
|
||||
"user_id": "03091956-99ad-4729-923a-a6aa1c5c13be",
|
||||
"display_name": "همکار / تست",
|
||||
"role": "procurement_manager",
|
||||
"is_admin": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,10 @@ async def handle_update(ctx: BotContext, update: dict) -> None:
|
||||
|
||||
|
||||
async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
|
||||
user = await _gate(ctx, chat_id, message)
|
||||
if user is None:
|
||||
return
|
||||
|
||||
text = (message.get("text") or message.get("caption") or "").strip()
|
||||
source = "text"
|
||||
voice_transcripts: list[dict] | None = None
|
||||
@@ -56,10 +60,6 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
|
||||
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
|
||||
|
||||
@@ -272,25 +272,58 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
|
||||
# helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
UNREGISTERED_FILE = Path(__file__).resolve().parent.parent / "unregistered_users.json"
|
||||
|
||||
|
||||
def _record_unregistered_user(chat_id: int, message: dict) -> None:
|
||||
from_user = message.get("from") or {}
|
||||
record = {
|
||||
"chat_id": chat_id,
|
||||
"username": from_user.get("username"),
|
||||
"first_name": from_user.get("first_name"),
|
||||
"last_name": from_user.get("last_name"),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"text_preview": (message.get("text") or "")[:100],
|
||||
}
|
||||
records = []
|
||||
try:
|
||||
if UNREGISTERED_FILE.exists():
|
||||
content = UNREGISTERED_FILE.read_text(encoding="utf-8").strip()
|
||||
if content:
|
||||
records = json.loads(content)
|
||||
if not isinstance(records, list):
|
||||
records = []
|
||||
except Exception:
|
||||
records = []
|
||||
records.append(record)
|
||||
try:
|
||||
UNREGISTERED_FILE.write_text(json.dumps(records, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
except Exception as e:
|
||||
logger.warning("Could not write to unregistered_users.json: %s", e)
|
||||
|
||||
|
||||
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
|
||||
"""Whitelist gate (Casdoor bypassed). Unregistered users are blocked and logged."""
|
||||
from app.defaults import AUTHORIZED_USERS
|
||||
|
||||
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)
|
||||
# 1. Whitelist check (only registered whitelist IDs)
|
||||
if chat_id in AUTHORIZED_USERS:
|
||||
u = AUTHORIZED_USERS[chat_id]
|
||||
return LinkedUser(
|
||||
chat_id=chat_id,
|
||||
user_id=u.get("user_id"),
|
||||
display_name=u.get("display_name"),
|
||||
role=u.get("role", "procurement_manager"),
|
||||
is_admin=u.get("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", {})
|
||||
# 2. Block unregistered user and record log
|
||||
_record_unregistered_user(chat_id, message)
|
||||
await ctx.send(chat_id, "دسترسی شما هنوز تعریف نشده است. درخواست شما جهت بررسی ثبت شد.")
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -29,12 +29,18 @@ async def handle_callback(ctx, cq: dict) -> 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)
|
||||
from app.defaults import AUTHORIZED_USERS
|
||||
|
||||
if chat_id in AUTHORIZED_USERS:
|
||||
u = AUTHORIZED_USERS[chat_id]
|
||||
return LinkedUser(
|
||||
chat_id=chat_id,
|
||||
user_id=u.get("user_id"),
|
||||
display_name=u.get("display_name"),
|
||||
role=u.get("role", "procurement_manager"),
|
||||
is_admin=u.get("is_admin", True),
|
||||
)
|
||||
await ctx.send(chat_id, "دسترسی شما هنوز تعریف نشده است. درخواست شما جهت بررسی ثبت شد.")
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -31,12 +31,15 @@ async def handle_command(ctx, chat_id: int, text: str) -> None:
|
||||
|
||||
|
||||
async def _start(ctx, chat_id: int) -> None:
|
||||
from app.defaults import AUTHORIZED_USERS
|
||||
|
||||
await ctx.send(chat_id, WELCOME)
|
||||
if chat_id in AUTHORIZED_USERS or chat_id in ctx.admin_chat_ids:
|
||||
return
|
||||
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", {})
|
||||
await ctx.send(chat_id, "برای شروع، حساب خود را متصل کن — نام کاربری Casdoor را بفرست:")
|
||||
ctx.pending.set(chat_id, "link_username", {})
|
||||
|
||||
|
||||
async def _drafts(ctx, chat_id: int) -> None:
|
||||
@@ -71,6 +74,13 @@ async def _cancel(ctx, chat_id: int) -> None:
|
||||
|
||||
|
||||
async def _whoami(ctx, chat_id: int) -> None:
|
||||
from app.defaults import AUTHORIZED_USERS
|
||||
|
||||
if chat_id in AUTHORIZED_USERS:
|
||||
u = AUTHORIZED_USERS[chat_id]
|
||||
role = ROLE_FA.get(u.get("role") or "", u.get("role") or "مدیر تدارکات")
|
||||
await ctx.send(chat_id, f"اتصال (لیست سفید): {esc(u.get('display_name'))} — {role}")
|
||||
return
|
||||
link = await ctx.links.get(chat_id)
|
||||
if chat_id in ctx.admin_chat_ids:
|
||||
await ctx.send(chat_id, "حالت ادمین (بدون اتصال Casdoor).")
|
||||
|
||||
@@ -151,6 +151,13 @@ async def understand(settings: Settings, text: str | list[dict], draft_summary:
|
||||
lines.append(f"- نسخه {idx} ({name}): «{t}»")
|
||||
lines.append("\nبا تلفیق این نسخهها و تصحیح خطاهای شنیداری، پیام را تحلیل کن و consensus_text را به همراه intent و patch بساز.")
|
||||
user_content = "\n".join(lines)
|
||||
elif isinstance(text, str) and ("مدل 1" in text or "مدل ۱" in text) and ("مدل 2" in text or "مدل ۲" in text):
|
||||
user_content = (
|
||||
"پیام صوتی کاربر توسط چند مدل تبدیل گفتار به متن (STT) به صورت زیر پیادهسازی شده است:\n"
|
||||
+ normalize_digits(text)
|
||||
+ "\n\nبا تلفیق این نسخهها و تصحیح خطاهای شنیداری، پیام را تحلیل کن و consensus_text را به همراه intent و patch بساز."
|
||||
)
|
||||
first_text = text
|
||||
else:
|
||||
user_content = normalize_digits(str(text))
|
||||
first_text = str(text)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1,77 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add project root to sys.path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from app.config import settings
|
||||
from app.bale.client import BaleClient
|
||||
from app.api.client import ErpClient
|
||||
from app.runtime import BotContext, LinkedUser
|
||||
from app.nlu import understand
|
||||
from app.handlers import quote
|
||||
from app.defaults import AUTHORIZED_USERS
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s — %(message)s")
|
||||
logger = logging.getLogger("send_analysis")
|
||||
|
||||
CHAT_ID = 1461166742
|
||||
|
||||
TRANSCRIPTS = [
|
||||
{
|
||||
"name": "مدل 1 (Whisper)",
|
||||
"text": "الان شبت بخیر می خواستیم که بیست و پنج تا کیسه عرض به خدمت شما که گچ و خاک از مصطفوی بخریم و کنارش هم یه پنجاه تا کیسه سیمان پنجاه کیلویی که اونا تقریبا در می آد پامون دونه ای بیست و پنج میلیون سیمانی ها پامون در می آد حدودا چهل میلیون اینا دونه ای بهمون گفته که می تونین سه قسط بهم بدید کلی پولش ها در طول زمان و یا بیاید یه بار پولش رو امروز بهم بدین یه تخفیف ده درصدی تقریبا براتون لحاظ می کنم عددش رو کم می کنم خریدمون هم عمدتا برای منشیانه و دفتر پیش فاکتورم گفته تا حدودا بیست روزی نامه براتون اوکیه همین",
|
||||
},
|
||||
{
|
||||
"name": "مدل 2 (Vosk)",
|
||||
"text": "سلام شبت بخیر. میخواستیم که 25 تا کیسه عرض به خدمت شما که گچ و خاک از مستفدی بخریم و کنارشم یه 50 تا کیسه سیمان 50 کیلویی که اونا تقریباً در میاد پامون دونهای 25 میلیون، سیمانیا پامون در میاد حدوداً 40 میلیون اینا دونهای. بهمون گفته که میتونین سه قسم بدین کلی پولشا در طول زمان و یا بیاین یه باره پولشو امروز بم بدین یه تخفیف 10 درصدی تقریباً بره دون، لحاظ میکنم عددشو کم میکنم. خریدم اونم اومدن برای منشیانه و دفتر. پیشفاکتورم گفته تا حدود یه 20 روز اینا برادون اوکیه. همین.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def run():
|
||||
print(f"Connecting to Bale and ERP API for Chat ID: {CHAT_ID}...")
|
||||
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)
|
||||
|
||||
user_info = AUTHORIZED_USERS.get(CHAT_ID, {})
|
||||
user = LinkedUser(
|
||||
chat_id=CHAT_ID,
|
||||
user_id=user_info.get("user_id"),
|
||||
display_name=user_info.get("display_name", "مدیر تدارکات"),
|
||||
role=user_info.get("role", "procurement_manager"),
|
||||
is_admin=user_info.get("is_admin", True),
|
||||
)
|
||||
|
||||
print("Sending transcripts to NLU (LLM) for multi-model consensus and entity extraction...")
|
||||
result = await understand(settings, TRANSCRIPTS)
|
||||
print("NLU Result:")
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
|
||||
consensus_text = result.get("consensus_text") or ""
|
||||
patch = result.get("patch") or {}
|
||||
|
||||
intro_msg = (
|
||||
f"🤖 **تحلیل هوشمند فایل صوتی (تجمیع خروجی چند مدل STT):**\n\n"
|
||||
f"📝 **متن استخراجشده و تصحیحشده (Consensus):**\n«{consensus_text}»\n\n"
|
||||
f"📋 پیشنویس استعلام خرید با مشخصات استخراجشده صادر گردید:"
|
||||
)
|
||||
await ctx.send(CHAT_ID, intro_msg)
|
||||
|
||||
# Create and render the draft quote
|
||||
await quote.handle_entities(
|
||||
ctx,
|
||||
CHAT_ID,
|
||||
user,
|
||||
patch,
|
||||
source="voice",
|
||||
)
|
||||
print("Draft quote sent successfully to Bale chat!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run())
|
||||
+47
-11
@@ -24,7 +24,7 @@ try:
|
||||
except ImportError:
|
||||
sys.modules.setdefault("asyncpg", types.ModuleType("asyncpg"))
|
||||
|
||||
CHAT = 100200300
|
||||
CHAT = 1461166742
|
||||
|
||||
_passed = _failed = 0
|
||||
|
||||
@@ -297,8 +297,10 @@ def build_ctx():
|
||||
return ctx, bale, api
|
||||
|
||||
|
||||
def text_update(update_id, text, reply_to=None):
|
||||
msg = {"message_id": update_id, "chat": {"id": CHAT}, "text": text, "from": {"id": CHAT}}
|
||||
def text_update(update_id, text, reply_to=None, chat_id=None, from_user=None):
|
||||
cid = chat_id or CHAT
|
||||
fuser = from_user or {"id": cid, "first_name": "مدیر تدارکات", "username": "proc_manager"}
|
||||
msg = {"message_id": update_id, "chat": {"id": cid}, "text": text, "from": fuser}
|
||||
if reply_to:
|
||||
msg["reply_to_message"] = {"message_id": reply_to}
|
||||
return {"update_id": update_id, "message": msg}
|
||||
@@ -318,12 +320,22 @@ 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
|
||||
from app.nlu import _extract_json, normalize_digits, understand
|
||||
from app.config import settings
|
||||
|
||||
# ---- pure logic -------------------------------------------------------
|
||||
check("digits: ۵۰۰→500", normalize_digits("۵۰۰ کیلو") == "500 کیلو")
|
||||
check("json extract: fenced", (_extract_json('```json\n{"a": 1}\n```') or {}) == {"a": 1})
|
||||
|
||||
# ---- multi-STT understand pipeline support ---------------------------
|
||||
multi_stt_samples = [
|
||||
{"name": "whisper", "text": "25 کیسه گچ از مصطفوی"},
|
||||
{"name": "vosk", "text": "بیست و پنج کیسه گچ از مصطفوی"},
|
||||
]
|
||||
stt_res = await understand(settings, multi_stt_samples)
|
||||
check("multi-stt: pipeline accepted list input", isinstance(stt_res, dict) and "consensus_text" in stt_res)
|
||||
check("multi-stt: consensus text generated", bool(stt_res.get("consensus_text")))
|
||||
|
||||
payload = new_quote_payload()
|
||||
merge_quote_patch(payload, {
|
||||
"items": [{"material_name": "میلگرد A3", "quantity": 500, "unit_price": 45000}],
|
||||
@@ -341,15 +353,39 @@ async def main() -> int:
|
||||
body, problems = build_quote_request(payload)
|
||||
check("build: blocked without supplier", body is None)
|
||||
|
||||
# ---- pipeline: linking gate -------------------------------------------
|
||||
# ---- pipeline: whitelist gate & unregistered blocking -----------------
|
||||
ctx, bale, api = build_ctx()
|
||||
await H.handle_update(ctx, text_update(1, "سلام"))
|
||||
check("gate: unlinked asked username", "نام کاربری" in bale.last_text())
|
||||
UNAUTH_CHAT = 999999999
|
||||
from app.handlers import UNREGISTERED_FILE
|
||||
if UNREGISTERED_FILE.exists():
|
||||
try:
|
||||
UNREGISTERED_FILE.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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())
|
||||
# 1. Unregistered user is blocked and recorded
|
||||
await H.handle_update(
|
||||
ctx,
|
||||
text_update(
|
||||
1,
|
||||
"سلام من متقاضی جدید هستم",
|
||||
chat_id=UNAUTH_CHAT,
|
||||
from_user={"id": UNAUTH_CHAT, "first_name": "کاربر متفرقه", "username": "intruder"},
|
||||
),
|
||||
)
|
||||
check("gate: unauthorized user blocked", "دسترسی شما هنوز تعریف نشده است" in bale.last_text())
|
||||
|
||||
import json
|
||||
unreg_records = json.loads(UNREGISTERED_FILE.read_text(encoding="utf-8")) if UNREGISTERED_FILE.exists() else []
|
||||
check(
|
||||
"gate: unregistered logged to JSON file",
|
||||
any(u.get("chat_id") == UNAUTH_CHAT and u.get("first_name") == "کاربر متفرقه" for u in unreg_records),
|
||||
)
|
||||
|
||||
# 2. Whitelisted user (1461166742) bypasses Casdoor completely
|
||||
await H.handle_update(ctx, text_update(2, "/whoami"))
|
||||
check("gate: whitelisted user active without casdoor", "مدیر تدارکات" in bale.last_text())
|
||||
check("gate: no casdoor login prompted", "نام کاربری Casdoor" not in bale.last_text())
|
||||
|
||||
# ---- voice → draft quote ----------------------------------------------
|
||||
NLU_QUEUE.append({"intent": "register_quote", "confidence": 0.9, "patch": {
|
||||
|
||||
Reference in New Issue
Block a user