Watch
1
0
Fork
You've already forked Seyyed_arc
0
forked from hesabix/arc
Seyyed_arc/hesabixAPI/app/services/telegram_ai_chat_service.py

686 lines
24 KiB
Python
Executable file
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
from typing import Dict, Any, List, Optional
from datetime import datetime
from sqlalchemy.orm import Session
import logging
from app.core.auth_dependency import AuthContext
from app.services.ai.ai_service import AIService
from app.services.ai.ai_content_sanitize import sanitize_assistant_content
from app.core.json_safe import json_dumps_safe
from app.services.providers.telegram_provider import TelegramProvider
from adapters.db.repositories.telegram_repo import TelegramAISessionRepository
from adapters.db.repositories.ai_chat_repository import AIChatSessionRepository, AIChatMessageRepository
from adapters.db.repositories.business_repo import BusinessRepository
from adapters.db.repositories.business_permission_repo import BusinessPermissionRepository
from app.services.business_service import get_user_businesses
from app.services.messenger_operator.crm_web_chat_access import user_has_crm_web_chat_messenger_access
from app.services.telegram_ai_chat_text import (
TELEGRAM_APPROVAL_HINT_FA,
split_telegram_text,
telegram_approval_inline_rows,
)
from app.services.ai.ai_write_guard import extract_pending_approval_ops
logger = logging.getLogger(__name__)
class TelegramAIChatService:
"""سرویس مدیریت چت AI از طریق تلگرام"""
def __init__(
self,
db: Session,
user_id: int,
chat_id: int,
telegram_provider: TelegramProvider
):
self.db = db
self.user_id = user_id
self.chat_id = chat_id
self.telegram_provider = telegram_provider
self.session_repo = TelegramAISessionRepository(db)
self.ai_chat_repo = AIChatSessionRepository(db)
self.ai_message_repo = AIChatMessageRepository(db)
self.business_repo = BusinessRepository(db)
self.permission_repo = BusinessPermissionRepository(db)
def _create_auth_context(self) -> AuthContext:
"""ایجاد AuthContext برای کاربر"""
from adapters.db.repositories.user_repo import UserRepository
user_repo = UserRepository(self.db)
user = user_repo.get_by_id(self.user_id)
if not user:
raise ValueError(f"User {self.user_id} not found")
from app.core.auth_dependency import AuthContext
# برای تلگرام، api_key_id را 0 می‌گذاریم (نشان می‌دهد از طریق تلگرام است)
return AuthContext(db=self.db, user=user, api_key_id=0)
def _build_inline_keyboard(self, buttons: List[List[Dict[str, str]]]) -> Dict[str, Any]:
"""ساخت Inline Keyboard از لیست دکمه‌ها"""
return {"inline_keyboard": buttons}
def send_main_menu(self, user_context: AuthContext) -> bool:
"""ارسال منوی اصلی بر اساس دسترسی کاربر"""
buttons: List[List[Dict[str, str]]] = []
# دکمه‌های عمومی
buttons.append([{"text": "💬 گفت‌وگو با AI", "callback_data": "menu:chat"}])
# دکمه تیکت‌ها (فقط برای اپراتورها)
if user_context.can_access_support_operator():
buttons.append([{"text": "🎫 تیکت‌های پشتیبانی", "callback_data": "menu:tickets"}])
# چت وب CRM (ویجت) — کاربران با مجوز پاسخ
u = user_context.user
if u is not None and user_has_crm_web_chat_messenger_access(self.db, u):
buttons.append([{"text": "🖥 چت وب CRM", "callback_data": "crm:start"}])
# دکمه مدیریت (فقط برای SuperAdmin)
if user_context.is_superadmin():
buttons.append([{"text": "👥 مدیریت سیستم", "callback_data": "menu:admin"}])
# دکمه جلسات
buttons.append([{"text": "📋 جلسات من", "callback_data": "menu:sessions"}])
keyboard = self._build_inline_keyboard(buttons)
return self.telegram_provider.send_text(
chat_id=self.chat_id,
text="منوی اصلی:",
reply_markup=keyboard
)
def send_chat_menu(self, user_context: AuthContext) -> bool:
"""ارسال منوی انتخاب کسب‌وکار برای گفت‌وگو"""
# دریافت کسب‌وکارهای کاربر
query_info = {"skip": 0, "take": 100}
businesses_result = get_user_businesses(self.db, self.user_id, query_info)
businesses = businesses_result.get("items", [])
buttons: List[List[Dict[str, str]]] = []
# دکمه‌های کسب‌وکارها (حداکثر 2 در هر ردیف)
for i in range(0, len(businesses), 2):
row: List[Dict[str, str]] = []
for j in range(2):
if i + j < len(businesses):
business = businesses[i + j]
business_name = business.get("name", f"کسب‌وکار {business.get('id')}")
# محدود کردن طول نام برای دکمه
if len(business_name) > 20:
business_name = business_name[:17] + "..."
row.append({
"text": f"🏢 {business_name}",
"callback_data": f"chat:business:{business.get('id')}"
})
if row:
buttons.append(row)
buttons.append([{"text": "➕ گفت‌وگوی جدید", "callback_data": "chat:new"}])
buttons.append([{"text": "⬅️ بازگشت", "callback_data": "back:main"}])
keyboard = self._build_inline_keyboard(buttons)
return self.telegram_provider.send_text(
chat_id=self.chat_id,
text="لطفاً کسب‌وکار خود را انتخاب کنید:",
reply_markup=keyboard
)
def send_sessions_menu(self, user_context: AuthContext) -> bool:
"""ارسال منوی جلسات"""
sessions = self.session_repo.get_user_sessions(self.user_id, self.chat_id, limit=10)
if not sessions:
buttons = [
[{"text": "➕ گفت‌وگوی جدید", "callback_data": "chat:new"}],
[{"text": "⬅️ بازگشت", "callback_data": "back:main"}]
]
keyboard = self._build_inline_keyboard(buttons)
return self.telegram_provider.send_text(
chat_id=self.chat_id,
text="جلسه‌ای وجود ندارد. برای شروع گفت‌وگوی جدید، دکمه زیر را بزنید:",
reply_markup=keyboard
)
buttons: List[List[Dict[str, str]]] = []
# دکمه‌های جلسات (3 در هر ردیف)
for i in range(0, len(sessions), 3):
row: List[Dict[str, str]] = []
for j in range(3):
if i + j < len(sessions):
session = sessions[i + j]
session_id = session.session_id or 0
emoji = "1️⃣" if j == 0 else ("2️⃣" if j == 1 else "3️⃣")
row.append({
"text": f"{emoji}",
"callback_data": f"chat:session:{session_id}"
})
if row:
buttons.append(row)
buttons.append([{"text": "➕ گفت‌وگوی جدید", "callback_data": "chat:new"}])
buttons.append([{"text": "⬅️ بازگشت", "callback_data": "back:main"}])
keyboard = self._build_inline_keyboard(buttons)
# ساخت متن لیست جلسات
sessions_text = "جلسات فعال شما:\n\n"
for idx, session in enumerate(sessions[:10], 1):
title = "گفت‌وگوی جدید"
if session.session_id:
ai_session = self.ai_chat_repo.get_by_id(session.session_id)
if ai_session:
title = ai_session.title
sessions_text += f"{idx}. {title}\n"
return self.telegram_provider.send_text(
chat_id=self.chat_id,
text=sessions_text,
reply_markup=keyboard
)
def handle_business_selection(self, business_id: int, user_context: AuthContext) -> bool:
"""مدیریت انتخاب کسب‌وکار"""
# بررسی دسترسی به کسب‌وکار
business = self.business_repo.get_by_id(business_id)
if not business:
return self.telegram_provider.send_text(
chat_id=self.chat_id,
text="❌ کسب‌وکار یافت نشد."
)
# بررسی دسترسی کاربر
if business.owner_id != self.user_id:
permission = self.permission_repo.get_by_user_and_business(self.user_id, business_id)
if not permission:
return self.telegram_provider.send_text(
chat_id=self.chat_id,
text="❌ شما به این کسب‌وکار دسترسی ندارید."
)
# دریافت یا ایجاد جلسه AI
ai_sessions = self.ai_chat_repo.get_user_sessions(
user_id=self.user_id,
business_id=business_id,
limit=1
)
if ai_sessions:
ai_session = ai_sessions[0]
else:
# ایجاد جلسه جدید
from adapters.db.models.ai_chat_session import AIChatSession
ai_session = AIChatSession(
user_id=self.user_id,
business_id=business_id,
title="گفت‌وگوی جدید"
)
self.db.add(ai_session)
self.db.commit()
self.db.refresh(ai_session)
# ایجاد یا به‌روزرسانی جلسه تلگرام
telegram_session = self.session_repo.create_or_update_session(
user_id=self.user_id,
chat_id=self.chat_id,
session_id=ai_session.id,
business_id=business_id
)
# ارسال پیام تایید
buttons = [
[{"text": "💬 سوال بپرس", "callback_data": "chat:ask"}],
[{"text": "⬅️ بازگشت", "callback_data": "back:chat"}]
]
keyboard = self._build_inline_keyboard(buttons)
return self.telegram_provider.send_text(
chat_id=self.chat_id,
text=f"✅ گفت‌وگو با کسب‌وکار {business.name} شروع شد.\n\nچه کمکی می‌تونم بکنم؟",
reply_markup=keyboard
)
async def process_message(self, text: str, user_context: AuthContext) -> bool:
"""پردازش پیام متنی و ارسال به AI"""
# دریافت جلسه فعال
active_session = self.session_repo.get_active_session(self.user_id, self.chat_id)
if not active_session or not active_session.session_id:
return self.telegram_provider.send_text(
chat_id=self.chat_id,
text="❌ ابتدا یک کسب‌وکار را انتخاب کنید.",
reply_markup=self._build_inline_keyboard([
[{"text": "🏢 انتخاب کسب‌وکار", "callback_data": "menu:chat"}]
])
)
# بررسی اجباری business_id (چون کیف پول‌ها business-specific هستند)
if not active_session.business_id:
logger.warning(f"Session without business_id for user {self.user_id}, chat {self.chat_id}")
return self.telegram_provider.send_text(
chat_id=self.chat_id,
text="❌ کسب‌وکار انتخاب شده نامعتبر است. لطفاً دوباره کسب‌وکار را انتخاب کنید.",
reply_markup=self._build_inline_keyboard([
[{"text": "🏢 انتخاب کسب‌وکار", "callback_data": "menu:chat"}]
])
)
# ایجاد AI Service یکبار
ai_service = AIService(self.db, user_context, active_session.business_id)
# چک اعتبار قبل از ارسال (بدون try-except گسترده)
availability = ai_service.check_availability(estimated_tokens=len(text) * 2)
if not availability["can_use"]:
return self._send_availability_error(availability)
# وضعیت «در حال نوشتن» به‌جای پیام جدا (CHN-01)
self.telegram_provider.send_chat_action(self.chat_id, "typing")
try:
# دریافت پیام‌های قبلی
previous_messages = self.ai_message_repo.get_session_messages(
active_session.session_id,
limit=50
)
# ساخت messages برای AI
messages = []
for msg in previous_messages:
messages.append({
"role": msg.role if isinstance(msg.role, str) else getattr(msg.role, "value", msg.role),
"content": msg.content
})
# اضافه کردن پیام جدید
messages.append({
"role": "user",
"content": text
})
# ذخیره پیام کاربر
from adapters.db.models.ai_chat_message import AIChatMessage, MessageRole
user_message = AIChatMessage(
session_id=active_session.session_id,
role=MessageRole.USER.value,
content=text,
tokens_used=0
)
self.db.add(user_message)
self.db.commit()
self.db.refresh(user_message)
# ارسال به AI (async)
response = await ai_service.chat_completion(
messages=messages,
use_function_calling=True,
session_business_id=active_session.business_id,
session_id=active_session.session_id,
execution_mode="supervised",
approve_writes=False,
)
# بررسی سهمیه و شارژ
usage = response.get("usage", {})
input_tokens = usage.get("input_tokens", 0)
output_tokens = usage.get("output_tokens", 0)
charge_result = ai_service.check_quota_and_charge(input_tokens, output_tokens)
assistant_content = sanitize_assistant_content(
response["message"]["content"] or ""
)
if response.get("awaiting_approval"):
assistant_content = (assistant_content or "").rstrip() + TELEGRAM_APPROVAL_HINT_FA
# ذخیره پاسخ AI
assistant_message = AIChatMessage(
session_id=active_session.session_id,
role=MessageRole.ASSISTANT.value,
content=assistant_content,
tokens_used=input_tokens + output_tokens,
function_calls=json_dumps_safe(response.get("_function_calls"))
if response.get("_function_calls")
else None,
function_results=json_dumps_safe(response.get("_function_results"))
if response.get("_function_results")
else None,
)
self.db.add(assistant_message)
# ثبت لاگ استفاده
ai_service.log_usage(
provider=ai_service.config.provider if ai_service.config else "openai",
model=ai_service.config.model_name if ai_service.config else "gpt-4",
input_tokens=input_tokens,
output_tokens=output_tokens,
cost=charge_result.get("cost", 0),
payment_method=charge_result.get("payment_method", "free"),
wallet_transaction_id=charge_result.get("wallet_transaction_id"),
document_id=charge_result.get("document_id")
)
# به‌روزرسانی زمان جلسه
ai_session = self.ai_chat_repo.get_by_id(active_session.session_id)
if ai_session:
ai_session.updated_at = datetime.utcnow()
# اگر عنوان پیش‌فرض است و این اولین پیام است، عنوان هوشمند بساز
if ai_session.title == "گفت‌وگوی جدید" or ai_session.title == "جلسه چت جدید":
generated_title = await ai_service.generate_chat_title(text)
if generated_title:
ai_session.title = generated_title[:80]
self.db.commit()
from app.services.ai.ai_memory_hooks import schedule_memory_update_after_chat
if active_session.business_id:
schedule_memory_update_after_chat(
active_session.session_id,
int(active_session.business_id),
user_context,
)
# ارسال پاسخ (صفحه‌بندی به‌جای برش ۴۰۰۰ کاراکتر)
chunks = split_telegram_text(assistant_content)
pending_ops = extract_pending_approval_ops(
response.get("_function_results")
)
buttons = telegram_approval_inline_rows(pending_ops)
buttons.extend(
[
[{"text": "💬 سوال دیگر", "callback_data": "chat:ask"}],
[{"text": "⬅️ بازگشت", "callback_data": "back:chat"}],
]
)
keyboard = self._build_inline_keyboard(buttons)
sent_ok = True
for index, chunk in enumerate(chunks):
ok = self.telegram_provider.send_text(
chat_id=self.chat_id,
text=chunk,
parse_mode=None,
reply_markup=keyboard if index == len(chunks) - 1 else None,
)
sent_ok = sent_ok and bool(ok)
return sent_ok
except Exception as e:
logger.error(f"Error processing AI message: {e}", exc_info=True)
# بررسی نوع خطا و نمایش پیام مناسب
error_message = "❌ خطا در پردازش پیام"
from app.core.responses import ApiError
if isinstance(e, ApiError):
error_code = e.error_code
if error_code == "NO_ACTIVE_SUBSCRIPTION":
error_message = """❌ اشتراک فعالی ندارید
برای استفاده از هوش مصنوعی، ابتدا یک پلن را از داخل برنامه انتخاب کنید.
💡 پلن‌های موجود:
• رایگان: ۵۰۰۰ توکن
• پایه: ۵۰٬۰۰۰ توکن ماهانه
• حرفه‌ای: نامحدود"""
elif error_code == "QUOTA_EXCEEDED":
extra_data = getattr(e, 'extra_data', {})
tokens_used = extra_data.get('tokens_used', 0)
tokens_limit = extra_data.get('tokens_limit', 0)
error_message = f"""⚠️ سهمیه شما تمام شده است
استفاده شده: {tokens_used:,}
سقف: {tokens_limit:,}
💡 برای ادامه:
• ارتقا به پلن بالاتر از داخل برنامه
• منتظر تمدید ماهانه بمانید"""
elif error_code == "INSUFFICIENT_FUNDS":
extra_data = getattr(e, 'extra_data', {})
wallet = extra_data.get('wallet', {})
balance = wallet.get('balance', 0)
estimated_cost = wallet.get('estimated_cost', 0)
error_message = f"""💰 موجودی کیف پول ناکافی
موجودی فعلی: {balance:,.0f} ریال
هزینه تخمینی: {estimated_cost:,.0f} ریال
لطفاً از داخل برنامه، کیف پول خود را شارژ کنید."""
else:
error_message = f"❌ خطا: {e.message}"
else:
error_message = f"❌ خطا در پردازش پیام: {str(e)}"
return self.telegram_provider.send_text(
chat_id=self.chat_id,
text=error_message
)
async def confirm_pending_write(
self,
approval_id: str,
user_context: AuthContext,
*,
approved: bool,
) -> bool:
"""تأیید یا رد کارت نوشتن از دکمهٔ inline تلگرام (CHN-01)."""
aid = (approval_id or "").strip()
if not aid:
return self.telegram_provider.send_text(
chat_id=self.chat_id,
text="❌ شناسهٔ تأیید نامعتبر است.",
)
active_session = self.session_repo.get_active_session(self.user_id, self.chat_id)
if not active_session or not active_session.session_id or not active_session.business_id:
return self.telegram_provider.send_text(
chat_id=self.chat_id,
text="❌ ابتدا یک کسب‌وکار را انتخاب کنید.",
reply_markup=self._build_inline_keyboard([
[{"text": "🏢 انتخاب کسب‌وکار", "callback_data": "menu:chat"}]
]),
)
if not approved:
return self.telegram_provider.send_text(
chat_id=self.chat_id,
text="عملیات رد شد. تغییری اعمال نشد.",
reply_markup=self._build_inline_keyboard([
[{"text": "💬 سوال دیگر", "callback_data": "chat:ask"}],
[{"text": "⬅️ بازگشت", "callback_data": "back:chat"}],
]),
)
from adapters.db.models.ai_chat_message import AIChatMessage, MessageRole
import json
last_assistant = (
self.db.query(AIChatMessage)
.filter(
AIChatMessage.session_id == active_session.session_id,
AIChatMessage.role == MessageRole.ASSISTANT.value,
)
.order_by(AIChatMessage.created_at.desc())
.first()
)
if not last_assistant or not last_assistant.function_results:
return self.telegram_provider.send_text(
chat_id=self.chat_id,
text="❌ کارت تأیید منقضی شده است. دوباره درخواست را بفرستید.",
)
try:
stored = json.loads(last_assistant.function_results)
except (TypeError, ValueError, json.JSONDecodeError):
stored = {}
matched = [
op
for op in extract_pending_approval_ops(stored)
if str(op.get("approval_id") or "") == aid
]
if not matched:
return self.telegram_provider.send_text(
chat_id=self.chat_id,
text="❌ این تأیید دیگر معتبر نیست.",
)
self.telegram_provider.send_chat_action(self.chat_id, "typing")
confirm_text = "کاربر عملیات را از تلگرام تأیید کرد. همان عملیات تأییدشده را اجرا کن."
previous_messages = self.ai_message_repo.get_session_messages(
active_session.session_id,
limit=50,
)
messages = []
for msg in previous_messages:
messages.append({
"role": msg.role if isinstance(msg.role, str) else getattr(msg.role, "value", msg.role),
"content": msg.content,
})
messages.append({"role": "user", "content": confirm_text})
user_message = AIChatMessage(
session_id=active_session.session_id,
role=MessageRole.USER.value,
content=confirm_text,
tokens_used=0,
)
self.db.add(user_message)
self.db.commit()
ai_service = AIService(self.db, user_context, active_session.business_id)
availability = ai_service.check_availability(estimated_tokens=400)
if not availability["can_use"]:
return self._send_availability_error(availability)
try:
response = await ai_service.chat_completion(
messages=messages,
use_function_calling=True,
session_business_id=active_session.business_id,
session_id=active_session.session_id,
execution_mode="supervised",
approve_writes=True,
approved_write_calls=matched,
)
usage = response.get("usage") or {}
input_tokens = usage.get("input_tokens", 0)
output_tokens = usage.get("output_tokens", 0)
charge_result = ai_service.check_quota_and_charge(input_tokens, output_tokens)
assistant_content = sanitize_assistant_content(
response["message"]["content"] or ""
)
assistant_message = AIChatMessage(
session_id=active_session.session_id,
role=MessageRole.ASSISTANT.value,
content=assistant_content,
tokens_used=input_tokens + output_tokens,
function_calls=json_dumps_safe(response.get("_function_calls"))
if response.get("_function_calls")
else None,
function_results=json_dumps_safe(response.get("_function_results"))
if response.get("_function_results")
else None,
)
self.db.add(assistant_message)
ai_service.log_usage(
provider=ai_service.config.provider if ai_service.config else "openai",
model=ai_service.config.model_name if ai_service.config else "gpt-4",
input_tokens=input_tokens,
output_tokens=output_tokens,
cost=charge_result.get("cost", 0),
payment_method=charge_result.get("payment_method", "free"),
wallet_transaction_id=charge_result.get("wallet_transaction_id"),
document_id=charge_result.get("document_id"),
)
self.db.commit()
from app.services.ai.ai_memory_hooks import schedule_memory_update_after_chat
if active_session.business_id:
schedule_memory_update_after_chat(
active_session.session_id,
int(active_session.business_id),
user_context,
)
chunks = split_telegram_text(assistant_content)
keyboard = self._build_inline_keyboard([
[{"text": "💬 سوال دیگر", "callback_data": "chat:ask"}],
[{"text": "⬅️ بازگشت", "callback_data": "back:chat"}],
])
sent_ok = True
for index, chunk in enumerate(chunks):
ok = self.telegram_provider.send_text(
chat_id=self.chat_id,
text=chunk,
parse_mode=None,
reply_markup=keyboard if index == len(chunks) - 1 else None,
)
sent_ok = sent_ok and bool(ok)
return sent_ok
except Exception as e:
logger.error(f"Error confirming telegram write: {e}", exc_info=True)
return self.telegram_provider.send_text(
chat_id=self.chat_id,
text="❌ خطا در اجرای عملیات تأییدشده. لطفاً دوباره امتحان کنید.",
)
def _send_availability_error(self, availability: Dict[str, Any]) -> bool:
"""ارسال پیام خطای عدم امکان استفاده از AI"""
reason = availability.get("reason")
details = availability.get("details", {})
message = details.get("message", "خطای نامشخص")
suggestions = details.get("suggestions", [])
if reason == "NO_ACTIVE_SUBSCRIPTION":
text = """❌ اشتراک فعالی ندارید
برای استفاده از هوش مصنوعی، ابتدا یک پلن را از داخل برنامه انتخاب کنید.
💡 پلن‌های موجود:
• رایگان: ۵۰۰۰ توکن
• پایه: ۵۰٬۰۰۰ توکن ماهانه
• حرفه‌ای: نامحدود"""
elif reason == "QUOTA_EXCEEDED":
subscription = details.get("subscription", {})
tokens_used = subscription.get("tokens_used", 0)
tokens_limit = subscription.get("tokens_limit", 0)
text = f"""⚠️ سهمیه شما تمام شده است
استفاده شده: {tokens_used:,}
سقف: {tokens_limit:,}
💡 برای ادامه:
• ارتقا به پلن بالاتر از داخل برنامه
• منتظر تمدید ماهانه بمانید"""
elif reason == "INSUFFICIENT_FUNDS":
wallet = details.get("wallet", {})
balance = wallet.get("balance", 0)
estimated_cost = wallet.get("estimated_cost", 0)
text = f"""💰 موجودی کیف پول ناکافی
موجودی فعلی: {balance:,.0f} ریال
هزینه تخمینی: {estimated_cost:,.0f} ریال
لطفاً از داخل برنامه، کیف پول خود را شارژ کنید."""
else:
text = f"❌ {message}"
if suggestions:
text += "\n\n" + "\n".join(f"• {s}" for s in suggestions)
return self.telegram_provider.send_text(
chat_id=self.chat_id,
text=text,
reply_markup=self._build_inline_keyboard([
[{"text": "🔙 بازگشت", "callback_data": "back:main"}]
])
)