From b8ab8b4b93b6d9d187fa0b5d500f4c978ab3d232 Mon Sep 17 00:00:00 2001 From: vahidrezvani Date: Sat, 21 Feb 2026 09:48:28 +0330 Subject: [PATCH] firt commit --- .api.env | 29 + .dockerignore | 3 + .env | 17 + .gitignore | 18 + Dockerfile | 44 + Makefile | 83 ++ backup.sh | 20 + docker-compose.yml | 137 +++ frontend/Dockerfile | 23 + frontend/nginx.conf | 29 + migrations/models/0_20250106094716_init.py | 173 +++ ...10_20250130091551_update_category_model.py | 15 + .../11_20250201101206_update_banner_model.py | 13 + .../12_20250201110621_update_banner_model.py | 19 + .../13_20250201143637_update_bot_model.py | 11 + .../14_20250202114245_create_code_model.py | 20 + .../15_20250217085342_update_bot_model.py | 11 + ...0250222102808_update_notification_model.py | 11 + .../17_20250303105524_update_message_model.py | 13 + .../18_20250306105319_update_bot_model.py | 11 + ...19_20250308134441_update_category_model.py | 11 + .../1_20250106145739_update_billing_model.py | 11 + ...20_20250310094400_update_category_model.py | 11 + ...21_20250310114405_update_category_model.py | 13 + .../22_20250310151750_update_bot_model.py | 11 + ..._20250311084644_create_suno_tasks_model.py | 23 + .../24_20250313085522_update_bot_model.py | 11 + ...0313094152_create_event_&_winner_models.py | 33 + .../26_20250316135700_update_bot_model.py | 11 + ...27_20250316142019_update_category_model.py | 11 + .../28_20250318121834_update_event_model.py | 11 + migrations/models/29_20250408132115_update.py | 11 + .../2_20250116093753_update_rate_model.py | 11 + .../models/30_20250409123731_app_metadata.py | 16 + migrations/models/31_20250416113625_ads.py | 18 + .../32_20250417000000_add_user_level.py | 9 + ...3_20250418000000_add_bot_allowed_levels.py | 9 + .../34_20250419000000_add_user_parent.py | 11 + .../3_20250119093009_update_bot_model.py | 11 + .../4_20250120105036_create_banner_model.py | 17 + .../5_20250120135043_update_banner_model.py | 17 + .../6_20250121155650_update_user_model.py | 11 + .../7_20250125094403_create_plan_model.py | 20 + .../8_20250125162351_update_user_model.py | 11 + .../9_20250127150507_update_plan_model.py | 11 + proxy/nginx.conf | 93 ++ proxy/nginx.conf.save | 33 + pyproject.toml | 4 + requirements.txt | 218 ++++ src/__init__.py | 0 src/config.py | 38 + src/dependencies.py | 7 + src/main.py | 252 ++++ src/messages.py | 46 + src/models/__init__.py | 22 + src/models/advertisement.py | 15 + src/models/app_metadata.py | 11 + src/models/banner.py | 16 + src/models/billing.py | 39 + src/models/bot.py | 62 + src/models/category.py | 26 + src/models/chatbot.py | 20 + src/models/code.py | 13 + src/models/comment.py | 32 + src/models/discount.py | 15 + src/models/event.py | 21 + src/models/feedback.py | 24 + src/models/mark.py | 17 + src/models/message.py | 20 + src/models/notification.py | 16 + src/models/plan.py | 17 + src/models/rate.py | 18 + src/models/settlement.py | 22 + src/models/suno_task.py | 19 + src/models/ticket.py | 22 + src/models/timestamp.py | 5 + src/models/user.py | 51 + src/models/winner.py | 17 + src/routes/__init__.py | 43 + src/routes/advertisement.py | 34 + src/routes/app_metadata.py | 39 + src/routes/banner.py | 30 + src/routes/bot.py | 376 ++++++ src/routes/category.py | 81 ++ src/routes/chatbot.py | 371 ++++++ src/routes/discount.py | 30 + src/routes/event.py | 19 + src/routes/forum.py | 87 ++ src/routes/payment.py | 150 +++ src/routes/ticket.py | 89 ++ src/routes/tool.py | 244 ++++ src/routes/user.py | 426 +++++++ src/schemas/__init__.py | 0 src/schemas/advertisement.py | 11 + src/schemas/banner.py | 14 + src/schemas/bot.py | 197 +++ src/schemas/category.py | 24 + src/schemas/chatbot.py | 80 ++ src/schemas/comment.py | 56 + src/schemas/discount.py | 13 + src/schemas/event.py | 0 src/schemas/main.py | 5 + src/schemas/payment.py | 60 + src/schemas/ticket.py | 37 + src/schemas/token.py | 9 + src/schemas/tool.py | 6 + src/schemas/user.py | 179 +++ src/services/__init__.py | 0 src/services/_utils/__init__.py | 0 src/services/_utils/generate_en_prompt.py | 16 + src/services/_utils/get_or_create_chatbot.py | 36 + src/services/_utils/local_upload_file.py | 21 + src/services/_utils/validate_user_and_bot.py | 442 +++++++ src/services/advertisement.py | 50 + src/services/app_metadata.py | 26 + src/services/auth.py | 119 ++ src/services/banner.py | 5 + src/services/bot.py | 728 +++++++++++ src/services/category.py | 77 ++ src/services/chatbot.py | 723 +++++++++++ src/services/chroma.py | 56 + src/services/database/__init__.py | 0 src/services/database/service.py | 43 + src/services/discount.py | 46 + src/services/email.py | 29 + src/services/event.py | 32 + src/services/falai/__init__.py | 0 src/services/falai/service.py | 45 + src/services/falai/workflow.py | 20 + src/services/firebase.py | 25 + src/services/forum.py | 150 +++ src/services/others/__init__.py | 0 src/services/others/service.py | 86 ++ src/services/others/workflow.py | 114 ++ src/services/otp.py | 106 ++ src/services/payment.py | 344 ++++++ src/services/pixverse_img2vid/__init__.py | 0 src/services/pixverse_img2vid/service.py | 132 ++ src/services/pixverse_img2vid/workflow.py | 86 ++ src/services/pixverse_text2vid/__init__.py | 0 src/services/pixverse_text2vid/service.py | 90 ++ src/services/pixverse_text2vid/workflow.py | 71 ++ src/services/sms.py | 64 + src/services/storage.py | 81 ++ src/services/stt/__init__.py | 0 src/services/stt/service.py | 9 + src/services/stt/workflow.py | 33 + src/services/suno/__init__.py | 0 src/services/suno/service.py | 92 ++ src/services/suno/workflow.py | 115 ++ src/services/ticket.py | 120 ++ src/services/tool.py | 1059 +++++++++++++++++ src/services/translator/__init__.py | 0 src/services/translator/service.py | 9 + src/services/translator/workflow.py | 33 + src/services/tts/__init__.py | 0 src/services/tts/service.py | 9 + src/services/tts/workflow.py | 43 + src/services/tts_openai_fm/__init__.py | 0 src/services/tts_openai_fm/service.py | 72 ++ src/services/tts_openai_fm/workflow.py | 25 + src/services/user.py | 686 +++++++++++ src/settings.py | 14 + src/tasks/__init__.py | 0 src/tasks/user.py | 47 + static/.well-known/assetlinks.json | 12 + 166 files changed, 11222 insertions(+) create mode 100644 .api.env create mode 100644 .dockerignore create mode 100644 .env create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 backup.sh create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/nginx.conf create mode 100644 migrations/models/0_20250106094716_init.py create mode 100644 migrations/models/10_20250130091551_update_category_model.py create mode 100644 migrations/models/11_20250201101206_update_banner_model.py create mode 100644 migrations/models/12_20250201110621_update_banner_model.py create mode 100644 migrations/models/13_20250201143637_update_bot_model.py create mode 100644 migrations/models/14_20250202114245_create_code_model.py create mode 100644 migrations/models/15_20250217085342_update_bot_model.py create mode 100644 migrations/models/16_20250222102808_update_notification_model.py create mode 100644 migrations/models/17_20250303105524_update_message_model.py create mode 100644 migrations/models/18_20250306105319_update_bot_model.py create mode 100644 migrations/models/19_20250308134441_update_category_model.py create mode 100644 migrations/models/1_20250106145739_update_billing_model.py create mode 100644 migrations/models/20_20250310094400_update_category_model.py create mode 100644 migrations/models/21_20250310114405_update_category_model.py create mode 100644 migrations/models/22_20250310151750_update_bot_model.py create mode 100644 migrations/models/23_20250311084644_create_suno_tasks_model.py create mode 100644 migrations/models/24_20250313085522_update_bot_model.py create mode 100644 migrations/models/25_20250313094152_create_event_&_winner_models.py create mode 100644 migrations/models/26_20250316135700_update_bot_model.py create mode 100644 migrations/models/27_20250316142019_update_category_model.py create mode 100644 migrations/models/28_20250318121834_update_event_model.py create mode 100644 migrations/models/29_20250408132115_update.py create mode 100644 migrations/models/2_20250116093753_update_rate_model.py create mode 100644 migrations/models/30_20250409123731_app_metadata.py create mode 100644 migrations/models/31_20250416113625_ads.py create mode 100644 migrations/models/32_20250417000000_add_user_level.py create mode 100644 migrations/models/33_20250418000000_add_bot_allowed_levels.py create mode 100644 migrations/models/34_20250419000000_add_user_parent.py create mode 100644 migrations/models/3_20250119093009_update_bot_model.py create mode 100644 migrations/models/4_20250120105036_create_banner_model.py create mode 100644 migrations/models/5_20250120135043_update_banner_model.py create mode 100644 migrations/models/6_20250121155650_update_user_model.py create mode 100644 migrations/models/7_20250125094403_create_plan_model.py create mode 100644 migrations/models/8_20250125162351_update_user_model.py create mode 100644 migrations/models/9_20250127150507_update_plan_model.py create mode 100644 proxy/nginx.conf create mode 100644 proxy/nginx.conf.save create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 src/__init__.py create mode 100644 src/config.py create mode 100644 src/dependencies.py create mode 100644 src/main.py create mode 100644 src/messages.py create mode 100644 src/models/__init__.py create mode 100644 src/models/advertisement.py create mode 100644 src/models/app_metadata.py create mode 100644 src/models/banner.py create mode 100644 src/models/billing.py create mode 100644 src/models/bot.py create mode 100644 src/models/category.py create mode 100644 src/models/chatbot.py create mode 100644 src/models/code.py create mode 100644 src/models/comment.py create mode 100644 src/models/discount.py create mode 100644 src/models/event.py create mode 100644 src/models/feedback.py create mode 100644 src/models/mark.py create mode 100644 src/models/message.py create mode 100644 src/models/notification.py create mode 100644 src/models/plan.py create mode 100644 src/models/rate.py create mode 100644 src/models/settlement.py create mode 100644 src/models/suno_task.py create mode 100644 src/models/ticket.py create mode 100644 src/models/timestamp.py create mode 100644 src/models/user.py create mode 100644 src/models/winner.py create mode 100644 src/routes/__init__.py create mode 100644 src/routes/advertisement.py create mode 100644 src/routes/app_metadata.py create mode 100644 src/routes/banner.py create mode 100644 src/routes/bot.py create mode 100644 src/routes/category.py create mode 100644 src/routes/chatbot.py create mode 100644 src/routes/discount.py create mode 100644 src/routes/event.py create mode 100644 src/routes/forum.py create mode 100644 src/routes/payment.py create mode 100644 src/routes/ticket.py create mode 100644 src/routes/tool.py create mode 100644 src/routes/user.py create mode 100644 src/schemas/__init__.py create mode 100644 src/schemas/advertisement.py create mode 100644 src/schemas/banner.py create mode 100644 src/schemas/bot.py create mode 100644 src/schemas/category.py create mode 100644 src/schemas/chatbot.py create mode 100644 src/schemas/comment.py create mode 100644 src/schemas/discount.py create mode 100644 src/schemas/event.py create mode 100644 src/schemas/main.py create mode 100644 src/schemas/payment.py create mode 100644 src/schemas/ticket.py create mode 100644 src/schemas/token.py create mode 100644 src/schemas/tool.py create mode 100644 src/schemas/user.py create mode 100644 src/services/__init__.py create mode 100644 src/services/_utils/__init__.py create mode 100644 src/services/_utils/generate_en_prompt.py create mode 100644 src/services/_utils/get_or_create_chatbot.py create mode 100644 src/services/_utils/local_upload_file.py create mode 100644 src/services/_utils/validate_user_and_bot.py create mode 100644 src/services/advertisement.py create mode 100644 src/services/app_metadata.py create mode 100644 src/services/auth.py create mode 100644 src/services/banner.py create mode 100644 src/services/bot.py create mode 100644 src/services/category.py create mode 100644 src/services/chatbot.py create mode 100644 src/services/chroma.py create mode 100644 src/services/database/__init__.py create mode 100644 src/services/database/service.py create mode 100644 src/services/discount.py create mode 100644 src/services/email.py create mode 100644 src/services/event.py create mode 100644 src/services/falai/__init__.py create mode 100644 src/services/falai/service.py create mode 100644 src/services/falai/workflow.py create mode 100644 src/services/firebase.py create mode 100644 src/services/forum.py create mode 100644 src/services/others/__init__.py create mode 100644 src/services/others/service.py create mode 100644 src/services/others/workflow.py create mode 100644 src/services/otp.py create mode 100644 src/services/payment.py create mode 100644 src/services/pixverse_img2vid/__init__.py create mode 100644 src/services/pixverse_img2vid/service.py create mode 100644 src/services/pixverse_img2vid/workflow.py create mode 100644 src/services/pixverse_text2vid/__init__.py create mode 100644 src/services/pixverse_text2vid/service.py create mode 100644 src/services/pixverse_text2vid/workflow.py create mode 100644 src/services/sms.py create mode 100644 src/services/storage.py create mode 100644 src/services/stt/__init__.py create mode 100644 src/services/stt/service.py create mode 100644 src/services/stt/workflow.py create mode 100644 src/services/suno/__init__.py create mode 100644 src/services/suno/service.py create mode 100644 src/services/suno/workflow.py create mode 100644 src/services/ticket.py create mode 100644 src/services/tool.py create mode 100644 src/services/translator/__init__.py create mode 100644 src/services/translator/service.py create mode 100644 src/services/translator/workflow.py create mode 100644 src/services/tts/__init__.py create mode 100644 src/services/tts/service.py create mode 100644 src/services/tts/workflow.py create mode 100644 src/services/tts_openai_fm/__init__.py create mode 100644 src/services/tts_openai_fm/service.py create mode 100644 src/services/tts_openai_fm/workflow.py create mode 100644 src/services/user.py create mode 100644 src/settings.py create mode 100644 src/tasks/__init__.py create mode 100644 src/tasks/user.py create mode 100644 static/.well-known/assetlinks.json diff --git a/.api.env b/.api.env new file mode 100644 index 0000000..66425ee --- /dev/null +++ b/.api.env @@ -0,0 +1,29 @@ +BASE_URL=https://basa.houshan.ai +DATABASE_URL=postgres://root:WwLbnHzA0fC15QzKSORSvsXr@fitz-roy.liara.cloud:33568/houshan +JWT_SECRET_KEY=g,m`oF]Bs$pf)afA[2J*Dsb*zKc|o$eQ +JWT_ADMIN_SECRET_KEY=XRU@4xfZS&egA&&93,o@oVtONr>9Ur=4lI%5U[ize{1QIPqdETU8N2v5f4q61PbN6H +CHROMA_URL=https://houshan-chroma.liara.run +CHROMA_CREDENTIALS=RkjmBFhVBlnUIweJGEB +ANTHROPIC_API_KEY=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 +MMERCHANT_ID=667136b5-b23e-45fb-844f-4d2c7fd3fef3 +CAFEBAZAAR_PISHKHAN_API_SECRET=eyJhbGciOiJIUzI1NiIsImtpZCI6ImFuY2llbnQiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJuYXNoZXItcGlzaGtoYW4tYXBpIiwiaWF0IjoxNzM3MTk5MjE0LCJleHAiOjQ4OTA3OTkyMTQsImFwaV9hZ2VudF9pZCI6MzM2MX0.Tj-eTmAAN4hZ8xAaeiJoRnoC3uYdWYqieX2OATjZLOM +CAFEBAZAAR_PRICE_KEY=6X0QCJnxK3CqFB9dFlwFbqpZhK4SOqKG1gxXhdxWNSg +MYKET_ACCESS_TOKEN=37fa0df1-5a6e-46b3-8b84-b0da43f9749c +DEEPSEEK_API_KEY=sk-a89054e15dbf4f21a3715470db084765 +GHOST_ID=6b449ccb-bd7b-4988-986a-35e653569785 +FAL_KEY=3a793a2f-65ac-404a-aca3-02d36a7d2cb0:77c185cd1adfc8593e6ad8343b4f28d9 +MAILERSEND_API_KEY=mlsn.eebd4e124869da92ca8a83bd876c3a8e57dd73e9dae61c5c3bf77e17546e56df +SUNO_API_KEY=39f3326be8f930953a7a0b80a53b7231 +PIXVERSE_API_KEY=sk-2c785f1f77ace5b4f39cb3d4dc5ef554 +INTERNAL_TELEGRAM_SECRET=do8asyd0h21uodh2od2hkdmbzxc2349ASAX80scasokcu23ked2zxc +LIARA_API_URL =https://ai.liara.ir/api/68eb653bb55873971e0d46d6/v1 +LIARA_API_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiI2OThhZGIxMWQzODZhNWVmODNlMTg2YzAiLCJ0eXBlIjoiYWlfa2V5IiwiaWF0IjoxNzcwNzA3NzMwfQ.ckoR00Uxt8W4DmLCGW24P46Zq-et0Yhtu2xej5P0ClQ diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..847e9eb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +.venv +.env +db-backup \ No newline at end of file diff --git a/.env b/.env new file mode 100644 index 0000000..53ec364 --- /dev/null +++ b/.env @@ -0,0 +1,17 @@ +HOSTNAME=basa.houshan.ai + +API_HOSTNAME=basa.houshan.ai +API_DB_BACKUP_DELAY=120s +API_DB_BACKUP_INTERVAL=86400s +API_DB_NAME=houshan +API_DB_USER=root +API_DB_PASS=lXU4kz2JSsJKM6PiBSbVddyE + +# Traefik +TRAEFIK_IMAGE_TAG=traefik:v3.3.4nhvu +TRAEFIK_HOSTNAME=traefik.houshan.ai +TRAEFIK_LOG_LEVEL=INFO +TRAEFIK_LOG_FORMAT=json +TRAEFIK_ACCESS_LOG_FORMAT=json +TRAEFIK_BASIC_AUTH=admin:$$apr1$$m7z6vCld$$EXqonhrD4jI05jKZLD7Gh1 # htpasswd -nb user pass +TRAEFIK_METRICS_PORT=8899 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e7f0d1d --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# fluter +build.zip + +# Virtual environment +env/ +.idea + +# Environment variables +# .env +# .api.env +db-backup + +# Python compiled files +__pycache__/ +*.pyc + +# +serviceAccountKey.json \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e5a4232 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,44 @@ +# Use an official Python runtime as a base image +FROM python:3.11-slim + +# Set the working directory inside the container +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + curl \ + libpq-dev \ + gcc \ + ffmpeg \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# --- تنظیمات ریجستری پایتون (Runflare) --- +# روش پیشنهادی استفاده از ENV است که روی تمام دستورات pip تاثیر می‌گذارد +ENV PIP_INDEX_URL=https://mirror-pypi.runflare.com/simple +ENV PIP_TRUSTED_HOST=mirror-pypi.runflare.com + +# اگر اصرار دارید دقیقا از دستورات config استفاده کنید، به این صورت جایگزین کنید: +# RUN pip config set global.index-url https://mirror-pypi.runflare.com/simple && \ +# pip config set global.trusted-host mirror-pypi.runflare.com + +# نکته: خط مربوط به npm config حذف شد چون در این ایمیج npm وجود ندارد. +# اگر به Node نیاز دارید، باید ابتدا آن را نصب کنید. + +# Copy requirements.txt to the container +COPY requirements.txt . + +# Upgrade pip and install wheel using the mirror +RUN pip install --no-cache-dir --upgrade pip setuptools wheel + +# Install the Python dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Copy the rest of the FastAPI app code into the container +COPY . . + +# Expose port 8000 +EXPOSE 8000 + +# Command to run FastAPI app +CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c034593 --- /dev/null +++ b/Makefile @@ -0,0 +1,83 @@ +# Define variables for better maintainability +COMPOSE_FILE := docker-compose.yml +PROJECT_NAME := houshan # Replaced space with a hyphen + +# Default target +all: up + +## Build, (re)create, start, and attach to containers +up: + @echo "Bringing up the services..." + sudo docker compose -f $(COMPOSE_FILE) -p $(PROJECT_NAME) up --build --remove-orphans -d + +## Stop and remove containers, networks, images, and volumes +down: + @echo "Stopping and removing the services..." + sudo docker compose -f $(COMPOSE_FILE) -p $(PROJECT_NAME) down --remove-orphans + +## Rebuild and restart the services +rebuild: + @echo "Rebuilding the services..." + sudo docker compose -f $(COMPOSE_FILE) -p $(PROJECT_NAME) up --build --force-recreate --remove-orphans + +## Stop the running containers (without removing them) +stop: + @echo "Stopping the running containers..." + sudo docker compose -f $(COMPOSE_FILE) -p $(PROJECT_NAME) stop + +## Show the status of the containers +ps: + @echo "Displaying the status of the containers..." + sudo docker compose -f $(COMPOSE_FILE) -p $(PROJECT_NAME) ps + +## View the recent logs of the running containers +logs: + @echo "Displaying the logs of the containers (use Ctrl+C to exit)..." + sudo docker compose -f $(COMPOSE_FILE) -p $(PROJECT_NAME) logs -f --since 0s + +## View the logs of the running containers +logs-all: + @echo "Displaying the logs of the containers (use Ctrl+C to exit)..." + sudo docker compose -f $(COMPOSE_FILE) -p $(PROJECT_NAME) logs -f + + +## Clean up unused Docker resources (volumes, networks, images) +clean: + @echo "Cleaning up unused Docker resources..." + sudo docker system prune -af --volumes + +## Get shell from app +shell-app: + @echo "Getting shell from app..." + sudo docker compose -f $(COMPOSE_FILE) -p $(PROJECT_NAME) exec -it app /bin/bash + +## restore data +db-restore: + @echo "Restore pg_dump data to db..." + sudo docker compose -f $(COMPOSE_FILE) -p $(PROJECT_NAME) exec -i -T database pg_restore -U root -d houshan --verbose < ./restore/bots.sql + +## Help message +help: + @echo "Available targets:" + @echo " up - Start the services (builds if necessary)" + @echo " down - Stop and remove the services" + @echo " rebuild - Rebuild and restart the services" + @echo " stop - Stop the running containers" + @echo " ps - Show the status of the containers" + @echo " logs - View the logs of the running containers (use -f to follow logs in real-time)" + @echo " clean - Clean up unused Docker resources" + +.PHONY: all up down rebuild stop ps logs clean help + +# db-init (don't): +# aerich init -t src.settings.TORTOISE_ORM + +# db-migrate: +# aerich migrate --name "" + +# db-upgrade: +# export DATABASE_URL=postgres://root:XXXXX@localhost:54921/houshan +# aerich upgrade + +# db-copy: +# pg_dump -h himalayas.liara.cloud -p 30555 -U root -d houshan -F c -t bots | pv > bots.sql \ No newline at end of file diff --git a/backup.sh b/backup.sh new file mode 100644 index 0000000..4ca049c --- /dev/null +++ b/backup.sh @@ -0,0 +1,20 @@ +#!/bin/sh +set -e +echo "Starting backups service" +sleep "${API_DB_BACKUP_DELAY}" + +while true; do + NOW=$(date "+%Y-%m-%d_%H-%M") + FILE="/srv/houshan-postgres/backups/houshan-postgres-backup-${NOW}.backup" + + echo "[${NOW}] Running pg_dump (custom format)..." + pg_dump -h postgres -p 5432 -d "${API_DB_NAME}" -U "${API_DB_USER}" -F c -f "${FILE}" + + echo "[${NOW}] Backup saved to ${FILE}" + + echo "[${NOW}] Keeping last 3 backups..." + ls -1t /srv/houshan-postgres/backups/houshan-postgres-backup-*.backup | tail -n +4 | xargs -r rm -f + + echo "[${NOW}] Sleeping for ${API_DB_BACKUP_INTERVAL}" + sleep "${API_DB_BACKUP_INTERVAL}" +done diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..be4ad6d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,137 @@ +networks: + houshan-network: + +volumes: + houshan-postgres: + +services: + # --------------------------------- + # API Service (Your Python App) + # --------------------------------- + app: + build: . + container_name: basa_api + networks: + - houshan-network + env_file: + - .api.env + restart: always + labels: + - "autoheal=true" + healthcheck: + test: + [ + "CMD", + "curl", + "--fail", + "--max-time", + "10", + "http://localhost:8000/category/", + ] + interval: 30s + timeout: 10s + retries: 2 + + + # --------------------------------- + # Autoheal Service + # --------------------------------- + autoheal: + image: willfarrell/autoheal + restart: unless-stopped + environment: + - AUTOHEAL_CONTAINER_LABEL=autoheal + - AUTOHEAL_DEFAULT_STOP_TIMEOUT=10 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + networks: + - houshan-network + + # --------------------------------- + # Database Service + # --------------------------------- + # postgres: + # image: postgres:17-alpine + # volumes: + # - houshan-postgres:/var/lib/postgresql/data + # environment: + # POSTGRES_DB: ${API_DB_NAME} + # POSTGRES_USER: ${API_DB_USER} + # POSTGRES_PASSWORD: ${API_DB_PASS} + # ports: + # - 54921:5432 + # networks: + # - houshan-network + # healthcheck: + # test: [ "CMD", "pg_isready", "-q", "-d", "${API_DB_NAME}", "-U", "${API_DB_USER}" ] + # interval: 10s + # timeout: 5s + # retries: 3 + # start_period: 60s + # restart: unless-stopped + + # --------------------------------- + # Database Backup Service + # --------------------------------- + # backups: + # image: postgres:17-alpine + # command: >- + # sh -c 'echo "Starting backups service" && sleep "${API_DB_BACKUP_DELAY}" && + # while true; do + # echo "Running pg_dump" && pg_dump -h postgres -p 5432 -d "${API_DB_NAME}" -U "${API_DB_USER}" | gzip > "/srv/houshan-postgres/backups/houshan-postgres-backup-$(date "+%Y-%m-%d_%H-%M").sql" && + # echo "Keeping last 3 backups" && ls -1t "/srv/houshan-postgres/backups/houshan-postgres-backup-"*.gz | tail -n +4 | xargs rm -f && + # echo "Sleeping for ${API_DB_BACKUP_INTERVAL}" && sleep "${API_DB_BACKUP_INTERVAL}"; done' + # volumes: + # - ./db-backup/houshan-postgres-backup:/var/lib/postgresql/data + # - ./db-backup/houshan-database-backups:/srv/houshan-postgres/backups + # environment: + # PGPASSWORD: ${API_DB_PASS} + # networks: + # - houshan-network + # restart: unless-stopped + # depends_on: + # postgres: + # condition: service_healthy + + # backups: + # image: postgres:17-alpine + # command: ["/bin/sh", "/usr/local/bin/backup.sh"] + # volumes: + # - ./backup.sh:/usr/local/bin/backup.sh + # - ./db-backup/houshan-database-backups:/srv/houshan-postgres/backups + # environment: + # PGPASSWORD: ${API_DB_PASS} + # API_DB_BACKUP_DELAY: ${API_DB_BACKUP_DELAY} + # API_DB_BACKUP_INTERVAL: ${API_DB_BACKUP_INTERVAL} + # API_DB_NAME: ${API_DB_NAME} + # API_DB_USER: ${API_DB_USER} + # networks: + # - houshan-network + # restart: unless-stopped + # depends_on: + # postgres: + # condition: service_healthy + + + # --------------------------------- + # Frontend Service (Your Flutter App) + # --------------------------------- + # web: + #build: + # context: ./frontend + # networks: + # - houshan-network +# restart: unless-stopped + + # --------------------------------- + # Main Reverse Proxy (Nginx) + # --------------------------------- + proxy: + image: nginx:alpine + ports: + - "80:80" + volumes: + - ./proxy/nginx.conf:/etc/nginx/conf.d/default.conf:ro + networks: + - houshan-network + restart: unless-stopped diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..9f0e7fe --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,23 @@ +FROM nginx:alpine + +# Setting the working directory inside the container +WORKDIR /usr/share/nginx/html + +# Copying the zip file containing the Flutter web build into the container +COPY build.zip /tmp/build.zip + +# Installing unzip, extracting the build files with overwrite, and cleaning up +RUN apk add --no-cache unzip && \ + unzip -o /tmp/build.zip -d /usr/share/nginx/html && \ + rm /tmp/build.zip && \ + chown -R nginx:nginx /usr/share/nginx/html + +# Copying a custom Nginx configuration to serve the Flutter app +# It looks for 'nginx.conf' in the same directory (the build context) +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Exposing port 80 (inside the container) +EXPOSE 80 + +# Starting Nginx in the foreground +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..f8ebba8 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,29 @@ +server { + listen 80; + server_name localhost; + + root /usr/share/nginx/html; + index index.html; + + # Serving static files from Flutter build + location / { + try_files $uri $uri/ /index.html; # Fallback to index.html for SPA routing + } + + # Caching static assets for better performance + location ~* \.(png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|js|json|wasm)$ { + expires 1y; + access_log off; + add_header Cache-Control "public"; + } + + # Handling CORS for Flutter web + location ~* \.(js|json|wasm)$ { + add_header 'Access-Control-Allow-Origin' '*'; + add_header 'Access-Control-Allow-Methods' 'GET, HEAD'; + } + + # Logging configuration + access_log /var/log/nginx/access.log; + error_log /var/log/nginx/error.log; +} diff --git a/migrations/models/0_20250106094716_init.py b/migrations/models/0_20250106094716_init.py new file mode 100644 index 0000000..c715088 --- /dev/null +++ b/migrations/models/0_20250106094716_init.py @@ -0,0 +1,173 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + CREATE TABLE IF NOT EXISTS "categories" ( + "id" SERIAL NOT NULL PRIMARY KEY, + "name" VARCHAR(255) NOT NULL +); +CREATE TABLE IF NOT EXISTS "discounts" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "id" SERIAL NOT NULL PRIMARY KEY, + "code" VARCHAR(8) NOT NULL, + "percent" INT, + "value" INT, + "max_value" INT, + "expires_at" TIMESTAMPTZ +); +CREATE TABLE IF NOT EXISTS "users" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "id" UUID NOT NULL PRIMARY KEY, + "mobile_number" VARCHAR(11) UNIQUE, + "password" VARCHAR(255), + "name" VARCHAR(255), + "image" VARCHAR(255), + "email" VARCHAR(255) UNIQUE, + "username" VARCHAR(255) UNIQUE, + "verified" BOOL NOT NULL DEFAULT False, + "otp" VARCHAR(6), + "otp_expires_at" TIMESTAMPTZ, + "code" VARCHAR(6), + "card_number" VARCHAR(16), + "income" DOUBLE PRECISION NOT NULL DEFAULT 0, + "credit" INT NOT NULL DEFAULT 0, + "free_credit" INT NOT NULL DEFAULT 100 +); +CREATE INDEX IF NOT EXISTS "idx_users_mobile__c5f07a" ON "users" ("mobile_number"); +CREATE INDEX IF NOT EXISTS "idx_users_email_133a6f" ON "users" ("email"); +CREATE INDEX IF NOT EXISTS "idx_users_usernam_266d85" ON "users" ("username"); +CREATE TABLE IF NOT EXISTS "billings" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "id" SERIAL NOT NULL PRIMARY KEY, + "amount" INT NOT NULL, + "credit" INT NOT NULL, + "discount" INT NOT NULL DEFAULT 0, + "authority" VARCHAR(255) UNIQUE, + "ref_id" VARCHAR(255) UNIQUE, + "type" VARCHAR(9) NOT NULL DEFAULT 'purchase', + "status" VARCHAR(7) NOT NULL DEFAULT 'pending', + "user_id" UUID REFERENCES "users" ("id") ON DELETE SET NULL +); +COMMENT ON COLUMN "billings"."type" IS 'FREE: free\nCODE: code\nINVITE: invite\nWELCOME: welcome\nPURCHASE: purchase\nINSTAGRAM: instagram'; +COMMENT ON COLUMN "billings"."status" IS 'SUCCESS: success\nFAILED: failed\nPENDING: pending'; +CREATE TABLE IF NOT EXISTS "bots" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "id" SERIAL NOT NULL PRIMARY KEY, + "name" VARCHAR(255), + "model" VARCHAR(255) NOT NULL, + "description" TEXT, + "image" VARCHAR(255) NOT NULL, + "attachment" INT NOT NULL DEFAULT 0, + "attachment_type" JSONB, + "tool" BOOL NOT NULL DEFAULT False, + "public" BOOL NOT NULL DEFAULT True, + "cost" INT NOT NULL DEFAULT 0, + "limit" INT NOT NULL DEFAULT -1, + "prompt" TEXT, + "links" JSONB, + "docs" JSONB, + "status" VARCHAR(9) NOT NULL DEFAULT 'confirmed', + "category_id" INT REFERENCES "categories" ("id") ON DELETE SET NULL, + "user_id" UUID REFERENCES "users" ("id") ON DELETE SET NULL +); +COMMENT ON COLUMN "bots"."status" IS 'CONFIRMED: confirmed\nREJECTED: rejected\nPENDING: pending'; +CREATE TABLE IF NOT EXISTS "chatbots" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "id" SERIAL NOT NULL PRIMARY KEY, + "title" VARCHAR(255), + "archive" BOOL NOT NULL DEFAULT False, + "bot_id" INT NOT NULL REFERENCES "bots" ("id") ON DELETE CASCADE, + "user_id" UUID NOT NULL REFERENCES "users" ("id") ON DELETE CASCADE +); +CREATE TABLE IF NOT EXISTS "comments" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "id" SERIAL NOT NULL PRIMARY KEY, + "text" TEXT NOT NULL, + "image" VARCHAR(255), + "category_id" INT NOT NULL REFERENCES "categories" ("id") ON DELETE CASCADE, + "parent_id" INT REFERENCES "comments" ("id") ON DELETE SET NULL, + "replied_user_id" UUID REFERENCES "users" ("id") ON DELETE SET NULL, + "user_id" UUID NOT NULL REFERENCES "users" ("id") ON DELETE CASCADE +); +CREATE TABLE IF NOT EXISTS "feedbacks" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "id" SERIAL NOT NULL PRIMARY KEY, + "status" VARCHAR(8) NOT NULL, + "comment_id" INT NOT NULL REFERENCES "comments" ("id") ON DELETE CASCADE, + "user_id" UUID NOT NULL REFERENCES "users" ("id") ON DELETE CASCADE, + CONSTRAINT "uid_feedbacks_user_id_c72030" UNIQUE ("user_id", "comment_id") +); +COMMENT ON COLUMN "feedbacks"."status" IS 'LIKED: liked\nDISLIKED: disliked'; +CREATE TABLE IF NOT EXISTS "marks" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "id" SERIAL NOT NULL PRIMARY KEY, + "bot_id" INT NOT NULL REFERENCES "bots" ("id") ON DELETE CASCADE, + "user_id" UUID NOT NULL REFERENCES "users" ("id") ON DELETE CASCADE, + CONSTRAINT "uid_marks_user_id_76ce59" UNIQUE ("user_id", "bot_id") +); +CREATE TABLE IF NOT EXISTS "messages" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "id" SERIAL NOT NULL PRIMARY KEY, + "input_tokens" INT NOT NULL, + "output_tokens" INT NOT NULL, + "chatbot_id" INT REFERENCES "chatbots" ("id") ON DELETE SET NULL +); +CREATE TABLE IF NOT EXISTS "notifications" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "id" SERIAL NOT NULL PRIMARY KEY, + "title" VARCHAR(255) NOT NULL, + "message" VARCHAR(255) NOT NULL, + "seen" BOOL NOT NULL DEFAULT False, + "user_id" UUID NOT NULL REFERENCES "users" ("id") ON DELETE CASCADE +); +CREATE TABLE IF NOT EXISTS "rates" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "id" SERIAL NOT NULL PRIMARY KEY, + "text" TEXT NOT NULL, + "score" INT NOT NULL, + "bot_id" INT NOT NULL REFERENCES "bots" ("id") ON DELETE CASCADE, + "user_id" UUID NOT NULL REFERENCES "users" ("id") ON DELETE CASCADE +); +CREATE TABLE IF NOT EXISTS "settlements" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "id" SERIAL NOT NULL PRIMARY KEY, + "income" INT NOT NULL, + "confirmed" BOOL NOT NULL DEFAULT False, + "type" VARCHAR(5) NOT NULL, + "user_id" UUID NOT NULL REFERENCES "users" ("id") ON DELETE CASCADE +); +COMMENT ON COLUMN "settlements"."type" IS 'COIN: coin\nMONEY: money'; +CREATE TABLE IF NOT EXISTS "tickets" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "id" SERIAL NOT NULL PRIMARY KEY, + "text" TEXT, + "role" VARCHAR(5) NOT NULL DEFAULT 'user', + "file" VARCHAR(255), + "user_id" UUID NOT NULL REFERENCES "users" ("id") ON DELETE CASCADE +); +COMMENT ON COLUMN "tickets"."role" IS 'USER: user\nADMIN: admin'; +CREATE TABLE IF NOT EXISTS "aerich" ( + "id" SERIAL NOT NULL PRIMARY KEY, + "version" VARCHAR(255) NOT NULL, + "app" VARCHAR(100) NOT NULL, + "content" JSONB NOT NULL +);""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + """ diff --git a/migrations/models/10_20250130091551_update_category_model.py b/migrations/models/10_20250130091551_update_category_model.py new file mode 100644 index 0000000..5f71146 --- /dev/null +++ b/migrations/models/10_20250130091551_update_category_model.py @@ -0,0 +1,15 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "categories" ADD "image" VARCHAR(255); + ALTER TABLE "categories" ADD "description" TEXT; + ALTER TABLE "categories" ADD "tool" BOOL NOT NULL DEFAULT False;""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "categories" DROP COLUMN "image"; + ALTER TABLE "categories" DROP COLUMN "description"; + ALTER TABLE "categories" DROP COLUMN "tool";""" diff --git a/migrations/models/11_20250201101206_update_banner_model.py b/migrations/models/11_20250201101206_update_banner_model.py new file mode 100644 index 0000000..80b94f0 --- /dev/null +++ b/migrations/models/11_20250201101206_update_banner_model.py @@ -0,0 +1,13 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "banners" ADD "bot_id" INT; + ALTER TABLE "banners" ADD CONSTRAINT "fk_banners_bots_1c5a6f42" FOREIGN KEY ("bot_id") REFERENCES "bots" ("id") ON DELETE SET NULL;""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "banners" DROP CONSTRAINT "fk_banners_bots_1c5a6f42"; + ALTER TABLE "banners" DROP COLUMN "bot_id";""" diff --git a/migrations/models/12_20250201110621_update_banner_model.py b/migrations/models/12_20250201110621_update_banner_model.py new file mode 100644 index 0000000..af45bf6 --- /dev/null +++ b/migrations/models/12_20250201110621_update_banner_model.py @@ -0,0 +1,19 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "banners" ADD "link" VARCHAR(255); + ALTER TABLE "banners" ALTER COLUMN "title" SET DEFAULT ''; + ALTER TABLE "banners" ALTER COLUMN "title" SET NOT NULL; + ALTER TABLE "banners" ALTER COLUMN "description" SET DEFAULT ''; + ALTER TABLE "banners" ALTER COLUMN "description" SET NOT NULL;""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "banners" DROP COLUMN "link"; + ALTER TABLE "banners" ALTER COLUMN "title" DROP NOT NULL; + ALTER TABLE "banners" ALTER COLUMN "title" DROP DEFAULT; + ALTER TABLE "banners" ALTER COLUMN "description" DROP NOT NULL; + ALTER TABLE "banners" ALTER COLUMN "description" DROP DEFAULT;""" diff --git a/migrations/models/13_20250201143637_update_bot_model.py b/migrations/models/13_20250201143637_update_bot_model.py new file mode 100644 index 0000000..e84bcee --- /dev/null +++ b/migrations/models/13_20250201143637_update_bot_model.py @@ -0,0 +1,11 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "bots" ADD "deleted" BOOL NOT NULL DEFAULT False;""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "bots" DROP COLUMN "deleted";""" diff --git a/migrations/models/14_20250202114245_create_code_model.py b/migrations/models/14_20250202114245_create_code_model.py new file mode 100644 index 0000000..974f721 --- /dev/null +++ b/migrations/models/14_20250202114245_create_code_model.py @@ -0,0 +1,20 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + CREATE TABLE IF NOT EXISTS "code" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "id" SERIAL NOT NULL PRIMARY KEY, + "usage_count" INT NOT NULL DEFAULT 0, + "code" VARCHAR(6) NOT NULL UNIQUE, + "message" VARCHAR(255) +); + ALTER TABLE "plans" ADD "description" VARCHAR(255);""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "plans" DROP COLUMN "description"; + DROP TABLE IF EXISTS "code";""" diff --git a/migrations/models/15_20250217085342_update_bot_model.py b/migrations/models/15_20250217085342_update_bot_model.py new file mode 100644 index 0000000..544db3d --- /dev/null +++ b/migrations/models/15_20250217085342_update_bot_model.py @@ -0,0 +1,11 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "bots" ADD "order" INT NOT NULL DEFAULT 1;""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "bots" DROP COLUMN "order";""" diff --git a/migrations/models/16_20250222102808_update_notification_model.py b/migrations/models/16_20250222102808_update_notification_model.py new file mode 100644 index 0000000..4c62bdd --- /dev/null +++ b/migrations/models/16_20250222102808_update_notification_model.py @@ -0,0 +1,11 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "notifications" ALTER COLUMN "message" TYPE TEXT USING "message"::TEXT;""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "notifications" ALTER COLUMN "message" TYPE VARCHAR(255) USING "message"::VARCHAR(255);""" diff --git a/migrations/models/17_20250303105524_update_message_model.py b/migrations/models/17_20250303105524_update_message_model.py new file mode 100644 index 0000000..2aba024 --- /dev/null +++ b/migrations/models/17_20250303105524_update_message_model.py @@ -0,0 +1,13 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "messages" ADD "cost" INT NOT NULL DEFAULT -1; + ALTER TABLE "users" ALTER COLUMN "free_credit" SET DEFAULT 50;""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "users" ALTER COLUMN "free_credit" SET DEFAULT 100; + ALTER TABLE "messages" DROP COLUMN "cost";""" diff --git a/migrations/models/18_20250306105319_update_bot_model.py b/migrations/models/18_20250306105319_update_bot_model.py new file mode 100644 index 0000000..7940428 --- /dev/null +++ b/migrations/models/18_20250306105319_update_bot_model.py @@ -0,0 +1,11 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "bots" ADD "web_search" BOOL NOT NULL DEFAULT False;""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "bots" DROP COLUMN "web_search";""" diff --git a/migrations/models/19_20250308134441_update_category_model.py b/migrations/models/19_20250308134441_update_category_model.py new file mode 100644 index 0000000..7f9f625 --- /dev/null +++ b/migrations/models/19_20250308134441_update_category_model.py @@ -0,0 +1,11 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "categories" ADD "character" BOOL NOT NULL DEFAULT False;""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "categories" DROP COLUMN "character";""" diff --git a/migrations/models/1_20250106145739_update_billing_model.py b/migrations/models/1_20250106145739_update_billing_model.py new file mode 100644 index 0000000..3d0cf93 --- /dev/null +++ b/migrations/models/1_20250106145739_update_billing_model.py @@ -0,0 +1,11 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "billings" ADD "code" VARCHAR(8);""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "billings" DROP COLUMN "code";""" diff --git a/migrations/models/20_20250310094400_update_category_model.py b/migrations/models/20_20250310094400_update_category_model.py new file mode 100644 index 0000000..47ccca4 --- /dev/null +++ b/migrations/models/20_20250310094400_update_category_model.py @@ -0,0 +1,11 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "categories" ADD "media" BOOL NOT NULL DEFAULT False;""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "categories" DROP COLUMN "media";""" diff --git a/migrations/models/21_20250310114405_update_category_model.py b/migrations/models/21_20250310114405_update_category_model.py new file mode 100644 index 0000000..12af78c --- /dev/null +++ b/migrations/models/21_20250310114405_update_category_model.py @@ -0,0 +1,13 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "categories" ADD "parent_category_id" INT; + ALTER TABLE "categories" ADD CONSTRAINT "fk_categori_categori_6c119c37" FOREIGN KEY ("parent_category_id") REFERENCES "categories" ("id") ON DELETE SET NULL;""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "categories" DROP CONSTRAINT "fk_categori_categori_6c119c37"; + ALTER TABLE "categories" DROP COLUMN "parent_category_id";""" diff --git a/migrations/models/22_20250310151750_update_bot_model.py b/migrations/models/22_20250310151750_update_bot_model.py new file mode 100644 index 0000000..6e37790 --- /dev/null +++ b/migrations/models/22_20250310151750_update_bot_model.py @@ -0,0 +1,11 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "bots" ADD "style" VARCHAR(255);""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "bots" DROP COLUMN "style";""" diff --git a/migrations/models/23_20250311084644_create_suno_tasks_model.py b/migrations/models/23_20250311084644_create_suno_tasks_model.py new file mode 100644 index 0000000..5e59b21 --- /dev/null +++ b/migrations/models/23_20250311084644_create_suno_tasks_model.py @@ -0,0 +1,23 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + CREATE TABLE IF NOT EXISTS "suno_tasks" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "id" UUID NOT NULL PRIMARY KEY, + "task_id" VARCHAR(255) NOT NULL UNIQUE, + "status" VARCHAR(50) NOT NULL DEFAULT 'PENDING', + "audio_url" VARCHAR(500), + "image_url" VARCHAR(500), + "audio_url2" VARCHAR(500), + "image_url2" VARCHAR(500), + "chatbot_id" INT NOT NULL REFERENCES "chatbots" ("id") ON DELETE CASCADE, + "user_id" UUID NOT NULL REFERENCES "users" ("id") ON DELETE CASCADE +);""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP TABLE IF EXISTS "suno_tasks";""" diff --git a/migrations/models/24_20250313085522_update_bot_model.py b/migrations/models/24_20250313085522_update_bot_model.py new file mode 100644 index 0000000..1084cff --- /dev/null +++ b/migrations/models/24_20250313085522_update_bot_model.py @@ -0,0 +1,11 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "bots" ADD "type" VARCHAR(9) NOT NULL DEFAULT 'llm';""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "bots" DROP COLUMN "type";""" diff --git a/migrations/models/25_20250313094152_create_event_&_winner_models.py b/migrations/models/25_20250313094152_create_event_&_winner_models.py new file mode 100644 index 0000000..5fc63ba --- /dev/null +++ b/migrations/models/25_20250313094152_create_event_&_winner_models.py @@ -0,0 +1,33 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + CREATE TABLE IF NOT EXISTS "events" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "id" SERIAL NOT NULL PRIMARY KEY, + "title" VARCHAR(255) NOT NULL, + "description" TEXT NOT NULL, + "awards" TEXT NOT NULL, + "image" VARCHAR(255), + "start_at" TIMESTAMPTZ NOT NULL, + "end_at" TIMESTAMPTZ NOT NULL, + "is_open" BOOL NOT NULL DEFAULT True, + "total_participants" INT NOT NULL DEFAULT 0, + "total_received_works" INT NOT NULL DEFAULT 0 +); + CREATE TABLE IF NOT EXISTS "winners" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "id" SERIAL NOT NULL PRIMARY KEY, + "rank" INT NOT NULL, + "event_id" INT NOT NULL REFERENCES "events" ("id") ON DELETE CASCADE, + "user_id" UUID NOT NULL REFERENCES "users" ("id") ON DELETE CASCADE +);""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP TABLE IF EXISTS "events"; + DROP TABLE IF EXISTS "winners";""" diff --git a/migrations/models/26_20250316135700_update_bot_model.py b/migrations/models/26_20250316135700_update_bot_model.py new file mode 100644 index 0000000..4f3ef0c --- /dev/null +++ b/migrations/models/26_20250316135700_update_bot_model.py @@ -0,0 +1,11 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "bots" ADD "image_2" VARCHAR(255);""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "bots" DROP COLUMN "image_2";""" diff --git a/migrations/models/27_20250316142019_update_category_model.py b/migrations/models/27_20250316142019_update_category_model.py new file mode 100644 index 0000000..df0c7f1 --- /dev/null +++ b/migrations/models/27_20250316142019_update_category_model.py @@ -0,0 +1,11 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "categories" ADD "icon" VARCHAR(255);""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "categories" DROP COLUMN "icon";""" diff --git a/migrations/models/28_20250318121834_update_event_model.py b/migrations/models/28_20250318121834_update_event_model.py new file mode 100644 index 0000000..c5e07a7 --- /dev/null +++ b/migrations/models/28_20250318121834_update_event_model.py @@ -0,0 +1,11 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "events" ADD "subtitle" VARCHAR(255) NOT NULL DEFAULT '';""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "events" DROP COLUMN "subtitle";""" diff --git a/migrations/models/29_20250408132115_update.py b/migrations/models/29_20250408132115_update.py new file mode 100644 index 0000000..b33763e --- /dev/null +++ b/migrations/models/29_20250408132115_update.py @@ -0,0 +1,11 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "users" ADD "telegram_id" VARCHAR(20);""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "users" DROP COLUMN "telegram_id";""" diff --git a/migrations/models/2_20250116093753_update_rate_model.py b/migrations/models/2_20250116093753_update_rate_model.py new file mode 100644 index 0000000..5d33547 --- /dev/null +++ b/migrations/models/2_20250116093753_update_rate_model.py @@ -0,0 +1,11 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "rates" ALTER COLUMN "score" TYPE DOUBLE PRECISION USING "score"::DOUBLE PRECISION;""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "rates" ALTER COLUMN "score" TYPE INT USING "score"::INT;""" diff --git a/migrations/models/30_20250409123731_app_metadata.py b/migrations/models/30_20250409123731_app_metadata.py new file mode 100644 index 0000000..ab4d9fd --- /dev/null +++ b/migrations/models/30_20250409123731_app_metadata.py @@ -0,0 +1,16 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + CREATE TABLE IF NOT EXISTS "app_metadata" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "tag" VARCHAR(24) NOT NULL PRIMARY KEY, + "value" TEXT +);""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP TABLE IF EXISTS "app_metadata";""" diff --git a/migrations/models/31_20250416113625_ads.py b/migrations/models/31_20250416113625_ads.py new file mode 100644 index 0000000..61a2a6d --- /dev/null +++ b/migrations/models/31_20250416113625_ads.py @@ -0,0 +1,18 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + CREATE TABLE IF NOT EXISTS "advertisements" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "id" SERIAL NOT NULL PRIMARY KEY, + "ad_type" VARCHAR(255) NOT NULL, + "timestamp" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "user_id" UUID NOT NULL REFERENCES "users" ("id") ON DELETE CASCADE +);""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP TABLE IF EXISTS "advertisements";""" \ No newline at end of file diff --git a/migrations/models/32_20250417000000_add_user_level.py b/migrations/models/32_20250417000000_add_user_level.py new file mode 100644 index 0000000..8d90e84 --- /dev/null +++ b/migrations/models/32_20250417000000_add_user_level.py @@ -0,0 +1,9 @@ +from tortoise import BaseDBAsyncClient + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "users" ADD "level" INT NOT NULL DEFAULT 1;""" + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "users" DROP COLUMN "level";""" \ No newline at end of file diff --git a/migrations/models/33_20250418000000_add_bot_allowed_levels.py b/migrations/models/33_20250418000000_add_bot_allowed_levels.py new file mode 100644 index 0000000..86e6759 --- /dev/null +++ b/migrations/models/33_20250418000000_add_bot_allowed_levels.py @@ -0,0 +1,9 @@ +from tortoise import BaseDBAsyncClient + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "bots" ADD "allowed_levels" JSONB;""" + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "bots" DROP COLUMN "allowed_levels";""" \ No newline at end of file diff --git a/migrations/models/34_20250419000000_add_user_parent.py b/migrations/models/34_20250419000000_add_user_parent.py new file mode 100644 index 0000000..432b773 --- /dev/null +++ b/migrations/models/34_20250419000000_add_user_parent.py @@ -0,0 +1,11 @@ +from tortoise import BaseDBAsyncClient + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "users" ADD "parent_id" UUID; + ALTER TABLE "users" ADD CONSTRAINT "fk_users_users_parent" FOREIGN KEY ("parent_id") REFERENCES "users" ("id") ON DELETE SET NULL;""" + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "users" DROP CONSTRAINT "fk_users_users_parent"; + ALTER TABLE "users" DROP COLUMN "parent_id";""" \ No newline at end of file diff --git a/migrations/models/3_20250119093009_update_bot_model.py b/migrations/models/3_20250119093009_update_bot_model.py new file mode 100644 index 0000000..17ba359 --- /dev/null +++ b/migrations/models/3_20250119093009_update_bot_model.py @@ -0,0 +1,11 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "bots" ALTER COLUMN "image" DROP NOT NULL;""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "bots" ALTER COLUMN "image" SET NOT NULL;""" diff --git a/migrations/models/4_20250120105036_create_banner_model.py b/migrations/models/4_20250120105036_create_banner_model.py new file mode 100644 index 0000000..1c277e9 --- /dev/null +++ b/migrations/models/4_20250120105036_create_banner_model.py @@ -0,0 +1,17 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + CREATE TABLE IF NOT EXISTS "banners" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "id" SERIAL NOT NULL PRIMARY KEY, + "image" VARCHAR(255) NOT NULL, + "description" TEXT NOT NULL +);""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP TABLE IF EXISTS "banners";""" diff --git a/migrations/models/5_20250120135043_update_banner_model.py b/migrations/models/5_20250120135043_update_banner_model.py new file mode 100644 index 0000000..816e1d8 --- /dev/null +++ b/migrations/models/5_20250120135043_update_banner_model.py @@ -0,0 +1,17 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "banners" ADD "title" VARCHAR(255); + ALTER TABLE "banners" ALTER COLUMN "description" DROP DEFAULT; + ALTER TABLE "banners" ALTER COLUMN "description" DROP NOT NULL; + ALTER TABLE "banners" ALTER COLUMN "description" TYPE VARCHAR(255) USING "description"::VARCHAR(255);""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "banners" DROP COLUMN "title"; + ALTER TABLE "banners" ALTER COLUMN "description" TYPE TEXT USING "description"::TEXT; + ALTER TABLE "banners" ALTER COLUMN "description" SET NOT NULL; + ALTER TABLE "banners" ALTER COLUMN "description" SET;""" diff --git a/migrations/models/6_20250121155650_update_user_model.py b/migrations/models/6_20250121155650_update_user_model.py new file mode 100644 index 0000000..718e8f8 --- /dev/null +++ b/migrations/models/6_20250121155650_update_user_model.py @@ -0,0 +1,11 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "users" ADD "role" VARCHAR(5) NOT NULL DEFAULT 'user';""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "users" DROP COLUMN "role";""" diff --git a/migrations/models/7_20250125094403_create_plan_model.py b/migrations/models/7_20250125094403_create_plan_model.py new file mode 100644 index 0000000..efc6474 --- /dev/null +++ b/migrations/models/7_20250125094403_create_plan_model.py @@ -0,0 +1,20 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + CREATE TABLE IF NOT EXISTS "plans" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "id" VARCHAR(20) NOT NULL PRIMARY KEY, + "price" INT NOT NULL, + "coins" INT NOT NULL, + "free_coins" INT NOT NULL, + "title" VARCHAR(255) NOT NULL, + "active" BOOL NOT NULL DEFAULT False +);""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP TABLE IF EXISTS "plans";""" diff --git a/migrations/models/8_20250125162351_update_user_model.py b/migrations/models/8_20250125162351_update_user_model.py new file mode 100644 index 0000000..e77ff27 --- /dev/null +++ b/migrations/models/8_20250125162351_update_user_model.py @@ -0,0 +1,11 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "users" ADD "firebase_token" VARCHAR(255);""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "users" DROP COLUMN "firebase_token";""" diff --git a/migrations/models/9_20250127150507_update_plan_model.py b/migrations/models/9_20250127150507_update_plan_model.py new file mode 100644 index 0000000..70edbfc --- /dev/null +++ b/migrations/models/9_20250127150507_update_plan_model.py @@ -0,0 +1,11 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "plans" ADD "image" VARCHAR(255);""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "plans" DROP COLUMN "image";""" diff --git a/proxy/nginx.conf b/proxy/nginx.conf new file mode 100644 index 0000000..27291c5 --- /dev/null +++ b/proxy/nginx.conf @@ -0,0 +1,93 @@ +# # Upstream for the API +upstream api_server { + server basa_api:8000; +} + +# # Upstream for the Frontend +# upstream panel_server { +# server nextjs_app:3000; +# } + +# upstream plan_server { +# server basa_plans:6000; +# } + + +# Server block for your API +server { + listen 80; + server_name basa.houshan.ai; + + location / { + proxy_pass http://api_server; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +# server { +# listen 80; +# server_name basapanel.houshan.ai; + +# location / { +# proxy_pass http://panel_server; + +# proxy_set_header Host $host; +# proxy_set_header X-Real-IP $remote_addr; +# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +# proxy_set_header X-Forwarded-Proto $scheme; + +# proxy_http_version 1.1; +# proxy_set_header Upgrade $http_upgrade; +# proxy_set_header Connection "upgrade"; +# } +# } + +# server { +# listen 80; +# server_name basaplans.houshan.ai; + +# location / { +# proxy_pass http://plan_server; + +# proxy_set_header Host $host; +# proxy_set_header X-Real-IP $remote_addr; +# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +# proxy_set_header X-Forwarded-Proto $scheme; + +# proxy_http_version 1.1; +# proxy_set_header Upgrade $http_upgrade; +# proxy_set_header Connection "upgrade"; +# } +# } + # --- FLUTTER FRONTEND --- +# server { +# listen 80; +# server_name basaweb.houshan.ai; + +# location / { +# resolver 127.0.0.11 valid=30s; +# set $upstream_flutter basa_flutter:80; +# proxy_pass http://$upstream_flutter; +# proxy_set_header Host $host; +# proxy_set_header X-Real-IP $remote_addr; +# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +# proxy_set_header X-Forwarded-Proto http; + +# # Support for WebSockets +# proxy_http_version 1.1; +# proxy_set_header Upgrade $http_upgrade; +# proxy_set_header Connection "upgrade"; +# } +# } +# A default server to catch any other requests +server { + listen 80 default_server; + server_name _; + + location / { + # Returns a 404 Not Found for any requests to unhandled domains + return 404; + } +} diff --git a/proxy/nginx.conf.save b/proxy/nginx.conf.save new file mode 100644 index 0000000..a649be3 --- /dev/null +++ b/proxy/nginx.conf.save @@ -0,0 +1,33 @@ +# Upstream for the API +upstream api_server { + server app:8000; +} + +# Upstream for the Frontend +#upstream web_server { + # server web:80; +#} + +# Server block for your API +server { + listen 80; + server_name api.basa.houshan.ai; + + location / { + proxy_pass http://api_server; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +# A default server to catch any other requests +server { + listen 80 default_server; + server_name _; + + location / { + # Returns a 404 Not Found for any requests to unhandled domains + return 404; + } +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0008d8e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,4 @@ +[tool.aerich] +tortoise_orm = "settings.TORTOISE_ORM" +location = "./migrations" +src_folder = "./src" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f4efe02 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,218 @@ +aerich==0.7.2 +aioboto3==13.2.0 +aiobotocore==2.15.2 +aiofiles==24.1.0 +aiohappyeyeballs==2.4.3 +aiohttp==3.10.10 +aioitertools==0.12.0 +aiosignal==1.3.1 +aiosqlite==0.17.0 +annotated-types==0.7.0 +anthropic==0.42.0 +anyio==4.6.0 +APScheduler==3.11.0 +asgiref==3.8.1 +asyncpg==0.29.0 +attrs==24.2.0 +backoff==2.2.1 +bcrypt==4.2.0 +beautifulsoup4==4.12.3 +boto3==1.35.36 +botocore==1.35.36 +build==1.2.2.post1 +CacheControl==0.14.2 +cachetools==5.5.0 +certifi==2024.8.30 +cffi==1.17.1 +chardet==5.2.0 +charset-normalizer==3.3.2 +chroma-hnswlib==0.7.3 +chromadb==0.5.0 +click==8.1.8 +coloredlogs==15.0.1 +cryptography==43.0.1 +dataclasses-json==0.6.7 +defusedxml==0.7.1 +Deprecated==1.2.15 +dictdiffer==0.9.0 +distro==1.9.0 +dnspython==2.6.1 +docx2txt==0.8 +duckduckgo_search==7.3.0 +durationpy==0.9 +email_validator==2.2.0 +emoji==2.14.0 +et_xmlfile==2.0.0 +eval_type_backport==0.2.2 +fal_client==0.5.8 +fastapi==0.115.0 +fastapi-cli==0.0.5 +filelock==3.16.1 +filetype==1.2.0 +firebase-admin==6.6.0 +flatbuffers==24.3.25 +frozenlist==1.4.1 +fsspec==2024.12.0 +google-ai-generativelanguage==0.6.10 +google-api-core==2.21.0 +google-api-python-client==2.149.0 +google-auth==2.35.0 +google-auth-httplib2==0.2.0 +google-cloud-core==2.4.1 +google-cloud-firestore==2.20.0 +google-cloud-storage==2.19.0 +google-crc32c==1.6.0 +google-generativeai==0.8.3 +google-resumable-media==2.7.2 +googleapis-common-protos==1.65.0 +greenlet==3.1.1 +grpcio==1.67.0 +grpcio-status==1.67.0 +h11==0.14.0 +html5lib==1.1 +httpcore==1.0.5 +httplib2==0.22.0 +httptools==0.6.1 +httpx==0.27.2 +httpx-sse==0.4.0 +huggingface-hub==0.27.0 +humanfriendly==10.0 +idna==3.10 +importlib_metadata==8.5.0 +importlib_resources==6.4.5 +iso8601==1.1.0 +isodate==0.7.2 +Jinja2==3.1.4 +jiter==0.6.1 +jmespath==1.0.1 +joblib==1.4.2 +jsonpatch==1.33 +jsonpath-python==1.0.6 +jsonpointer==3.0.0 +kavenegar==1.1.2 +Khayyam==3.0.17 +kubernetes==31.0.0 +langchain==0.3.13 +langchain-anthropic==0.3.1 +langchain-chroma==0.1.4 +langchain-community==0.3.13 +langchain-core==0.3.28 +langchain-google-genai==2.0.1 +langchain-openai==0.2.2 +langchain-text-splitters==0.3.4 +langdetect==1.0.9 +langgraph==0.2.37 +langgraph-checkpoint==2.0.1 +langgraph-checkpoint-postgres==2.0.1 +langgraph-sdk==0.1.33 +langsmith==0.1.135 +lxml==5.3.0 +mailersend==0.5.8 +markdown-it-py==3.0.0 +MarkupSafe==2.1.5 +marshmallow==3.23.0 +mdurl==0.1.2 +mmh3==5.0.1 +monotonic==1.6 +mpmath==1.3.0 +msgpack==1.1.0 +multidict==6.1.0 +mypy-extensions==1.0.0 +nest-asyncio==1.6.0 +networkx==3.4.2 +nltk==3.9.1 +numpy==1.26.4 +oauthlib==3.2.2 +olefile==0.47 +onnxruntime==1.20.1 +openai==1.68.2 +openpyxl==3.1.5 +opentelemetry-api==1.29.0 +opentelemetry-exporter-otlp-proto-common==1.29.0 +opentelemetry-exporter-otlp-proto-grpc==1.29.0 +opentelemetry-instrumentation==0.50b0 +opentelemetry-instrumentation-asgi==0.50b0 +opentelemetry-instrumentation-fastapi==0.50b0 +opentelemetry-proto==1.29.0 +opentelemetry-sdk==1.29.0 +opentelemetry-semantic-conventions==0.50b0 +opentelemetry-util-http==0.50b0 +orjson==3.10.7 +overrides==7.7.0 +packaging==24.1 +pandas==2.2.3 +passlib==1.7.4 +pillow==11.1.0 +platformdirs==4.3.6 +posthog==3.7.4 +primp==0.11.0 +propcache==0.2.0 +proto-plus==1.25.0 +protobuf==5.28.3 +psutil==6.1.1 +psycopg==3.2.3 +psycopg-pool==3.2.3 +pyasn1==0.6.1 +pyasn1_modules==0.4.1 +pycparser==2.22 +pydantic==2.9.2 +pydantic-settings==2.5.2 +pydantic_core==2.23.4 +pydub==0.25.1 +Pygments==2.18.0 +PyJWT==2.9.0 +pyparsing==3.2.0 +pypdf==5.1.0 +PyPika==0.48.9 +pypika-tortoise==0.1.6 +pyproject_hooks==1.2.0 +python-dateutil==2.9.0.post0 +python-dotenv==1.0.1 +python-iso639==2024.10.22 +python-magic==0.4.27 +python-multipart==0.0.10 +python-oxmsg==0.0.1 +pytz==2024.2 +PyYAML==6.0.2 +RapidFuzz==3.11.0 +regex==2024.9.11 +requests==2.32.3 +requests-file==2.1.0 +requests-oauthlib==2.0.0 +requests-toolbelt==1.0.0 +rich==13.8.1 +rsa==4.9 +s3transfer==0.10.3 +shellingham==1.5.4 +six==1.16.0 +sniffio==1.3.1 +soupsieve==2.6 +SQLAlchemy==2.0.35 +starlette==0.38.6 +sympy==1.13.3 +tenacity==8.5.0 +tiktoken==0.8.0 +tokenizers==0.20.3 +tomlkit==0.13.2 +tortoise-orm==0.21.6 +tqdm==4.66.5 +typer==0.12.5 +typing-inspect==0.9.0 +typing_extensions==4.12.2 +tzdata==2025.1 +tzlocal==5.2 +unstructured==0.16.11 +unstructured-client==0.28.1 +uritemplate==4.1.1 +urllib3==2.2.3 +uuid==1.30 +uvicorn==0.31.0 +uvloop==0.20.0 +watchfiles==0.24.0 +webencodings==0.5.1 +websocket-client==1.8.0 +websockets==13.1 +wrapt==1.16.0 +yarl==1.16.0 +zeep==4.3.1 +zipp==3.21.0 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..197cc70 --- /dev/null +++ b/src/config.py @@ -0,0 +1,38 @@ +from os import getenv +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + base_url: str = getenv("BASE_URL", "") + database_url: str = getenv("DATABASE_URL", "") + jwt_secret_key: str = getenv("JWT_SECRET_KEY", "") + jwt_admin_secret_key: str = getenv("JWT_ADMIN_SECRET_KEY", "") + jwt_algorithm: str = getenv("JWT_ALGORITHM", "") + kavenegar_api_key: str = getenv("KAVENEGAR_API_KEY", "") + openai_api_key: str = getenv("OPENAI_API_KEY", "") + google_api_key: str = getenv("GOOGLE_API_KEY", "") + deepseek_api_key: str = getenv("DEEPSEEK_API_KEY", "") + anthropic_api_key: str = getenv("ANTHROPIC_API_KEY", "") + st_endpoint: str = getenv("ST_ENDPOINT", "") + st_access_key: str = getenv("ST_ACCESS_KEY", "") + st_secret_key: str = getenv("ST_SECRET_KEY", "") + st_bucket_name: str = getenv("ST_BUCKET_NAME", "") + ydc_api_key: str = getenv("YDC_API_KEY", "") + mmerchant_id: str = getenv("MMERCHANT_ID", "") + chroma_url: str = getenv("CHROMA_URL", "") + chroma_credentials: str = getenv("CHROMA_CREDENTIALS", "") + cafebazaar_pishkhan_api_secret: str = getenv("CAFEBAZAAR_PISHKHAN_API_SECRET", "") + cafebazaar_price_key: str = getenv("CAFEBAZAAR_PRICE_KEY", "") + myket_access_token: str = getenv("MYKET_ACCESS_TOKEN", "") + ghost_id: str = getenv("GHOST_ID", "") + fal_key: str = getenv("FAL_KEY", "") + mailersend_api_key: str = getenv("MAILERSEND_API_KEY", "") + model_config = SettingsConfigDict(env_file=".api.env") + suno_api_key: str = getenv("SUNO_API_KEY", "") + pixverse_api_key: str = getenv("PIXVERSE_API_KEY", "") + internal_telegram_secret: str = getenv("INTERNAL_TELEGRAM_SECRET", "") + liara_api_url: str = getenv("LIARA_API_URL", "") + liara_api_key: str = getenv("LIARA_API_KEY", "") + + +settings = Settings() diff --git a/src/dependencies.py b/src/dependencies.py new file mode 100644 index 0000000..7370c61 --- /dev/null +++ b/src/dependencies.py @@ -0,0 +1,7 @@ +from fastapi.security import HTTPBearer +from passlib.context import CryptContext + + +auth_scheme = HTTPBearer(auto_error=True) + +pwd_context = CryptContext(schemes=["bcrypt"]) diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..41e2113 --- /dev/null +++ b/src/main.py @@ -0,0 +1,252 @@ +from fastapi import FastAPI, Form # <-- Form is imported here +from fastapi.staticfiles import StaticFiles +from fastapi.responses import StreamingResponse, JSONResponse +from fastapi.middleware.cors import CORSMiddleware +from tortoise.contrib.fastapi import register_tortoise +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.cron import CronTrigger +from .config import settings +from .tasks.user import free_credit +from .services import storage as storage_service, tool as tool_service +from .routes import register_routers +from .models import SunoTask +from pytz import timezone +from typing import Annotated # We keep the import but adjust usage to fix the NameError +import fal_client +import os +import httpx +import asyncio +import json +# مدل مورد نظر Fal AI برای تولید ویدئو از متن +FAL_VIDEO_MODEL = "fal-ai/pixverse/v3.5/text-to-video" +FAL_API_KEY = os.getenv("FAL_KEY") # کلید Fal API از محیط +FAL_BASE_URL = "https://queue.fal.run/fal-ai/pixverse" + +app = FastAPI() + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], + allow_credentials=True, +) + + +# Create scheduler +scheduler = AsyncIOScheduler() + +# Add a job to the scheduler +tehran_tz = timezone('Asia/Tehran') + +# scheduler.add_job( +# free_credit, +# CronTrigger(hour=0, minute=0, second=0, timezone=tehran_tz) +# ) + +# Start the scheduler +scheduler.start() + +register_tortoise( + app, + db_url=settings.database_url, + modules={"models": ["src.models"]}, + generate_schemas=False, + add_exception_handlers=True, +) + +app.mount( + "/.well-known", + StaticFiles(directory="static/.well-known"), + name=".well-known", +) + +register_routers(app) + + +@app.get("/", tags=["home"]) +async def info(): + return { + "image": "https://www.instagram.com/houshan.ai/", + "audio": "https://www.instagram.com/houshan.ai/", + "video": "https://www.instagram.com/houshan.ai/", + } + + +@app.get("/apk", tags=["app"]) +async def apk(): + file_size = await storage_service.get_file_size("apk/app-release.apk") + + headers = { + "Content-Disposition": 'attachment; filename="houshan.apk"', + "Content-Length": str(file_size), + } + + return StreamingResponse( + storage_service.get("apk/app-release.apk"), + media_type="application/octet-stream", + headers=headers, + ) + + +# ---------------------------------------------------------------------- +## 🎥 روت عمومی: تولید ویدئو از متن (با استفاده مستقیم از fal_client) +# ---------------------------------------------------------------------- +@app.post("/public/generate-video", tags=["public"]) +async def generate_public_video( + prompt: str = Form(..., description="The text prompt to generate the video from."), + length: int = Form(5), + negative_prompt: str = Form("blurry, low quality"), +): + if not FAL_API_KEY: + return JSONResponse({ + "status": "error", + "message": "FAL_KEY environment variable is not set." + }, 500) + + headers = { + "Authorization": f"Key {FAL_API_KEY}", + "Content-Type": "application/json", + } + + # مرحله ۱: ارسال درخواست تولید ویدیو + async with httpx.AsyncClient(timeout=60.0) as client: + try: + submit_url = f"{FAL_BASE_URL}/v3.5/text-to-video" + response = await client.post( + submit_url, + headers=headers, + json={ + "prompt": prompt, + "duration": "5", + "resolution":"540p", + "negative_prompt": negative_prompt + } + ) + + if response.status_code != 200: + return JSONResponse({ + "status": "error", + "message": "Failed to submit video generation request.", + "details": response.text + }, response.status_code) + + data = response.json() + request_id = data.get("request_id") + + if not request_id: + return JSONResponse({ + "status": "error", + "message": "No request_id returned from Fal API.", + "details": data + }, 500) + + print(f"✅ Fal request submitted. Request ID: {request_id}") + + except Exception as e: + return JSONResponse({ + "status": "error", + "message": "Failed to connect to Fal API.", + "details": str(e) + }, 500) + + # مرحله ۲: بررسی وضعیت تا زمان تکمیل + status_url = f"{FAL_BASE_URL}/requests/{request_id}/status" + for attempt in range(30): + await asyncio.sleep(3) + async with httpx.AsyncClient(timeout=60.0) as client: + status_response = await client.get(status_url, headers=headers) + status_data = status_response.json() + status = status_data.get("status") + + print(f"🔄 Fal Status Check {attempt+1}: {status}") + + if status == "COMPLETED": + break + elif status in ["FAILED", "ERROR"]: + return JSONResponse({ + "status": "error", + "message": "Video generation failed.", + "details": status_data + }, 500) + else: + return JSONResponse({ + "status": "error", + "message": "Timeout: Video not completed in expected time." + }, 504) + + # مرحله ۳: دریافت نتیجه نهایی + result_url = f"{FAL_BASE_URL}/requests/{request_id}" + async with httpx.AsyncClient(timeout=60.0) as client: + final_response = await client.get(result_url, headers=headers) + + if final_response.status_code != 200: + return JSONResponse({ + "status": "error", + "message": "Failed to retrieve final video result.", + "details": final_response.text + }, final_response.status_code) + + final_data = final_response.json() + + return JSONResponse({ + "status": "success", + "message": "Video generated successfully.", + "video_result": final_data + }) + +@app.post("/suno/callback") +async def suno_callback(payload: dict): + print("=== Suno Callback Received ===") + print("Payload:", json.dumps(payload, ensure_ascii=False, indent=2)) + + task_id = payload.get("data", {}).get("task_id") + callback_type = payload.get("data", {}).get("callbackType") + + if not task_id: + print("WARNING: task_id missing in callback") + return {"ok": True} + + suno_task = await SunoTask.get_or_none(task_id=task_id) + if not suno_task: + print(f"WARNING: Task {task_id} not found in DB") + return {"ok": True} + + suno_task.raw_callback = payload + + if callback_type in ("text", "first"): + suno_task.status = "PROCESSING" + print(f"Task {task_id} status updated to PROCESSING") + + elif callback_type == "complete": + items = payload.get("data", {}).get("data", []) + if items: + suno_task.audio_url = items[0].get("audio_url") + suno_task.image_url = items[0].get("image_url") + print(f"Task {task_id} audio_url: {suno_task.audio_url}") + print(f"Task {task_id} image_url: {suno_task.image_url}") + + suno_task.status = "COMPLETE" + print(f"Task {task_id} status updated to COMPLETE") + + await suno_task.save() + print(f"Task {task_id} saved to DB") + return {"ok": True} + + +@app.get("/api/suno/tasks/{task_id}") +async def get_suno_task(task_id: str): + suno_task = await SunoTask.get_or_none(task_id=task_id) + + if not suno_task: + print(f"Task {task_id} not found") + raise HTTPException(404, "Task not found") + + result = { + "task_id": suno_task.task_id, + "status": suno_task.status, + "audio_url": suno_task.audio_url, + "image_url": suno_task.image_url, + } + print(f"Returning task {task_id}:", json.dumps(result, ensure_ascii=False)) + return result \ No newline at end of file diff --git a/src/messages.py b/src/messages.py new file mode 100644 index 0000000..947abfc --- /dev/null +++ b/src/messages.py @@ -0,0 +1,46 @@ +MESSAGES = { + "user": { + "not_found": """شماره وارد شده یافت نشد. لطفاً با شماره‌ای که در باسا ثبت‌نام کرده‌اید وارد شوید. +اگر عضو زیرمجموعه خانواده هستید، لطفاً دقیقاً همان شماره‌ای را وارد کنید که پیامک دعوت برای آن ارسال شده است.""", + "wrong_pass": "رمز عبور اشتباه است", + "welcome": ["به هوشان خوش اومدی!", "50 سکه هوشان، بسته اعتبار هدیه به شما"], + "free": ["هدیه امروز", "سکه هوشان؛ هدیه امروز شما"], + "code": ["کد معرف", "20 سکه هوشان رایگان، بابت وارد کردن کد معرف"], + "invite": [ + "دعوت از دوستان", + "20 سکه هوشان رایگان، بابت استفاده دیگران از کد معرف شما", + ], + "wrong_input": "لطفا یک شماره موبایل یا ایمیل معتبر وارد نمایید", + "try_later": lambda index: f"لطفا بعد از {index} ثانیه دوباره تلاش کنید", + }, + "chat": { + "no_credit": "موجودی حساب کافی نیست", + "not_found": "چت مورد نظر یافت نشد", + "deleted": "چت با موفقیت حذف شد", + "limit": "شما به حد محدودیت پیام زمانی رسیده‌اید", + }, + "message": { + "not_found": "پیام مورد نظر یافت نشد", + }, + "bot": { + "not_found": "ربات مورد نظر یافت نشد", + }, + "assistant": { + "confirmed": "وضعیت دستیار به تایید شده تغییر یافت", + "rejected": "وضعیت دستیار به رد شده تغییر یافت", + }, + "code": { + "not_valid": "کد وارد شده معتبر نمی‌باشد", + "not_allow": "شما قبلا کد معرف ثبت کرده‌اید", + }, + "discount": { + "not_allow": "شما قبلا از این کد تخفیف استفاده کرده‌اید", + "not_found": "کد تخفیف وارد شده معتبر نمی‌باشد", + "expired": "کد تخفیف منقضی شده است", + }, + "settlement": { + "success": "درخواست تسویه حساب شما با موفقیت ثبت شد. نتیجه از طریق پیامک به شما اطلاع‌رسانی خواهد شد", + "failed": "درآمد شما برای تسویه حساب کافی نیست", + }, + "comment": {"not_allow": "لطفا قبل از ثبت نظر، اقدام به استفاده از بات نمایید"}, +} diff --git a/src/models/__init__.py b/src/models/__init__.py new file mode 100644 index 0000000..e895a0e --- /dev/null +++ b/src/models/__init__.py @@ -0,0 +1,22 @@ +from .app_metadata import AppMetadata +from .banner import Banner +from .billing import Billing, BillingStatus, BillingType +from .bot import Bot, BotStatus, BotType +from .category import Category +from .chatbot import Chatbot +from .code import Code +from .comment import Comment +from .discount import Discount +from .event import Event +from .feedback import Feedback, FeedbackStatus +from .mark import Mark +from .message import Message +from .notification import Notification +from .plan import Plan +from .rate import Rate +from .settlement import Settlement, SettlementType +from .suno_task import SunoTask +from .ticket import Ticket, TicketRole +from .user import User, UserRole +from .winner import Winner +from .advertisement import Advertisement \ No newline at end of file diff --git a/src/models/advertisement.py b/src/models/advertisement.py new file mode 100644 index 0000000..0236867 --- /dev/null +++ b/src/models/advertisement.py @@ -0,0 +1,15 @@ +from tortoise import fields +from tortoise.models import Model +from .timestamp import Timestamp + + +class Advertisement(Model, Timestamp): + id = fields.IntField(pk=True) + user = fields.ForeignKeyField( + "models.User", related_name="ads", on_delete=fields.CASCADE + ) + ad_type = fields.CharField(max_length=255) + timestamp = fields.DatetimeField(auto_now=True) + + class Meta: + table = "advertisements" diff --git a/src/models/app_metadata.py b/src/models/app_metadata.py new file mode 100644 index 0000000..0653a2d --- /dev/null +++ b/src/models/app_metadata.py @@ -0,0 +1,11 @@ +from tortoise import fields +from tortoise.models import Model +from .timestamp import Timestamp + + +class AppMetadata(Model, Timestamp): + tag = fields.CharField(max_length=24, pk=True) + value = fields.TextField(null=True) + + class Meta: + table = "app_metadata" diff --git a/src/models/banner.py b/src/models/banner.py new file mode 100644 index 0000000..fab5453 --- /dev/null +++ b/src/models/banner.py @@ -0,0 +1,16 @@ +from tortoise import fields +from tortoise.models import Model +from .timestamp import Timestamp + +class Banner(Model, Timestamp): + id = fields.IntField(pk=True) + image = fields.CharField(max_length=255) + link = fields.CharField(max_length=255, null=True) + title = fields.CharField(max_length=255, default="") + description = fields.CharField(max_length=255, default="") + bot = fields.ForeignKeyField( + "models.Bot", related_name="banners", on_delete=fields.SET_NULL, null=True + ) + + class Meta: + table = "banners" \ No newline at end of file diff --git a/src/models/billing.py b/src/models/billing.py new file mode 100644 index 0000000..5bcd45b --- /dev/null +++ b/src/models/billing.py @@ -0,0 +1,39 @@ +from tortoise import fields +from tortoise.models import Model +from .timestamp import Timestamp +from enum import Enum + + +class BillingStatus(str, Enum): + SUCCESS = "success" + FAILED = "failed" + PENDING = "pending" + START ="start" + + +class BillingType(str, Enum): + FREE = "free" + CODE = "code" + INVITE = "invite" + WELCOME = "welcome" + PURCHASE = "purchase" + INSTAGRAM = "instagram" + BASA = "basa" + + +class Billing(Model, Timestamp): + id = fields.IntField(pk=True) + amount = fields.IntField() + credit = fields.IntField() + discount = fields.IntField(default=0) + code = fields.CharField(max_length=8, null=True) + authority = fields.CharField(max_length=255, null=True, unique=True) + ref_id = fields.CharField(max_length=255, null=True, unique=True) + type = fields.CharEnumField(BillingType, default=BillingType.PURCHASE) + status = fields.CharEnumField(BillingStatus, default=BillingStatus.PENDING) + user = fields.ForeignKeyField( + "models.User", related_name="billings", on_delete=fields.SET_NULL, null=True + ) + + class Meta: + table = "billings" \ No newline at end of file diff --git a/src/models/bot.py b/src/models/bot.py new file mode 100644 index 0000000..7d0a46e --- /dev/null +++ b/src/models/bot.py @@ -0,0 +1,62 @@ +from tortoise import fields +from tortoise.models import Model +from .timestamp import Timestamp +from enum import Enum + + +class BotStatus(str, Enum): + CONFIRMED = "confirmed" + REJECTED = "rejected" + PENDING = "pending" + + +class BotType(str, Enum): + LLM = "llm" + CHARACTER = "character" + IMAGE = "image" + VIDEO = "video" + AUDIO = "audio" + + +class Bot(Model, Timestamp): + id = fields.IntField(pk=True) + name = fields.CharField(max_length=255, null=True) + model = fields.CharField(max_length=255) + style = fields.CharField(max_length=255, null=True) + description = fields.TextField(null=True) + image = fields.CharField(max_length=255, null=True) + image_2 = fields.CharField(max_length=255, null=True) + attachment = fields.IntField(default=0) + attachment_type = fields.JSONField(null=True) + tool = fields.BooleanField(default=False) + public = fields.BooleanField(default=True) + cost = fields.IntField(default=0) + limit = fields.IntField(default=-1) + order = fields.IntField(default=1) + prompt = fields.TextField(null=True) + links = fields.JSONField(null=True) + docs = fields.JSONField(null=True) + deleted = fields.BooleanField(default=False) + web_search = fields.BooleanField(default=False) + status = fields.CharEnumField(BotStatus, default=BotStatus.CONFIRMED) + type = fields.CharEnumField(BotType, default=BotType.LLM) + chatbots = fields.ReverseRelation["Chatbot"] + marks = fields.ReverseRelation["Mark"] + rates = fields.ReverseRelation["Rate"] + allowed_levels = fields.JSONField(default=[1]) + banners = fields.ReverseRelation["Banner"] + user = fields.ForeignKeyField( + "models.User", + related_name="bots", + on_delete=fields.SET_NULL, + null=True, + ) + category = fields.ForeignKeyField( + "models.Category", + related_name="bots", + on_delete=fields.SET_NULL, + null=True, + ) + + class Meta: + table = "bots" diff --git a/src/models/category.py b/src/models/category.py new file mode 100644 index 0000000..b9f4756 --- /dev/null +++ b/src/models/category.py @@ -0,0 +1,26 @@ +from tortoise import fields +from tortoise.models import Model + + +class Category(Model): + id = fields.IntField(pk=True) + name = fields.CharField(max_length=255) + description = fields.TextField(null=True) + tool = fields.BooleanField(default=False) + character = fields.BooleanField(default=False) + media = fields.BooleanField(default=False) + image = fields.CharField(max_length=255, null=True) + icon = fields.CharField(max_length=255, null=True) + bots = fields.ReverseRelation["Bot"] + comments = fields.ReverseRelation["Comment"] + user_bots = fields.ReverseRelation["UserBot"] + sub_categories = fields.ReverseRelation["Category"] + parent_category = fields.ForeignKeyField( + "models.Category", + related_name="sub_categories", + null=True, + on_delete=fields.SET_NULL, + ) + + class Meta: + table = "categories" \ No newline at end of file diff --git a/src/models/chatbot.py b/src/models/chatbot.py new file mode 100644 index 0000000..4823cab --- /dev/null +++ b/src/models/chatbot.py @@ -0,0 +1,20 @@ +from tortoise import fields +from tortoise.models import Model +from .timestamp import Timestamp + + +class Chatbot(Model, Timestamp): + id = fields.IntField(pk=True) + title = fields.CharField(max_length=255, null=True) + archive = fields.BooleanField(default=False) + messages = fields.ReverseRelation["Message"] + user = fields.ForeignKeyField( + "models.User", related_name="chatbots", on_delete=fields.SET_NULL, null=True + ) + bot = fields.ForeignKeyField( + "models.Bot", related_name="chatbots", on_delete=fields.SET_NULL, null=True + ) + is_deleted = fields.BooleanField(default=False) + is_ghost = fields.BooleanField(default=False) + class Meta: + table = "chatbots" \ No newline at end of file diff --git a/src/models/code.py b/src/models/code.py new file mode 100644 index 0000000..0e1c468 --- /dev/null +++ b/src/models/code.py @@ -0,0 +1,13 @@ +from tortoise import fields +from tortoise.models import Model +from .timestamp import Timestamp + + +class Code(Model, Timestamp): + id = fields.IntField(pk=True) + usage_count = fields.IntField(default=0) + code = fields.CharField(max_length=6, unique=True) + message = fields.CharField(max_length=255, null=True) + + class Meta: + table = "code" \ No newline at end of file diff --git a/src/models/comment.py b/src/models/comment.py new file mode 100644 index 0000000..cdd265a --- /dev/null +++ b/src/models/comment.py @@ -0,0 +1,32 @@ +from tortoise import fields +from tortoise.models import Model +from .timestamp import Timestamp + + +class Comment(Model, Timestamp): + id = fields.IntField(pk=True) + text = fields.TextField() + image = fields.CharField(max_length=255, null=True) + replies = fields.ReverseRelation["Comment"] + feedbacks = fields.ReverseRelation["Feedback"] + user = fields.ForeignKeyField( + "models.User", related_name="comments", on_delete=fields.CASCADE + ) + category = fields.ForeignKeyField( + "models.Category", related_name="comments", on_delete=fields.CASCADE + ) + parent = fields.ForeignKeyField( + "models.Comment", + related_name="replies", + null=True, + on_delete=fields.SET_NULL, + ) + replied_user = fields.ForeignKeyField( + "models.User", + related_name="replied_comments", + null=True, + on_delete=fields.SET_NULL, + ) + + class Meta: + table = "comments" diff --git a/src/models/discount.py b/src/models/discount.py new file mode 100644 index 0000000..8bb329d --- /dev/null +++ b/src/models/discount.py @@ -0,0 +1,15 @@ +from tortoise import fields +from tortoise.models import Model +from .timestamp import Timestamp + + +class Discount(Model, Timestamp): + id = fields.IntField(pk=True) + code = fields.CharField(max_length=8) + percent = fields.IntField(null=True) + value = fields.IntField(null=True) + max_value = fields.IntField(null=True) + expires_at = fields.DatetimeField(null=True) + + class Meta: + table = "discounts" \ No newline at end of file diff --git a/src/models/event.py b/src/models/event.py new file mode 100644 index 0000000..388c297 --- /dev/null +++ b/src/models/event.py @@ -0,0 +1,21 @@ +from tortoise import fields +from tortoise.models import Model +from .timestamp import Timestamp + + +class Event(Model, Timestamp): + id = fields.IntField(pk=True) + title = fields.CharField(max_length=255) + subtitle = fields.CharField(max_length=255, default="") + description = fields.TextField() + awards = fields.TextField() + image = fields.CharField(max_length=255, null=True) + start_at = fields.DatetimeField() + end_at = fields.DatetimeField() + is_open = fields.BooleanField(default=True) + total_participants = fields.IntField(default=0) + total_received_works = fields.IntField(default=0) + winners = fields.ReverseRelation["Winners"] + + class Meta: + table = "events" \ No newline at end of file diff --git a/src/models/feedback.py b/src/models/feedback.py new file mode 100644 index 0000000..857dcb7 --- /dev/null +++ b/src/models/feedback.py @@ -0,0 +1,24 @@ +from tortoise import fields +from tortoise.models import Model +from .timestamp import Timestamp +from enum import Enum + + +class FeedbackStatus(str, Enum): + LIKED = "liked" + DISLIKED = "disliked" + + +class Feedback(Model, Timestamp): + id = fields.IntField(pk=True) + status = fields.CharEnumField(FeedbackStatus) + user = fields.ForeignKeyField( + "models.User", related_name="feedbacks", on_delete=fields.CASCADE + ) + comment = fields.ForeignKeyField( + "models.Comment", related_name="feedbacks", on_delete=fields.CASCADE + ) + + class Meta: + table = "feedbacks" + unique_together = ("user_id", "comment_id") \ No newline at end of file diff --git a/src/models/mark.py b/src/models/mark.py new file mode 100644 index 0000000..50890ff --- /dev/null +++ b/src/models/mark.py @@ -0,0 +1,17 @@ +from tortoise import fields +from tortoise.models import Model +from .timestamp import Timestamp + + +class Mark(Model, Timestamp): + id = fields.IntField(pk=True) + user = fields.ForeignKeyField( + "models.User", related_name="marks", on_delete=fields.CASCADE + ) + bot = fields.ForeignKeyField( + "models.Bot", related_name="marks", on_delete=fields.CASCADE + ) + + class Meta: + table = "marks" + unique_together = ("user_id", "bot_id") \ No newline at end of file diff --git a/src/models/message.py b/src/models/message.py new file mode 100644 index 0000000..b51419a --- /dev/null +++ b/src/models/message.py @@ -0,0 +1,20 @@ +from tortoise import fields +from tortoise.models import Model +from .timestamp import Timestamp + + +class Message(Model, Timestamp): + id = fields.IntField(pk=True) + input_tokens = fields.IntField() + output_tokens = fields.IntField() + cost = fields.IntField(default=-1) + chatbot = fields.ForeignKeyField( + "models.Chatbot", related_name="messages", on_delete=fields.SET_NULL, null=True + ) + cost_from_gift = fields.IntField(default=0) + cost_from_credit = fields.IntField(default=0) + cost_from_free = fields.IntField(default=0) + is_deleted = fields.BooleanField(default=False) + + class Meta: + table = "messages" \ No newline at end of file diff --git a/src/models/notification.py b/src/models/notification.py new file mode 100644 index 0000000..bfc9497 --- /dev/null +++ b/src/models/notification.py @@ -0,0 +1,16 @@ +from tortoise import fields +from tortoise.models import Model +from .timestamp import Timestamp + + +class Notification(Model, Timestamp): + id = fields.IntField(pk=True) + title = fields.CharField(max_length=255) + message = fields.TextField() + seen = fields.BooleanField(default=False) + user = fields.ForeignKeyField( + "models.User", related_name="notifications", on_delete=fields.CASCADE + ) + + class Meta: + table = "notifications" \ No newline at end of file diff --git a/src/models/plan.py b/src/models/plan.py new file mode 100644 index 0000000..1c02b8d --- /dev/null +++ b/src/models/plan.py @@ -0,0 +1,17 @@ +from tortoise import fields +from tortoise.models import Model +from .timestamp import Timestamp + + +class Plan(Model, Timestamp): + id = fields.CharField(pk=True, max_length=20) + price = fields.IntField() + coins = fields.IntField() + free_coins = fields.IntField() + title = fields.CharField(max_length=255) + description = fields.CharField(max_length=255, null=True) + active = fields.BooleanField(default=False) + image = fields.CharField(max_length=255, null=True) + + class Meta: + table = "plans" \ No newline at end of file diff --git a/src/models/rate.py b/src/models/rate.py new file mode 100644 index 0000000..1d3f2af --- /dev/null +++ b/src/models/rate.py @@ -0,0 +1,18 @@ +from tortoise import fields +from tortoise.models import Model +from .timestamp import Timestamp + + +class Rate(Model, Timestamp): + id = fields.IntField(pk=True) + text = fields.TextField() + score = fields.FloatField() + user = fields.ForeignKeyField( + "models.User", related_name="rates", on_delete=fields.CASCADE + ) + bot = fields.ForeignKeyField( + "models.Bot", related_name="rates", on_delete=fields.CASCADE + ) + + class Meta: + table = "rates" \ No newline at end of file diff --git a/src/models/settlement.py b/src/models/settlement.py new file mode 100644 index 0000000..dcb3563 --- /dev/null +++ b/src/models/settlement.py @@ -0,0 +1,22 @@ +from tortoise import fields +from tortoise.models import Model +from .timestamp import Timestamp +from enum import Enum + + +class SettlementType(str, Enum): + COIN = "coin" + MONEY = "money" + + +class Settlement(Model, Timestamp): + id = fields.IntField(pk=True) + income = fields.IntField() + confirmed = fields.BooleanField(default=False) + type = fields.CharEnumField(SettlementType) + user = fields.ForeignKeyField( + "models.User", related_name="settlements", on_delete=fields.CASCADE + ) + + class Meta: + table = "settlements" \ No newline at end of file diff --git a/src/models/suno_task.py b/src/models/suno_task.py new file mode 100644 index 0000000..a3396ee --- /dev/null +++ b/src/models/suno_task.py @@ -0,0 +1,19 @@ +from tortoise import fields +from tortoise.models import Model +from .timestamp import Timestamp + +class SunoTask(Model, Timestamp): + id = fields.UUIDField(pk=True) + task_id = fields.CharField(max_length=255, unique=True) + chatbot = fields.ForeignKeyField("models.Chatbot", related_name="suno_tasks") + user = fields.ForeignKeyField("models.User", related_name="suno_tasks") + status = fields.CharField( + max_length=50, default="PENDING" + ) # PENDING, SUCCESS, FAILED + audio_url = fields.CharField(max_length=500, null=True) + image_url = fields.CharField(max_length=500, null=True) + audio_url2 = fields.CharField(max_length=500, null=True) + image_url2 = fields.CharField(max_length=500, null=True) + + class Meta: + table = "suno_tasks" \ No newline at end of file diff --git a/src/models/ticket.py b/src/models/ticket.py new file mode 100644 index 0000000..9edd16e --- /dev/null +++ b/src/models/ticket.py @@ -0,0 +1,22 @@ +from tortoise import fields +from tortoise.models import Model +from .timestamp import Timestamp +from enum import Enum + + +class TicketRole(str, Enum): + USER = "user" + ADMIN = "admin" + + +class Ticket(Model, Timestamp): + id = fields.IntField(pk=True) + text = fields.TextField(null=True) + role = fields.CharEnumField(TicketRole, default=TicketRole.USER) + file = fields.CharField(max_length=255, null=True) + user = fields.ForeignKeyField( + "models.User", related_name="tickets", on_delete=fields.CASCADE + ) + + class Meta: + table = "tickets" \ No newline at end of file diff --git a/src/models/timestamp.py b/src/models/timestamp.py new file mode 100644 index 0000000..e61d413 --- /dev/null +++ b/src/models/timestamp.py @@ -0,0 +1,5 @@ +from tortoise import fields + +class Timestamp: + created_at = fields.DatetimeField(auto_now_add=True) + updated_at = fields.DatetimeField(auto_now=True) diff --git a/src/models/user.py b/src/models/user.py new file mode 100644 index 0000000..cf4fa34 --- /dev/null +++ b/src/models/user.py @@ -0,0 +1,51 @@ +from tortoise import fields +from tortoise.models import Model +from .timestamp import Timestamp +from enum import Enum + +class UserRole(str, Enum): + USER = "user" + ADMIN = "admin" + + +class User(Model, Timestamp): + id = fields.UUIDField(pk=True) + mobile_number = fields.CharField(max_length=11, unique=True, index=True, null=True) + password = fields.CharField(max_length=255, null=True) + name = fields.CharField(max_length=255, null=True) + image = fields.CharField(max_length=255, null=True) + email = fields.CharField(max_length=255, unique=True, index=True, null=True) + username = fields.CharField(max_length=255, unique=True, index=True, null=True) + verified = fields.BooleanField(default=False) + otp = fields.CharField(max_length=6, null=True) + otp_expires_at = fields.DatetimeField(null=True) + code = fields.CharField(max_length=6, null=True) + card_number = fields.CharField(max_length=16, null=True) + income = fields.FloatField(default=0) + credit = fields.IntField(default=0) + free_credit = fields.IntField(default=0) + gift_credit = fields.IntField(default=0) + role = fields.CharEnumField(UserRole, default=UserRole.USER) + firebase_token = fields.CharField(max_length=255, null=True) + telegram_id = fields.CharField(max_length=20, null=True) + chatbots = fields.ReverseRelation["Chatbot"] + tickets = fields.ReverseRelation["Ticket"] + billings = fields.ReverseRelation["Billing"] + comments = fields.ReverseRelation["Comment"] + replied_comments = fields.ReverseRelation["Comment"] + feedbacks = fields.ReverseRelation["Feedback"] + marks = fields.ReverseRelation["Mark"] + bots = fields.ReverseRelation["Bot"] + rates = fields.ReverseRelation["Rate"] + notifications = fields.ReverseRelation["Notification"] + settlements = fields.ReverseRelation["Settlement"] + level = fields.IntField(default=1) + parent = fields.ForeignKeyField( + "models.User", + related_name="sub_users", + on_delete=fields.SET_NULL, + null=True + ) + + class Meta: + table = "users" diff --git a/src/models/winner.py b/src/models/winner.py new file mode 100644 index 0000000..3df31bc --- /dev/null +++ b/src/models/winner.py @@ -0,0 +1,17 @@ +from tortoise import fields +from tortoise.models import Model +from .timestamp import Timestamp + + +class Winner(Model, Timestamp): + id = fields.IntField(pk=True) + rank = fields.IntField() + user = fields.ForeignKeyField( + "models.User", related_name="events", on_delete=fields.CASCADE + ) + event = fields.ForeignKeyField( + "models.Event", related_name="winners", on_delete=fields.CASCADE + ) + + class Meta: + table = "winners" diff --git a/src/routes/__init__.py b/src/routes/__init__.py new file mode 100644 index 0000000..1b6c9d4 --- /dev/null +++ b/src/routes/__init__.py @@ -0,0 +1,43 @@ +from .chatbot import router as chatbot_router +from .bot import router as bot_router, admin_router as bot_admin_router +from .category import router as category_router, router_v2 as category_router_v2 +from .tool import router as tool_router +from .ticket import router as ticket_router, admin_router as ticket_admin_router +from .forum import router as forum_router +from .payment import router as payment_router, router_v2 as payment_router_v2 +from .discount import router as discount_router +from .banner import router as banner_router +from .event import router as event_router +from .app_metadata import router as app_metadata_router +from .user import ( + router as user_router, + admin_router as user_admin_router, + router_v2 as user_router_v2, +) +from .advertisement import router as advertisement_router + + +def register_routers(app): + app.include_router(user_router) + app.include_router(chatbot_router) + app.include_router(bot_router) + app.include_router(category_router) + app.include_router(tool_router) + app.include_router(ticket_router) + app.include_router(forum_router) + app.include_router(payment_router) + app.include_router(discount_router) + app.include_router(banner_router) + app.include_router(event_router) + app.include_router(app_metadata_router) + app.include_router(advertisement_router) + + ## Admin ## + app.include_router(bot_admin_router) + app.include_router(user_admin_router) + app.include_router(ticket_admin_router) + + ## V2 ## + app.include_router(payment_router_v2) + app.include_router(user_router_v2) + app.include_router(category_router_v2) diff --git a/src/routes/advertisement.py b/src/routes/advertisement.py new file mode 100644 index 0000000..ed2b45a --- /dev/null +++ b/src/routes/advertisement.py @@ -0,0 +1,34 @@ +from fastapi import APIRouter, Depends, Body +from typing import Annotated +from fastapi.security import HTTPAuthorizationCredentials +from ..dependencies import auth_scheme +from ..services import advertisement as ad_service +from ..services import auth as auth_service +from ..schemas.advertisement import Advertisement as AdvertisementSchema + + +router = APIRouter( + prefix="/advertisement", + tags=["advertisement"], + responses={404: {"detail": "Not found"}}, +) + +@router.get("/remaining") +async def is_ready( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], +): + user_id = await auth_service.verify_token(token.credentials) + seconds_remaining = await ad_service.remaining(user_id) + return { + "remaining_seconds": seconds_remaining + } + +@router.post("/claim") +async def claim( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + body: AdvertisementSchema = Body(...) +): + user_id = await auth_service.verify_token(token.credentials) + response = await ad_service.mark_seen(user_id, body.ad_type) + + return response \ No newline at end of file diff --git a/src/routes/app_metadata.py b/src/routes/app_metadata.py new file mode 100644 index 0000000..eaf913b --- /dev/null +++ b/src/routes/app_metadata.py @@ -0,0 +1,39 @@ +from typing import Annotated +from fastapi import APIRouter, Depends +from fastapi.security import HTTPAuthorizationCredentials +from ..dependencies import auth_scheme +from ..services import ( + app_metadata as app_metadata_service, + auth as auth_service, +) + + +router = APIRouter( + prefix="/app-metadata", + tags=["app-metadata"], + responses={404: {"detail": "Not found"}}, +) + +@router.get("") +async def findall_metadata(): + result = await app_metadata_service.findall() + return result + +@router.post("") +async def upsert_metadata( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + tag: str, + value: str | None = None, +): + await auth_service.verify_admin_token(token.credentials) + result = await app_metadata_service.upsert(tag, value) + return result + +@router.delete("") +async def delete_metadata( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + tag: str, +): + await auth_service.verify_admin_token(token.credentials) + result = await app_metadata_service.delete(tag) + return result \ No newline at end of file diff --git a/src/routes/banner.py b/src/routes/banner.py new file mode 100644 index 0000000..24ad563 --- /dev/null +++ b/src/routes/banner.py @@ -0,0 +1,30 @@ +from fastapi import APIRouter +from fastapi.responses import StreamingResponse +from ..schemas.banner import Banners +from ..services import banner as banner_service, storage as storage_service + +router = APIRouter( + prefix="/banner", + tags=["banner"], + responses={404: {"detail": "Not found"}}, +) + + +@router.get("/", response_model=Banners) +async def get_all(): + banners = await banner_service.get_all() + return {"banners": banners} + + +@router.get("/{key}") +async def stream_file( + key: str, +): + file_size = await storage_service.get_file_size(f"banner/{key}") + headers = {"Content-Length": str(file_size)} + + return StreamingResponse( + storage_service.get(f"banner/{key}"), + media_type="application/octet-stream", + headers=headers, + ) diff --git a/src/routes/bot.py b/src/routes/bot.py new file mode 100644 index 0000000..1dbc1a2 --- /dev/null +++ b/src/routes/bot.py @@ -0,0 +1,376 @@ +import tempfile +from typing import Annotated +from fastapi.responses import StreamingResponse +from fastapi import APIRouter, Depends, Form, File, UploadFile +from fastapi.security import HTTPAuthorizationCredentials +from ..schemas.main import BaseResponse +from ..dependencies import auth_scheme +from ..messages import MESSAGES +from ..services import ( + bot as bot_service, + auth as auth_service, + storage as storage_service, +) +from ..schemas.bot import ( + Bots, + Mark, + Categories, + MyBots, + Comments, + BaseComment, + OneBot, + MyBot, + BotStatus, + Assistants, + OneAssistant, + Confirm, + Reject, + Image, + BotLevelUpdate +) + +router = APIRouter( + prefix="/bot", + tags=["bot"], + responses={404: {"detail": "Not found"}}, +) + + +@router.post("/", response_model=BaseResponse) +async def create_bot( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + category_id: Annotated[int, Form()], + name: Annotated[str, Form()], + description: Annotated[str, Form()], + prompt: Annotated[str, Form()], + public: Annotated[bool, Form()], + bot_id: Annotated[int, Form()] = 1, + links: Annotated[list[str], Form()] = [], + docs: Annotated[list[UploadFile], File()] = [], +): + user_id = await auth_service.verify_token(token.credentials) + + files = [] + for doc in docs: + mime_type = doc.content_type.split("/")[0] + extension = doc.filename.split(".")[-1] + + fil_path = None + with tempfile.NamedTemporaryFile( + delete=False, suffix=f".{extension}" + ) as tmp_file: + tmp_file.write(await doc.read()) + fil_path = tmp_file.name + + file_url = None + with open(fil_path, "rb") as f: + file_url = await storage_service.upload_buffer( + user_id=user_id, file=f, mime_type=mime_type, extension=extension + ) + + files.append([file_url, fil_path]) + + await bot_service.create_bot( + bot_id=bot_id, + user_id=user_id, + category_id=category_id, + name=name, + description=description, + prompt=prompt, + public=public, + links=links, + docs=files, + ) + + return BaseResponse(detail="Bot has been successfully created") + + +@router.get("/", response_model=Bots) +async def get_primary_bots( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + query: str | None = None, +): + await auth_service.verify_token(token.credentials) + bots = await bot_service.get_primary_bots(query=query) + + return {"bots": [{**bot.__dict__, "category": bot.category} for bot in bots]} + + +@router.get("/personal", response_model=MyBots) +async def get_my_bots( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], +): + user_id = await auth_service.verify_token(token.credentials) + bots = await bot_service.get_my_bots(user_id=user_id) + + return {"bots": bots} + + +@router.get("/assistant", response_model=Categories) +async def get_other_bots( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + category: int | None = None, + marked: bool | None = None, +): + user_id = await auth_service.verify_token(token.credentials) + result = await bot_service.get_bots_by_message_count( + user_id=user_id, category=category, marked=marked + ) + + return {"categories": result} + + +@router.get("/personal/{id}", response_model=MyBot) +async def get_my_bot( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], id: int +): + user_id = await auth_service.verify_token(token.credentials) + bot = await bot_service.get_my_bot(id=id, user_id=user_id) + + return {"bot": bot} + + +@router.put("/personal/{id}", response_model=BaseResponse) +async def edit_bot( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + id: int, + category_id: Annotated[int, Form()], + name: Annotated[str, Form()], + description: Annotated[str, Form()], + prompt: Annotated[str, Form()], + public: Annotated[bool, Form()], + bot_id: Annotated[int, Form()] = 1, + links: Annotated[list[str], Form()] = [], + docs: Annotated[list[UploadFile], File()] = [], +): + user_id = await auth_service.verify_token(token.credentials) + + files = [] + for doc in docs: + mime_type = doc.content_type.split("/")[0] + extension = doc.filename.split(".")[-1] + + fil_path = None + with tempfile.NamedTemporaryFile( + delete=False, suffix=f".{extension}" + ) as tmp_file: + tmp_file.write(await doc.read()) + fil_path = tmp_file.name + + file_url = None + with open(fil_path, "rb") as f: + file_url = await storage_service.upload_buffer( + user_id=user_id, file=f, mime_type=mime_type, extension=extension + ) + + files.append([file_url, fil_path]) + + await bot_service.edit_bot( + id=id, + bot_id=bot_id, + user_id=user_id, + category_id=category_id, + name=name, + description=description, + prompt=prompt, + public=public, + links=links, + docs=files, + ) + + return BaseResponse(detail="Bot has been successfully edited") + + +@router.delete("/personal/{id}", response_model=BaseResponse) +async def delete_bot( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + id: int, +): + user_id = await auth_service.verify_token(token.credentials) + + await bot_service.delete_bot(id=id, user_id=user_id) + + return BaseResponse(detail="Bot has been successfully deleted") + + +@router.get("/{id}", response_model=OneBot) +async def get_bot( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + id: int, +): + user_id = await auth_service.verify_token(token.credentials) + bot = await bot_service.get_bot(id=id, user_id=user_id) + + return {"bot": bot} + + +@router.get("/{id}/comment", response_model=Comments) +async def get_comments( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + id: int, + page: int = 1, +): + await auth_service.verify_token(token.credentials) + result = await bot_service.get_comments(id=id, page=page) + + return result + + +@router.put("/{id}/comment", response_model=BaseResponse) +async def send_comment( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + id: int, + comment: BaseComment, +): + user_id = await auth_service.verify_token(token.credentials) + await bot_service.send_comment( + bot_id=id, user_id=user_id, score=comment.score, text=comment.text + ) + + return BaseResponse(detail="comment has been successfully registered") + + +@router.put("/{id}/mark", response_model=BaseResponse) +async def mark( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + id: int, + bot: Mark, +): + user_id = await auth_service.verify_token(token.credentials) + await bot_service.mark(bot_id=id, user_id=user_id, marked=bot.marked) + + return BaseResponse( + detail=f"Bot has been successfully {'marked' if bot.marked else 'unmarked'}" + ) + + +@router.get("/{id}/{key}") +async def stream_file(id: str, key: str): + file_size = await storage_service.get_file_size(f"bot/{id}/{key}") + headers = {"Content-Length": str(file_size)} + + return StreamingResponse( + storage_service.get(f"bot/{id}/{key}"), + media_type="application/octet-stream", + headers=headers, + ) + + +##### Admin Routes ##### + +admin_router = APIRouter( + prefix="/admin/bot", + tags=["admin"], + responses={404: {"detail": "Not found"}}, +) + + +@admin_router.get("/assistant", response_model=Assistants) +async def get_assistants( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + category: int | None = None, + q: str | None = None, + status: BotStatus = BotStatus.CONFIRMED, + page: int = 1, +): + await auth_service.verify_admin_token(token.credentials) + result = await bot_service.get_assistants( + category=category, status=status, page=page, q=q + ) + + return result + + +@admin_router.get("/assistant/{id}", response_model=OneAssistant) +async def get_assistant( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + id: int, +): + await auth_service.verify_admin_token(token.credentials) + assistant = await bot_service.get_assistant(id) + + return {"assistant": assistant} + + +# @admin_router.put("/assistant/{id}/confirm", response_model=BaseResponse) +# async def confirm_assistant( +# token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], +# id: int, +# assistant: Confirm, +# ): +# await auth_service.verify_admin_token(token.credentials) +# await bot_service.confirm_assistant( +# id=id, cost=assistant.cost, message=assistant.message +# ) + +# return BaseResponse(detail=MESSAGES["assistant"]["confirmed"]) + +@admin_router.put("/assistant/{id}/confirm", response_model=BaseResponse) +async def confirm_assistant( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + id: int, + assistant: Confirm, +): + await auth_service.verify_admin_token(token.credentials) + await bot_service.confirm_assistant( + id=id, + cost=assistant.cost, + message=assistant.message, + allowed_levels=assistant.allowed_levels # <--- ارسال به سرویس + ) + + return BaseResponse(detail=MESSAGES["assistant"]["confirmed"]) + + +@admin_router.put("/assistant/{id}/reject", response_model=BaseResponse) +async def reject_assistant( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + id: int, + assistant: Reject, +): + await auth_service.verify_admin_token(token.credentials) + await bot_service.reject_assistant(id=id, message=assistant.message) + + return BaseResponse(detail=MESSAGES["assistant"]["rejected"]) + + +@admin_router.put("/assistant/{id}/image", response_model=Image) +async def edit_profile( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + file: UploadFile, + id: int, +): + await auth_service.verify_admin_token(token.credentials) + url = await bot_service.edit_image(id=id, file=file) + + return {"url": url} + +@admin_router.put("/personal/{id}/levels", response_model=BaseResponse, summary="Update Bot Allowed Levels") +async def update_bot_levels( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + id: int, + data: BotLevelUpdate +): + + user_id = await auth_service.verify_token(token.credentials) + + await bot_service.update_allowed_levels( + bot_id=id, + user_id=user_id, + allowed_levels=data.allowed_levels + ) + + return BaseResponse(detail="Bot levels have been successfully updated") + +@admin_router.delete("/assistant/{id}", response_model=BaseResponse) +async def delete_bot( + id: int, + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], +): + # بررسی دسترسی ادمین + await auth_service.verify_admin_token(token.credentials) + + # فراخوانی سرویس + await bot_service.delete_bot(id) + + return BaseResponse(detail="بات با موفقیت حذف شد") \ No newline at end of file diff --git a/src/routes/category.py b/src/routes/category.py new file mode 100644 index 0000000..9b4ad12 --- /dev/null +++ b/src/routes/category.py @@ -0,0 +1,81 @@ +from fastapi import APIRouter +from ..schemas.category import Categories, ToolCategories +from ..services import category as category_service + +router = APIRouter( + prefix="/category", + tags=["category"], + responses={404: {"detail": "Not found"}}, +) + + +@router.get("/", response_model=Categories) +async def get_categories(): + categories = await category_service.get_categories() + + return {"categories": categories} + + +@router.get("/tool", response_model=ToolCategories) +async def get_tool_categories(): + categories = await category_service.get_tool_categories() + + return {"categories": categories} + + +@router.get("/character", response_model=ToolCategories) +async def get_character_categories(): + categories = await category_service.get_character_categories() + + return {"categories": categories} + + +@router.get("/media", response_model=Categories) +async def get_media_categories(): + categories = await category_service.get_media_categories() + + return {"categories": categories} + + +@router.get("/media/{id}", response_model=ToolCategories) +async def get_media_category(id: int): + categories = await category_service.get_media_category(id) + + return {"categories": categories} + + +@router.get("/effect") +async def get_video_effects(): + return { + "effects": [ + { + "name": "Hug Your Love", + "gif": "https://media.pixverse.ai/asset%2Ftemplate%2Fweb_hugyourlove.gif", + }, + { + "name": "Hot Harley Quinn", + "gif": "https://media.pixverse.ai/asset%2Ftemplate%2Fweb_harleyquinn.gif", + }, + { + "name": "Muscle Surge", + "gif": "https://media.pixverse.ai/asset%2Ftemplate%2Fweb_musclesurge.gif", + }, + ], + } + + +##### V2 Routes ##### + + +router_v2 = APIRouter( + prefix="/v2/category", + tags=["category"], + responses={404: {"detail": "Not found"}}, +) + + +@router_v2.get("/tool", response_model=ToolCategories) +async def get_tool_categories_v2(): + categories = await category_service.get_tool_categories_v2() + + return {"categories": categories} diff --git a/src/routes/chatbot.py b/src/routes/chatbot.py new file mode 100644 index 0000000..1ee4633 --- /dev/null +++ b/src/routes/chatbot.py @@ -0,0 +1,371 @@ +import tempfile +from fastapi import APIRouter, Depends, UploadFile, Form, File +from fastapi.responses import StreamingResponse +from fastapi.security import HTTPAuthorizationCredentials +from typing import Annotated +from datetime import date +from langchain_core.messages import HumanMessage, AIMessage +from ..config import settings +from ..models import BotType , SunoTask +from ..dependencies import auth_scheme +from ..schemas.main import BaseResponse +from ..services import ( + chatbot as chatbot_service, + auth as auth_service, + storage as storage_service, +) +import json +from ..schemas.chatbot import ( + ChatHistory, + Like, + Chats, + Title, + BaseMessage, + RelatedQuestions, + Archive, + MessageStream, +) + +router = APIRouter( + prefix="/chatbot", + tags=["chatbot"], + responses={404: {"detail": "Not found"}}, +) + + +@router.post("/", response_model=MessageStream) +async def send_message( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + bot_id: Annotated[int, Form()], + query: Annotated[str | None, Form()] = None, + id: Annotated[int | None, Form()] = None, + retry: Annotated[bool, Form()] = False, + ghost: Annotated[bool, Form()] = False, + image: Annotated[UploadFile | None, File()] = None, + audio: Annotated[UploadFile | None, File()] = None, + doc: Annotated[UploadFile | None, File()] = None, +): + user_id = await auth_service.verify_token(token.credentials) + chat_user_id = settings.ghost_id if ghost else user_id + + image_url = None + if image: + image_url = await storage_service.upload(id=chat_user_id, file=image) + + audio_url = None + audio_path = None + if audio: + mime_type = audio.content_type.split("/")[0] + extension = audio.filename.split(".")[-1] + + with tempfile.NamedTemporaryFile( + delete=False, suffix=f".{extension}" + ) as tmp_file: + tmp_file.write(await audio.read()) + audio_path = tmp_file.name + + with open(audio_path, "rb") as f: + audio_url = await storage_service.upload_buffer( + user_id=chat_user_id, + file=f, + mime_type=mime_type, + extension=extension, + ) + + doc_url = None + doc_path = None + if doc: + mime_type = doc.content_type.split("/")[0] + extension = doc.filename.split(".")[-1] + + with tempfile.NamedTemporaryFile( + delete=False, suffix=f".{extension}" + ) as tmp_file: + tmp_file.write(await doc.read()) + doc_path = tmp_file.name + + with open(doc_path, "rb") as f: + doc_url = await storage_service.upload_buffer( + user_id=chat_user_id, file=f, mime_type=mime_type, extension=extension + ) + + return StreamingResponse( + chatbot_service.send_message( + id=id, + query=query, + bot_id=bot_id, + user_id=user_id, + retry=retry, + ghost=ghost, + image=(settings.base_url + image_url) if image_url else None, + audio=[ + (settings.base_url + audio_url) if audio_url else None, + audio_path, + ], + doc=[(settings.base_url + doc_url) if doc_url else None, doc_path], + ), + media_type="application/json", + ) + + +@router.get("/", response_model=Chats) +async def chats_history( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + archive: bool = False, + query: str | None = None, + date: date | None = None, + type: BotType | None = None, + page: int = 1, +): + user_id = await auth_service.verify_token(token.credentials) + + response = await chatbot_service.get_chats( + user_id=user_id, query=query, archive=archive, date=date, page=page, type=type + ) + + return response + + +@router.delete("/", response_model=BaseResponse) +async def delete_chats( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + chat: Archive, +): + user_id = await auth_service.verify_token(token.credentials) + + await chatbot_service.delete_chats(user_id=user_id, archive=chat.archive) + + return BaseResponse(detail="Chats deleted successfully") + + +# @router.get("/{id}", response_model=ChatHistory) +# async def chat_history( +# token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], +# id: int, +# ): +# user_id = await auth_service.verify_token(token.credentials) + +# history, chatbot = await chatbot_service.get_chat(id=id, user_id=user_id) +# messages = history.checkpoint["channel_values"]["messages"] + +# return { +# "id": id, +# "title": chatbot.title, +# "created_at": chatbot.created_at, +# "bot": {**chatbot.bot.__dict__, "category": chatbot.bot.category}, +# "messages": [ +# { +# "id": message.id, +# "content": message.content +# if isinstance(message, HumanMessage) +# else [ +# { +# "type": "text", +# "text": message.content +# if not message.content.startswith("https") +# else "", +# "audio_url": {"url": message.content, "attachment": True} +# if ".mp3" in message.content or "erweima.ai" in message.content +# else None, +# "image_url": {"url": message.content, "attachment": True} +# if ".png" in message.content +# or ".jpg" in message.content +# or ".webp" in message.content +# or ".svg" in message.content +# else None, +# "video_url": {"url": message.content, "attachment": True} +# if ".mp4" in message.content +# else None, +# } +# ], +# "like": message.additional_kwargs.get("like", None), +# "role": "human" if isinstance(message, HumanMessage) else "ai", +# "created_at": message.additional_kwargs.get("created_at", None), +# } +# for message in messages +# if isinstance(message, HumanMessage) +# or (isinstance(message, AIMessage) and not message.tool_calls) +# ], +# } + +@router.get("/{id}", response_model=ChatHistory) +async def chat_history( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + id: int, +): + print(f"\n========== DEBUG: chat_history START (ID: {id}) ==========") + + # 1. احراز هویت و دریافت اطلاعات چت + user_id = await auth_service.verify_token(token.credentials) + history, chatbot = await chatbot_service.get_chat(id=id, user_id=user_id) + + # دسترسی به پیام‌های خام ذخیره شده در گراف + raw_messages = history.checkpoint["channel_values"]["messages"] + print(f"[DEBUG] Raw messages count: {len(raw_messages)}") + + formatted_messages = [] + + for idx, message in enumerate(raw_messages): + # 2. فیلتر کردن پیام‌های سیستمی و Tool Callها + if not (isinstance(message, HumanMessage) or (isinstance(message, AIMessage) and not message.tool_calls)): + # print(f"[DEBUG] Msg #{idx} SKIPPED (Type: {type(message).__name__})") + continue + + print(f"[DEBUG] Processing Msg #{idx} | ID: {message.id}") + + # 3. ساخت دیکشنری پایه پیام + msg_dict = { + "id": message.id, + "like": message.additional_kwargs.get("like", None), + "role": "human" if isinstance(message, HumanMessage) else "ai", + "created_at": message.additional_kwargs.get("created_at", None), + } + + # 4. منطق تولید محتوا (Content Logic) + if isinstance(message, HumanMessage): + # پیام کاربر فقط متن است + msg_dict["content"] = message.content + else: + # === منطق دریافت لینک موزیک/تصویر برای پیام AI === + suno_audio_url = None + suno_image_url = None + + # الف) تلاش برای خواندن زنده از جدول SunoTask (اولویت اول) + task_id = message.additional_kwargs.get("task_id") + if task_id: + try: + suno_task = await SunoTask.get_or_none(task_id=task_id) + if suno_task: + suno_audio_url = suno_task.audio_url + suno_image_url = suno_task.image_url + print(f" [DB-Lookup] Found Task {task_id} -> Audio: {suno_audio_url}") + except Exception as e: + print(f" [DB-Lookup] Error: {str(e)}") + + # ب) تعیین لینک نهایی با اولویت‌بندی: 1. دیتابیس 2. متادیتا 3. متن پیام + final_audio_url = ( + suno_audio_url or + message.additional_kwargs.get("audio_url") or + (message.content if ".mp3" in message.content or "erweima.ai" in message.content else None) + ) + + final_image_url = ( + suno_image_url or + message.additional_kwargs.get("image_url") or + (message.content if any(ext in message.content for ext in [".png", ".jpg", ".webp"]) else None) + ) + + # ج) بررسی اینکه آیا خود متن پیام فقط یک لینک است؟ (برای تمیز کردن نمایش متن) + is_link_text = message.content.startswith("https") + + # د) ساختار نهایی کانتنت برای فرانت‌اند + msg_dict["content"] = [ + { + "type": "text", + # اگر متن خودِ لینک است، آن را نمایش نده (چون در پلیر نمایش داده می‌شود) + "text": message.content if not is_link_text else "", + + "audio_url": {"url": final_audio_url, "attachment": True} + if final_audio_url else None, + + "image_url": {"url": final_image_url, "attachment": True} + if final_image_url else None, + + "video_url": {"url": message.content, "attachment": True} + if ".mp4" in message.content else None, + } + ] + + formatted_messages.append(msg_dict) + + # 5. ساخت پاسخ نهایی + response_data = { + "id": id, + "title": chatbot.title, + "created_at": chatbot.created_at, + "bot": {**chatbot.bot.__dict__, "category": chatbot.bot.category}, + "messages": formatted_messages, + } + + print("========== DEBUG: chat_history END ==========\n") + return response_data + + +@router.delete("/{id}", response_model=BaseResponse) +async def delete_chat( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + id: int, +): + user_id = await auth_service.verify_token(token.credentials) + + await chatbot_service.delete_chat(id=id, user_id=user_id) + + return BaseResponse(detail="Chat deleted successfully") + + +@router.put("/{id}/title", response_model=BaseResponse) +async def edit_title( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + id: int, + chat: Title, +): + user_id = await auth_service.verify_token(token.credentials) + + await chatbot_service.edit_title(id=id, user_id=user_id, title=chat.title) + + return BaseResponse(detail="Title edited successfully") + + +@router.put("/{id}/archive", response_model=BaseResponse) +async def archive( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + id: int, + chat: Archive, +): + user_id = await auth_service.verify_token(token.credentials) + + await chatbot_service.archive(id=id, user_id=user_id, archive=chat.archive) + + return BaseResponse(detail="Title edited successfully") + + +@router.post("/{id}/related_questions", response_model=RelatedQuestions) +async def related_questions( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + id: int, + message: BaseMessage, +): + await auth_service.verify_token(token.credentials) + + questions = await chatbot_service.related_questions(query=message.content) + + return {"questions": questions} + + +@router.delete("/{id}/message/{message_id}", response_model=BaseResponse) +async def delete_message( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + id: int, + message_id: str, +): + user_id = await auth_service.verify_token(token.credentials) + + await chatbot_service.delete_message(id=id, message_id=message_id, user_id=user_id) + + return BaseResponse(detail="Message deleted successfully") + + +@router.put("/{id}/message/{message_id}/feedback", response_model=BaseResponse) +async def like_message( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + id: int, + message_id: str, + message: Like, +): + user_id = await auth_service.verify_token(token.credentials) + + await chatbot_service.like_message( + id=id, message_id=message_id, user_id=user_id, like=message.like + ) + + return BaseResponse(detail="Message updated successfully") diff --git a/src/routes/discount.py b/src/routes/discount.py new file mode 100644 index 0000000..54cf245 --- /dev/null +++ b/src/routes/discount.py @@ -0,0 +1,30 @@ +from fastapi import APIRouter, Depends +from fastapi.security import HTTPAuthorizationCredentials +from typing import Annotated +from ..schemas.discount import Code, Discount +from ..dependencies import auth_scheme +from ..services import discount as discount_service, auth as auth_service + +router = APIRouter( + prefix="/discount", + tags=["discount"], + responses={404: {"detail": "Not found"}}, +) + + +@router.post("/", response_model=Discount) +async def verify( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + discount: Code, +): + user_id = await auth_service.verify_token(token.credentials) + discount, bazzar_token = await discount_service.verify( + user_id=user_id, code=discount.code, product_id=discount.product_id + ) + + return { + "percent": discount.percent, + "value": discount.value, + "maxValue": discount.max_value, + "bazzar_token": bazzar_token, + } diff --git a/src/routes/event.py b/src/routes/event.py new file mode 100644 index 0000000..ba0696f --- /dev/null +++ b/src/routes/event.py @@ -0,0 +1,19 @@ +from fastapi import APIRouter, Depends +from fastapi.security import HTTPAuthorizationCredentials +from typing import Annotated +from ..dependencies import auth_scheme +from ..services import event as event_service, auth as auth_service + +router = APIRouter( + prefix="/event", + tags=["event"], + responses={404: {"detail": "Not found"}}, +) + + +@router.get("/") +async def get_all(token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)]): + await auth_service.verify_token(token.credentials) + events = await event_service.get_all() + + return {"events": events} diff --git a/src/routes/forum.py b/src/routes/forum.py new file mode 100644 index 0000000..eb0e6b2 --- /dev/null +++ b/src/routes/forum.py @@ -0,0 +1,87 @@ +from uuid import UUID +from fastapi import APIRouter, Depends, UploadFile, Form, File +from fastapi.security import HTTPAuthorizationCredentials +from typing import Annotated, Literal +from ..schemas.main import BaseResponse +from ..schemas.comment import NewComment, Comments, Replies, Feedback +from ..dependencies import auth_scheme +from ..services import ( + forum as forum_service, + auth as auth_service, + storage as storage_service, +) + +router = APIRouter( + prefix="/forum/comment", + tags=["forum"], + responses={404: {"detail": "Not found"}}, +) + + +@router.get("/", response_model=Comments) +async def get_comments( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + category_id: int, + page: int = 1, + order_by: Literal["date", "replies"] = "date", +): + user_id = await auth_service.verify_token(token.credentials) + + result = await forum_service.get_comments( + category_id=category_id, user_id=user_id, page=page, order_by=order_by + ) + + return result + + +@router.post("/", response_model=NewComment) +async def send_comment( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + text: Annotated[str, Form()], + category_id: Annotated[int, Form()], + parent_id: Annotated[int | None, Form()] = None, + replied_user_id: Annotated[UUID | None, Form()] = None, + image: Annotated[UploadFile | None, File()] = None, +): + user_id = await auth_service.verify_token(token.credentials) + + image_url = None + if image: + image_url = await storage_service.upload(id=user_id, file=image) + + comment = await forum_service.send_comment( + text=text, + image_url=image_url, + user_id=user_id, + category_id=category_id, + parent_id=parent_id, + replied_user_id=replied_user_id, + ) + + return {"comment": comment} + + +@router.get("/{id}", response_model=Replies) +async def get_replies( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + id: int, + page: int = 1, +): + user_id = await auth_service.verify_token(token.credentials) + + result = await forum_service.get_replies(parent_id=id, user_id=user_id, page=page) + + return result + + +@router.put("/{id}/feedback", response_model=BaseResponse) +async def feedback( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + id: int, + feedback: Feedback, +): + user_id = await auth_service.verify_token(token.credentials) + + await forum_service.feedback(status=feedback.status, user_id=user_id, comment_id=id) + + return BaseResponse(detail="Feedback has been successfully registered") diff --git a/src/routes/payment.py b/src/routes/payment.py new file mode 100644 index 0000000..7eaef31 --- /dev/null +++ b/src/routes/payment.py @@ -0,0 +1,150 @@ +from fastapi import APIRouter, Depends +from fastapi.security import HTTPAuthorizationCredentials +from fastapi.responses import RedirectResponse, StreamingResponse +from typing import Annotated + +from ..messages import MESSAGES +from ..dependencies import auth_scheme +from ..schemas.payment import Billings, Url, Plans, Settlement, Credit, PlansV2 ,BasaCreditRequest +from ..services import ( + payment as payment_service, + auth as auth_service, + storage as storage_service, +) + +router = APIRouter( + prefix="/paymant", + tags=["paymant"], + responses={404: {"detail": "Not found"}}, +) + + +@router.get("/", response_model=Url) +async def request( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + amount: int, + code: str | None = None, +): + user_id = await auth_service.verify_token(token.credentials) + url = await payment_service.request(user_id=user_id, amount=amount, code=code) + + return {"url": url} + + +@router.get("/plan", response_model=Plans) +async def get_plans(): + result = await payment_service.get_plans() + + return result + + +@router.get("/history", response_model=Billings) +async def history(token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)]): + user_id = await auth_service.verify_token(token.credentials) + billings = await payment_service.history(user_id=user_id) + + return {"billings": billings} + + +@router.get("/verify") +async def verify(Status: str, Authority: str): + status, msg, credit = await payment_service.verify( + status=Status, authority=Authority + ) + + return RedirectResponse( + url=f"https://web.houshan.ai/purchase?status={status}&msg={msg}&credit={credit}" + ) + + +@router.get("/bazzar", response_model=Credit) +async def bazzar( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + order_id: str, + purchase_token: str, + product_id: str, +): + user_id = await auth_service.verify_token(token.credentials) + + credit = await payment_service.bazzar( + user_id=user_id, + order_id=order_id, + product_id=product_id, + purchase_token=purchase_token, + ) + + return {"credit": credit} + + +@router.get("/myket", response_model=Credit) +async def myket( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + order_id: str, + purchase_token: str, + product_id: str, +): + user_id = await auth_service.verify_token(token.credentials) + + credit = await payment_service.myket( + user_id=user_id, + order_id=order_id, + product_id=product_id, + purchase_token=purchase_token, + ) + + return {"credit": credit} + + +@router.post("/settlement") +async def settlement( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + settlement: Settlement, +): + user_id = await auth_service.verify_token(token.credentials) + await payment_service.settlement(user_id=user_id, type=settlement.type) + + return {"msg": MESSAGES["settlement"]["success"]} + + +@router.get("/{id}/{key}") +async def stream_file(id: str, key: str): + file_size = await storage_service.get_file_size(f"payment/{id}/{key}") + headers = {"Content-Length": str(file_size)} + + return StreamingResponse( + storage_service.get(f"payment/{id}/{key}"), + media_type="application/octet-stream", + headers=headers, + ) + + +##### V2 Routes ##### + + +router_v2 = APIRouter( + prefix="/v2/paymant", + tags=["paymant"], + responses={404: {"detail": "Not found"}}, +) + + +@router_v2.get("/plan", response_model=PlansV2) +async def get_plans_v2(): + plans = await payment_service.get_plans_v2() + + return {"plans": plans} + +@router.post("/basa/increase-credit") +async def increase_basa_credit( + request_data: BasaCreditRequest, + # x_api_key: str = Header(...) # پیشنهاد: دریافت کلید امنیتی از هدر +): + # if x_api_key != settings.basa_api_secret: + # raise HTTPException(status_code=403, detail="Invalid API Key") + + result = await payment_service.add_basa_credit( + user_id=request_data.user_id, + amount=request_data.amount + ) + + return result diff --git a/src/routes/ticket.py b/src/routes/ticket.py new file mode 100644 index 0000000..2787acb --- /dev/null +++ b/src/routes/ticket.py @@ -0,0 +1,89 @@ +from uuid import UUID +from fastapi import APIRouter, Depends, Form, File, UploadFile +from fastapi.security import HTTPAuthorizationCredentials +from typing import Annotated +from ..schemas.main import BaseResponse +from ..schemas.ticket import Ticket, Tickets +from ..dependencies import auth_scheme +from ..services import ticket as ticket_service, auth as auth_service + +router = APIRouter( + prefix="/ticket", + tags=["ticket"], + responses={404: {"detail": "Not found"}}, +) + + +@router.post("/", response_model=Ticket) +async def send( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + text: Annotated[str | None, Form()] = None, + file: Annotated[UploadFile | None, File()] = None, +): + user_id = await auth_service.verify_token(token.credentials) + ticket = await ticket_service.send(user_id=user_id, text=text, file=file) + + return {"ticket": ticket} + + +@router.get("/", response_model=Tickets) +async def get_all( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], +): + user_id = await auth_service.verify_token(token.credentials) + tickets = await ticket_service.get_all(user_id) + + return {"tickets": tickets} + + +@router.delete("/{id}", response_model=BaseResponse) +async def delete( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], id: int +): + user_id = await auth_service.verify_token(token.credentials) + await ticket_service.delete(user_id, id) + + return BaseResponse(detail="Ticket has been successfully deleted") + + +##### Admin Routes ##### + +admin_router = APIRouter( + prefix="/admin/ticket", + tags=["admin"], + responses={404: {"detail": "Not found"}}, +) + + +@admin_router.get("/", summary="List of Users Who Sent Tickets") +async def get_all_users_admin( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], page: int +): + await auth_service.verify_admin_token(token.credentials) + result = await ticket_service.get_all_admin(page) + + return result + + +@admin_router.post("/", response_model=Ticket, summary="Send Ticket") +async def send_admin( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + user_id: Annotated[UUID, Form()], + text: Annotated[str | None, Form()] = None, + file: Annotated[UploadFile | None, File()] = None, +): + await auth_service.verify_admin_token(token.credentials) + ticket = await ticket_service.sendـadmin(user_id, text, file) + + return {"ticket": ticket} + + +@admin_router.get("/{id}", summary="Get All Tickets From One User") +async def get_all_admin( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + id: UUID, +): + await auth_service.verify_admin_token(token.credentials) + result = await ticket_service.get_all(id) + + return result diff --git a/src/routes/tool.py b/src/routes/tool.py new file mode 100644 index 0000000..0ae2bba --- /dev/null +++ b/src/routes/tool.py @@ -0,0 +1,244 @@ +import tempfile +from fastapi import APIRouter, Depends, Form, File, UploadFile +from fastapi.responses import StreamingResponse +from fastapi.security import HTTPAuthorizationCredentials +from typing import Annotated +from ..config import settings +from ..dependencies import auth_scheme +from ..schemas.chatbot import MessageStream +from ..services import ( + tool as tool_service, + auth as auth_service, + storage as storage_service, +) + +router = APIRouter( + prefix="/tool", + tags=["tool"], + responses={404: {"detail": "Not found"}}, +) + +SUNO_AI = [783, 871, 957, 958, 959, 960, 961, 962, 962, 963, 964, 965] +PIXVERSE_AI=[955] +PIXVERSE_TXT2IMG_AI=[970] +FAL_AI = [116, 123, 124, 414, 882, 883, 884, 885, 886, 887, 888, 891, 892, 893, 894] +OPENAI_FM=[1050, 1242, 1243, 1244, 1245, 1246] + +@router.post("/{bot_id}", response_model=MessageStream) +async def tool( + bot_id: int, + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + query: Annotated[str | None, Form()] = None, + id: Annotated[int | None, Form()] = None, + retry: Annotated[bool, Form()] = False, + ghost: Annotated[bool, Form()] = False, + image: Annotated[UploadFile | None, File()] = None, + audio: Annotated[UploadFile | None, File()] = None, + doc: Annotated[UploadFile | None, File()] = None, + options: Annotated[str | None, Form()] = "Hot Harley Quinn" +): + # Verify user authentication + user_id = await auth_service.verify_token(token.credentials) + chat_user_id = settings.ghost_id if ghost else user_id + + # Handle file uploads generically + image_url = None + if image: + image_url = await storage_service.upload(id=chat_user_id, file=image) + + audio_url = None + audio_path = None + if audio: + mime_type = audio.content_type.split("/")[0] + extension = audio.filename.split(".")[-1] + with tempfile.NamedTemporaryFile( + delete=False, suffix=f".{extension}" + ) as tmp_file: + tmp_file.write(await audio.read()) + audio_path = tmp_file.name + with open(audio_path, "rb") as f: + audio_url = await storage_service.upload_buffer( + user_id=chat_user_id, + file=f, + mime_type=mime_type, + extension=extension, + ) + + doc_url = None + doc_path = None + if doc: + mime_type = doc.content_type.split("/")[0] + extension = doc.filename.split(".")[-1] + with tempfile.NamedTemporaryFile( + delete=False, suffix=f".{extension}" + ) as tmp_file: + tmp_file.write(await doc.read()) + doc_path = tmp_file.name + with open(doc_path, "rb") as f: + doc_url = await storage_service.upload_buffer( + user_id=chat_user_id, + file=f, + mime_type=mime_type, + extension=extension, + ) + + # Speech-to-text + if bot_id == 14: + return StreamingResponse( + tool_service.speech_to_text( + id=id, + user_id=user_id, + bot_id=bot_id, + ghost=ghost, + audio=[ + (settings.base_url + audio_url) if audio_url else None, + audio_path, + ], + ), + media_type="application/json", + ) + + # Image-to-image generation (Clarity Upscaler, Background Remover, Ghibli) + elif bot_id in [391, 392, 1320]: + return StreamingResponse( + tool_service.fal_ai_image_to_image_generation( + id=id, + user_id=user_id, + bot_id=bot_id, + ghost=ghost, + image=(settings.base_url + image_url) if image_url else None, + ), + media_type="application/json", + ) + + # Image personalization (Avatar, Gender, Caricature) + elif bot_id in [438, 453, 454]: + return StreamingResponse( + tool_service.fal_ai_image_personalization( + id=id, + user_id=user_id, + bot_id=bot_id, + ghost=ghost, + image=(settings.base_url + image_url) if image_url else None, + ), + media_type="application/json", + ) + + # Audio generation (Suno AI) + elif bot_id in SUNO_AI: + return StreamingResponse( + tool_service.suno_ai_audio_generator( + id=id, + user_id=user_id, + bot_id=bot_id, + ghost=ghost, + query=query, + ), + media_type="application/json", + ) + + # OpenAI text generation + elif bot_id == 11: + return StreamingResponse( + tool_service.text_to_speech( + id=id, + query=query, + user_id=user_id, + bot_id=bot_id, + ghost=ghost, + ), + media_type="application/json", + ) + + # Translator + elif bot_id in [12, 494]: + return StreamingResponse( + tool_service.translator( + id=id, + query=query, + user_id=user_id, + bot_id=bot_id, + ghost=ghost, + ), + media_type="application/json", + ) + + # OpenAI.FM TTS + elif bot_id in OPENAI_FM: + return StreamingResponse( + tool_service.tts_openai( + id=id, + query=query, + bot_id=bot_id, + user_id=user_id, + ghost=ghost, + ), + media_type="application/json", + ) + + # FAL AI image generation + elif bot_id in FAL_AI: + return StreamingResponse( + tool_service.fal_ai_image_generation( + id=id, + query=query, + bot_id=bot_id, + user_id=user_id, + ghost=ghost, + ), + media_type="application/json", + ) + + # Image personalization (Avatar, Gender, Caricature) + elif bot_id in PIXVERSE_AI: + await image.seek(0) + image_bytes = await image.read() if image is not None else None + + return StreamingResponse( + tool_service.pixverse_image2video_generator( + id=id, + image_bytes=image_bytes, + user_id=user_id, + bot_id=bot_id, + ghost=ghost, + effect=options, + user_image_url=(settings.base_url + image_url) if image_url else None, + ), + media_type="application/json", + ) + + # Text to image + elif bot_id in PIXVERSE_TXT2IMG_AI: + return StreamingResponse( + tool_service.pixverse_text2video_generator( + id=id, + user_id=user_id, + bot_id=bot_id, + ghost=ghost, + prompt=query + ), + media_type="application/json", + ) + + # Catch-all for other bot_ids + else: + return StreamingResponse( + tool_service.others( + id=id, + query=query, + user_id=user_id, + bot_id=bot_id, + retry=retry, + ghost=ghost, + image=(settings.base_url + image_url) if image_url else None, + audio=[ + (settings.base_url + audio_url) if audio_url else None, + audio_path, + ], + doc=[ + (settings.base_url + doc_url) if doc_url else None, + doc_path, + ], + ), + media_type="application/json", + ) diff --git a/src/routes/user.py b/src/routes/user.py new file mode 100644 index 0000000..169a87d --- /dev/null +++ b/src/routes/user.py @@ -0,0 +1,426 @@ +from uuid import UUID +from datetime import date +from typing import Annotated +from fastapi import APIRouter, Depends, UploadFile +from fastapi.security import HTTPAuthorizationCredentials +from fastapi.responses import StreamingResponse +from ..dependencies import auth_scheme +from ..schemas.token import Token, BaseResponse +from ..services import ( + user as user_service, + otp as otp_service, + auth as auth_service, + storage as storage_service, +) +from ..schemas.user import ( + UserBase, + UserLoginWithOTP, + UserLogin, + User, + Username, + Available, + Profile, + Password, + CardNumber, + Code, + CoinReport, + WeeklyReport, + DailyReport, + Firebase, + IsNew, + Users, + OneUser, + UserLevel, + TelegramSyncRequest, + TelegramLoginRequest, + UserLevel, + CreateSubUser, + SubUsersList, + GroupScore +) +from ..config import settings + +router = APIRouter( + prefix="/user", + tags=["user"], + responses={404: {"detail": "Not found"}}, +) + +def check_telegram_secret(secret: str): + return secret == settings.internal_telegram_secret + + +@router.post("/otp", response_model=BaseResponse, summary="Send OTP") +async def send_otp(user: UserBase): + print(f"--- [WARNING] ---") + print(f"--- [WARNING] ---") + print(f"--- [WARNING] ---") + print(f"--- [WARNING] ---") + print(f"--- [WARNING] ---") + print(f"--- [WARNING] ---") + print(f"--- [WARNING] ---") + await otp_service.generate(user.mobile_number) + + return BaseResponse(detail="OTP has been successfully sent") + + +@router.post("/telegram-login") +async def telegram_sync_password(request: TelegramLoginRequest): + if not check_telegram_secret(request.secret): + return BaseResponse(detail="Invalid telegram secret") + + result = await user_service.telegram_login(request.telegram_id) + return { + "access_token": result.get("access_token"), + "token_type": "Bearer", + "user": result.get("user"), + } + + +@router.post("/telegram-sync") +async def telegram_sync_password(request: TelegramSyncRequest): + if not check_telegram_secret(request.secret): + return BaseResponse(detail="Invalid telegram secret") + + result = await user_service.telegram_sync(request) + + if result: + return BaseResponse(detail="Telegram user has been synced") + return { + "message": "Telegram user has not been synced", + "user": result + } + + +@router.post("/register", response_model=BaseResponse) +async def register(user: UserBase): + await user_service.register(user.mobile_number) + + return BaseResponse(detail="OTP has been successfully sent") + + +@router.post("/login", response_model=Token, summary="Login With Password") +async def login(user: UserLogin): + access_token = await user_service.login(user.username, user.password) + + return { + "access_token": access_token, + "token_type": "Bearer", + "user": None + } + + +@router.post("/login/otp", response_model=Token, summary="Login With OTP") +async def login_with_otp(user: UserLoginWithOTP): + access_token = await user_service.login_with_otp(user.mobile_number, user.otp) + + return { + "access_token": access_token, + "token_type": "Bearer", + "user": None + } + + +@router.get("/info", response_model=User) +async def get_info( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], +): + user_id = await auth_service.verify_token(token.credentials) + user = await user_service.get_userAndVerify(user_id) + + return user + + +@router.post("/username", response_model=Available) +async def check_username( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], user: Username +): + await auth_service.verify_token(token.credentials) + available = await user_service.check_username(username=user.username) + + return {"available": available} + + +@router.put("/username", response_model=BaseResponse) +async def edit_username( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], user: Username +): + user_id = await auth_service.verify_token(token.credentials) + await user_service.edit_username(user_id=user_id, username=user.username) + + return BaseResponse(detail="Username has been successfully edited") + + +@router.put("/card-number", response_model=BaseResponse) +async def edit_card_number( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + user: CardNumber, +): + user_id = await auth_service.verify_token(token.credentials) + await user_service.edit_card_number(user_id=user_id, card_number=user.card_number) + + return BaseResponse(detail="Card number has been successfully edited") + + +@router.put("/firebase-token", response_model=BaseResponse) +async def set_firebase_token( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + user: Firebase, +): + user_id = await auth_service.verify_token(token.credentials) + await user_service.set_firebase_token(user_id=user_id, token=user.token) + + return BaseResponse(detail="Firebase token has been successfully registered") + + +@router.put("/profile", response_model=Profile) +async def edit_profile( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + file: UploadFile, +): + user_id = await auth_service.verify_token(token.credentials) + url = await user_service.edit_profile(user_id=user_id, file=file) + + return {"image": url} + + +@router.delete("/profile", response_model=BaseResponse) +async def delete_profile( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], +): + user_id = await auth_service.verify_token(token.credentials) + await user_service.delete_profile(user_id=user_id) + + return BaseResponse(detail="Profile has been successfully deleted") + + +@router.put("/password", response_model=BaseResponse) +async def edit_password( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], user: Password +): + user_id = await auth_service.verify_token(token.credentials) + await user_service.edit_password(user_id=user_id, password=user.password) + + return BaseResponse(detail="Password has been successfully edited") + + +@router.post("/code", response_model=BaseResponse) +async def code( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], user: Code +): + user_id = await auth_service.verify_token(token.credentials) + message = await user_service.register_code(user_id=user_id, code=user.code) + + return BaseResponse(detail=message) + + +@router.put("/notification", response_model=BaseResponse) +async def mark_all_notifications_as_seen( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], +): + user_id = await auth_service.verify_token(token.credentials) + await user_service.mark_all_notifications_as_seen(user_id=user_id) + + return BaseResponse(detail="All notifications has been successfully marked as seen") + + +@router.get("/report/coin", response_model=CoinReport) +async def coin_usage_report( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + start_date: date | None = None, + end_date: date | None = None, +): + user_id = await auth_service.verify_token(token.credentials) + report = await user_service.coin_usage_report( + user_id=user_id, start_date=start_date, end_date=end_date + ) + + return {"report": report} + + +@router.get("/report/periodic", response_model=WeeklyReport) +async def periodic_usage_report( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + start_date: date, + end_date: date, +): + user_id = await auth_service.verify_token(token.credentials) + report = await user_service.periodic_usage_report(user_id, start_date, end_date) + + return {"report": report} + + +@router.get("/report/weekly", response_model=WeeklyReport) +async def weekly_usage_report( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + start_date: date, +): + user_id = await auth_service.verify_token(token.credentials) + report = await user_service.weekly_usage_report(user_id, start_date) + + return {"report": report} + + +@router.get("/report/daily", response_model=DailyReport) +async def daily_usage_report( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + start_date: date, +): + user_id = await auth_service.verify_token(token.credentials) + report = await user_service.daily_usage_report(user_id, start_date) + + return {"report": report} + + +@router.get("/{id}/{key}") +async def stream_file( + # token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + id: UUID, + key: str, +): + # user_id = await auth_service.verify_token(token.credentials) + + # if id != user_id: + # raise HTTPException(status_code=403, detail="Forbidden") + + file_size = await storage_service.get_file_size(f"user/{id}/{key}") + headers = {"Content-Length": str(file_size)} + + return StreamingResponse( + storage_service.get(f"user/{id}/{key}"), + media_type="application/octet-stream", + headers=headers, + ) + + +##### Admin Routes ##### + +admin_router = APIRouter( + prefix="/admin/user", + tags=["admin"], + responses={404: {"detail": "Not found"}}, +) + + +@admin_router.get("/", response_model=Users) +async def get_users( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + page: int = 1, + q: str | None = None, +): + await auth_service.verify_admin_token(token.credentials) + result = await user_service.get_users(page, q) + + return result + + +@admin_router.post("/login", response_model=Token, summary="Login With Password") +async def admin_login(user: UserLogin): + access_token = await user_service.admin_login(user.username, user.password) + user = await user_service.get_admin_user(user.username) + + return { + "access_token": access_token, + "token_type": "Bearer", + "user": user, + } + + +@admin_router.post("/groupscore", response_model=BaseResponse, summary="Add Group Score to All Users") +async def add_group_score( + data: GroupScore, + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], +): + await auth_service.verify_admin_token(token.credentials) + await user_service.add_group_score(data.coins,data.price) + + return BaseResponse(detail="امتیاز به تمام کاربران با موفقیت اضافه شد") + + +@admin_router.get("/{id}", response_model=OneUser) +async def get_user( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], id: str +): + await auth_service.verify_admin_token(token.credentials) + user = await user_service.admin_get_user(id) + + return {"user": user} + + +##### V2 Routes ##### + +router_v2 = APIRouter( + prefix="/v2/user", + tags=["user"], + responses={404: {"detail": "Not found"}}, +) + + +@admin_router.put("/{user_id}/level", response_model=BaseResponse, summary="Update User Level") +async def update_level( + user_id: UUID, # <--- دریافت آیدی کاربر هدف از URL + data: UserLevel, + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], +): + await auth_service.verify_admin_token(token.credentials) + + await user_service.edit_level(user_id=user_id, level=data.level) + + return BaseResponse(detail="User level has been successfully updated") + + + + +@router_v2.post("/otp", response_model=IsNew, summary="Send OTP V2") +async def send_otp_v2(user: UserBase): + is_new = await otp_service.generate(user.mobile_number) + + return {"is_new": False} + + +@router.post("/sub-user", response_model=BaseResponse, summary="Create Sub User (Level 2)") +async def add_sub_user( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], + data: CreateSubUser +): + """ + کاربر اصلی (سطح ۱) می‌تواند با این متد یک کاربر فرعی (سطح ۲) ایجاد کند. + """ + user_id = await auth_service.verify_token(token.credentials) + + await user_service.create_sub_user( + parent_id=user_id, + mobile_number=data.mobile_number, + level = data.level + ) + + return BaseResponse(detail=" زیر مجموعه شما با موفقیت ایجاد شد") + + +@router.get("/sub-user", response_model=SubUsersList, summary="Get My Sub Users") +async def get_sub_users( + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], +): + """ + مشاهده لیست کاربران فرعی زیرمجموعه. + + """ + user_id = await auth_service.verify_token(token.credentials) + + result = await user_service.get_my_sub_users(user_id=user_id) + + return result + +@router.delete("/sub-user/{sub_user_id}", response_model=BaseResponse, summary="Delete Sub User") +async def remove_sub_user( + sub_user_id: UUID, + token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)], +): + """ + حذف یک کاربر زیرمجموعه توسط والد. + """ + user_id = await auth_service.verify_token(token.credentials) + + await user_service.delete_sub_user(parent_id=user_id, sub_user_id=sub_user_id) + + return BaseResponse(detail="زیرمجموعه با موفقیت حذف شد") \ No newline at end of file diff --git a/src/schemas/__init__.py b/src/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/schemas/advertisement.py b/src/schemas/advertisement.py new file mode 100644 index 0000000..b1e43e8 --- /dev/null +++ b/src/schemas/advertisement.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel +from enum import Enum + +class AdEnum(str, Enum): + REWARDED_VIDEO = "rewarded_video" + Interstitial = "interstitial" + Native = "native" + Standard = "standard" + +class Advertisement(BaseModel): + ad_type: AdEnum diff --git a/src/schemas/banner.py b/src/schemas/banner.py new file mode 100644 index 0000000..c2c164f --- /dev/null +++ b/src/schemas/banner.py @@ -0,0 +1,14 @@ +from pydantic import BaseModel +from .bot import BotInfo + + +class Banner(BaseModel): + image: str + link: str | None + title: str | None + description: str | None + bot: BotInfo | None + + +class Banners(BaseModel): + banners: list[Banner] diff --git a/src/schemas/bot.py b/src/schemas/bot.py new file mode 100644 index 0000000..afea4ac --- /dev/null +++ b/src/schemas/bot.py @@ -0,0 +1,197 @@ +from datetime import datetime +from pydantic import BaseModel, validator +from ..models import BotStatus, BotType + + +class BotUser(BaseModel): + name: str | None + username: str | None + + +class BotCategory(BaseModel): + id: int + name: str + + +class BaseBot(BaseModel): + id: int + image: str | None + image_2: str | None + model: str + name: str | None + description: str | None + + +class BotLevelUpdate(BaseModel): + allowed_levels: list[int] | None = [] + + +class BotInfo(BaseBot): + cost: int + limit: int + tool: bool + public: bool + attachment: int + deleted: bool + web_search: bool + marked: bool | None = None + attachment_type: list[str] | None = None + type: BotType + category: BotCategory | None = None + messages: int = 0 + allowed_levels: list[int] | None = [] + + @validator("allowed_levels", pre=True) + def ensure_list_levels(cls, v): + if v is None: + return [] + return v + + +class MyBotPreview(BaseBot): + score: float | None + comments: int + status: BotStatus + + @validator("score") + def round_score(cls, v): + if v is not None: + return round(v, 2) + return v + + +class MyBotInfo(BaseBot): + prompt: str + description: str + public: bool + docs: list[str] | None + links: list[str] | None + # تغییر: اجازه دادن به مقدار None برای جلوگیری از خطا + category: BotCategory | None + + +class MyBots(BaseModel): + bots: list[MyBotPreview] + + +class MyBot(BaseModel): + bot: MyBotInfo + + +class Bots(BaseModel): + bots: list[BotInfo] + + +class Mark(BaseModel): + marked: bool + + +class Category(BaseModel): + category_name: str + bots: list[BotInfo] + + +class Categories(BaseModel): + categories: list[Category] + + +class CommentUser(BaseModel): + username: str | None + image: str | None + + +class BaseComment(BaseModel): + text: str + score: float + + @validator("score") + def round_score(cls, v): + if v is not None: + return round(v, 2) + return v + + +class Comment(BaseComment): + created_at: datetime + user: CommentUser + + +class Comments(BaseModel): + comments: list[Comment] + page: int + total_count: int + last_page: int + + +class Bot(BotInfo): + score: float | None + comments: int + users: int + messages: int + created_at: datetime + user: BotUser | None + category: BotCategory | None + user_comment: BaseComment | None + + @validator("score") + def round_score(cls, v): + if v is not None: + return round(v, 2) + return v + + +class OneBot(BaseModel): + bot: Bot + + +class Assistant(BaseModel): + id: int + name: str | None + description: str | None + image: str | None + created_at: datetime + # تغییر: اختیاری کردن دسته بندی + category: BotCategory | None + status: BotStatus + + +class Assistants(BaseModel): + assistants: list[Assistant] + page: int + total_count: int + last_page: int + + +class AssistantDetail(Assistant): + cost: int + prompt: str | None + public: bool + links: list[str] | None + docs: list[str] | None + # تغییر: رفع خطای user is None + user: BotUser | None + allowed_levels: list[int] | None = None + + @validator("allowed_levels", pre=True) + def ensure_list_levels(cls, v): + if v is None: + return [] + return v + + +class OneAssistant(BaseModel): + assistant: AssistantDetail + + +class Confirm(BaseModel): + cost: int + message: str + allowed_levels: list[int] | None = [1] + + +class Reject(BaseModel): + message: str + + +class Image(BaseModel): + url: str \ No newline at end of file diff --git a/src/schemas/category.py b/src/schemas/category.py new file mode 100644 index 0000000..7accb61 --- /dev/null +++ b/src/schemas/category.py @@ -0,0 +1,24 @@ +from pydantic import BaseModel +from .bot import BotInfo + + +class Category(BaseModel): + id: int + name: str + icon: str | None + image: str | None + + +class ToolCategory(Category): + icon: str | None + image: str | None + description: str | None + bots: list[BotInfo] + + +class Categories(BaseModel): + categories: list[Category] + + +class ToolCategories(BaseModel): + categories: list[ToolCategory] diff --git a/src/schemas/chatbot.py b/src/schemas/chatbot.py new file mode 100644 index 0000000..f31f0c9 --- /dev/null +++ b/src/schemas/chatbot.py @@ -0,0 +1,80 @@ +from pydantic import BaseModel +from typing import List +from datetime import datetime +from .bot import BotInfo + + +class Url(BaseModel): + url: str + query: str | None = None + attachment: bool | None = None + + +class Content(BaseModel): + type: str + text: str | None = None + image_url: Url | None = None + audio_url: Url | None = None + pdf_url: Url | None = None + video_url: Url | None = None + + +class BaseMessage(BaseModel): + id: str + content: str | List[Content] + + +class Message(BaseMessage): + role: str + like: bool | None + created_at: datetime | None + + +class ChatMessage(BaseModel): + id: int | None = None + model: str + query: str + bot_id: int + retry: bool = False + + +class Chat(BaseModel): + id: int + title: str + created_at: datetime + bot: BotInfo + + +class ChatHistory(Chat): + messages: List[Message] + + +class Chats(BaseModel): + chats: List[Chat] + page: int + total_count: int + last_page: int + + +class Like(BaseModel): + like: bool | None + + +class Title(BaseModel): + title: str + + +class Archive(BaseModel): + archive: bool = False + + +class RelatedQuestions(BaseModel): + questions: List[str] + + +class MessageStream(BaseModel): + chat_id: str + chat_title: str + content: str + ai_message_id: str + human_message_id: str diff --git a/src/schemas/comment.py b/src/schemas/comment.py new file mode 100644 index 0000000..36b4c31 --- /dev/null +++ b/src/schemas/comment.py @@ -0,0 +1,56 @@ +from uuid import UUID +from pydantic import BaseModel +from datetime import datetime +from typing import Literal + + +class CommentUser(BaseModel): + id: UUID + name: str | None + image: str | None + username: str | None + + +class CommentBase(BaseModel): + id: int + text: str + image: str | None + created_at: datetime + + +class NewComment(BaseModel): + comment: CommentBase + + +class Comment(CommentBase): + replies: int + likes: int + dislikes: int + user_feedback: int + user: CommentUser + + +class Reply(CommentBase): + likes: int + dislikes: int + user_feedback: int + user: CommentUser + replied_user: CommentUser + + +class Comments(BaseModel): + comments: list[Comment] + page: int + total_count: int + last_page: int + + +class Replies(BaseModel): + replies: list[Reply] + page: int + total_count: int + last_page: int + + +class Feedback(BaseModel): + status: Literal[-1, 0, 1] diff --git a/src/schemas/discount.py b/src/schemas/discount.py new file mode 100644 index 0000000..dad2ca7 --- /dev/null +++ b/src/schemas/discount.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel + + +class Discount(BaseModel): + percent: int | None + value: int | None + maxValue: int | None + bazzar_token: str | None + + +class Code(BaseModel): + code: str + product_id: str diff --git a/src/schemas/event.py b/src/schemas/event.py new file mode 100644 index 0000000..e69de29 diff --git a/src/schemas/main.py b/src/schemas/main.py new file mode 100644 index 0000000..1320080 --- /dev/null +++ b/src/schemas/main.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel + + +class BaseResponse(BaseModel): + detail: str | dict | None diff --git a/src/schemas/payment.py b/src/schemas/payment.py new file mode 100644 index 0000000..2120811 --- /dev/null +++ b/src/schemas/payment.py @@ -0,0 +1,60 @@ +from pydantic import BaseModel +from typing import List +from datetime import datetime +from ..models import BillingStatus, BillingType, SettlementType +from uuid import UUID + +class Billing(BaseModel): + id: int + credit: int + amount: int + ref_id: str | None + type: BillingType + status: BillingStatus + created_at: datetime + + +class Billings(BaseModel): + billings: List[Billing] + + +class Url(BaseModel): + url: str + + +class Credit(BaseModel): + credit: int + + +class PlanDetail(BaseModel): + id: str + price: int + coins: int + free_coins: int + + +class Plan(BaseModel): + name: str + prices: list[PlanDetail] + + +class Plans(BaseModel): + plans: list[Plan] + + +class PlanDetailV2(PlanDetail): + title: str + image: str | None + description: str | None + + +class PlansV2(BaseModel): + plans: list[PlanDetailV2] + + +class Settlement(BaseModel): + type: SettlementType + +class BasaCreditRequest(BaseModel): + user_id: UUID + amount: int \ No newline at end of file diff --git a/src/schemas/ticket.py b/src/schemas/ticket.py new file mode 100644 index 0000000..f2f0e2a --- /dev/null +++ b/src/schemas/ticket.py @@ -0,0 +1,37 @@ +from uuid import UUID +from datetime import datetime +from pydantic import BaseModel +from ..models import TicketRole +from typing import Dict + + +class TicketBase(BaseModel): + id: int + text: str | None + file: str | None + role: TicketRole + created_at: datetime + + +class Ticket(BaseModel): + ticket: TicketBase + + +class Tickets(BaseModel): + tickets: Dict[str, list[TicketBase]] + + +class User(BaseModel): + id: UUID + name: str | None + username: str | None + phone_number: str | None + image: str | None + last_ticket: TicketBase + + +class Users(BaseModel): + users: list[User] + page: int + total_count: int + last_page: int diff --git a/src/schemas/token.py b/src/schemas/token.py new file mode 100644 index 0000000..18a1d56 --- /dev/null +++ b/src/schemas/token.py @@ -0,0 +1,9 @@ +from .main import BaseResponse +from .user import User + + +class Token(BaseResponse): + access_token: str + token_type: str + detail: str = "You have successfully logged in" + user: User | None = None diff --git a/src/schemas/tool.py b/src/schemas/tool.py new file mode 100644 index 0000000..a6e128b --- /dev/null +++ b/src/schemas/tool.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + + +class ToolMessage(BaseModel): + id: int | None = None + query: str diff --git a/src/schemas/user.py b/src/schemas/user.py new file mode 100644 index 0000000..0fc8b4d --- /dev/null +++ b/src/schemas/user.py @@ -0,0 +1,179 @@ +from uuid import UUID +from pydantic import BaseModel, validator, field_validator , Field +from datetime import datetime +from .bot import Assistant + + +class UserBase(BaseModel): + mobile_number: str | None + + +class UserLogin(BaseModel): + username: str + password: str + + +class UserLoginWithOTP(BaseModel): + mobile_number: str + otp: str + + +class Notification(BaseModel): + title: str + message: str + seen: bool + created_at: datetime + +class UserLevel(BaseModel): + level: int + +class CreateSubUser(BaseModel): + mobile_number: str + name: str | None = None + +class SubUserPreview(BaseModel): + id: UUID + name: str | None + username: str | None + mobile_number: str | None + level: int + created_at: datetime + +class SubUsersList(BaseModel): + sub_users: list[SubUserPreview] + total_count: int + +class TelegramLoginRequest(BaseModel): + telegram_id: str + secret: str + +class TelegramSyncRequest(BaseModel): + telegram_id: str + mobile_number: str | None + email: str | None + password: str | None + otp: str | None + secret: str + +class UserParent(BaseModel): + id: UUID + name: str | None + username: str | None + +class User(UserBase): + id: UUID + name: str | None + image: str | None + email: str | None + username: str | None + code: str | None + telegram_id: str | None + income: float + credit: int + free_credit: int + gift_credit: int | None = 0 + level: int + card_number: str | None + notifications: list[Notification] + parent: UserParent | None = None + + sub_users: list[SubUserPreview] = [] + + @validator("income") + def round_score(cls, v): + if v is not None: + return round(v, 2) + return v + + +class Username(BaseModel): + username: str + + +class CardNumber(BaseModel): + card_number: str + + +class Firebase(BaseModel): + token: str + + +class Password(BaseModel): + password: str + + +class Profile(BaseModel): + image: str + + +class Available(BaseModel): + available: bool + + +class Code(BaseModel): + code: str + + +class CoinUsage(BaseModel): + bot_name: str + messages_count: int + coin_usage: int + + +class CoinReport(BaseModel): + report: list[CoinUsage] + + +class WeeklyUsage(BaseModel): + date: str + messages_count: int + coin_usage: int + + +class WeeklyReport(BaseModel): + report: list[WeeklyUsage] + + +class DailyUsage(BaseModel): + hour: str + messages_count: int + coin_usage: int + + +class DailyReport(BaseModel): + report: list[DailyUsage] + + +class IsNew(BaseModel): + is_new: bool + + +class UserPreview(UserBase): + id: UUID + name: str | None + username: str | None + image: str | None + + +class Users(BaseModel): + users: list[UserPreview] + page: int + total_count: int + last_page: int + + +class UserDetail(User): + created_at: datetime + bots: list[Assistant] + + +class OneUser(BaseModel): + user: UserDetail + +class CreateSubUser(BaseModel): + mobile_number: str + level: int = Field(..., ge=1, le=5, description="سطح دسترسی کاربر بین ۱ تا ۵") + +class GroupScore(BaseModel): + price: int + coins :int \ No newline at end of file diff --git a/src/services/__init__.py b/src/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/services/_utils/__init__.py b/src/services/_utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/services/_utils/generate_en_prompt.py b/src/services/_utils/generate_en_prompt.py new file mode 100644 index 0000000..0384240 --- /dev/null +++ b/src/services/_utils/generate_en_prompt.py @@ -0,0 +1,16 @@ +from langchain_openai import ChatOpenAI +from langchain_core.messages import HumanMessage, SystemMessage + +from ...config import settings + + +async def generate_en_prompt(query: str): + model = ChatOpenAI(model="gpt-4o-mini", api_key=settings.openai_api_key) + + instruction = "Generate a detailed and precise prompt in English for an image generation model based on the given input text. Avoid extra words or explanations—only provide the refined prompt." + + res = await model.ainvoke( + [SystemMessage(content=instruction), HumanMessage(content=query)] + ) + + return res.content diff --git a/src/services/_utils/get_or_create_chatbot.py b/src/services/_utils/get_or_create_chatbot.py new file mode 100644 index 0000000..7ace118 --- /dev/null +++ b/src/services/_utils/get_or_create_chatbot.py @@ -0,0 +1,36 @@ +import json +from uuid import UUID +from ...models import Chatbot +from ...messages import MESSAGES + + +async def get_or_create_chatbot( + id: int | None, + bot_id: int, + user_id: UUID, + title: str | None = None, + is_ghost: bool = False +): + if id is None: + # اصلاح: اضافه کردن is_ghost=is_ghost + chatbot = await Chatbot.create( + user_id=user_id, + bot_id=bot_id, + title=title, + is_ghost=is_ghost + ) + return chatbot + + chatbot = await Chatbot.get_or_none(id=id, bot_id=bot_id, user_id=user_id) + if chatbot is None: + raise ValueError( + json.dumps( + { + "error": True, + "status_code": 404, + "detail": MESSAGES["chat"]["not_found"], + } + ) + ) + + return chatbot diff --git a/src/services/_utils/local_upload_file.py b/src/services/_utils/local_upload_file.py new file mode 100644 index 0000000..e2b08b9 --- /dev/null +++ b/src/services/_utils/local_upload_file.py @@ -0,0 +1,21 @@ +from pathlib import Path +import mimetypes + +from fastapi import UploadFile +from starlette.datastructures import Headers + + +class LocalUploadFile(UploadFile): + def __init__(self, file_path: str): + file_path = Path(file_path) + content_type, _ = mimetypes.guess_type(file_path) + content_type = content_type or "application/octet-stream" + + headers = Headers({"content-type": content_type}) + + super().__init__( + filename=file_path.name, file=open(file_path, "rb"), headers=headers + ) + + def close(self): + self.file.close() diff --git a/src/services/_utils/validate_user_and_bot.py b/src/services/_utils/validate_user_and_bot.py new file mode 100644 index 0000000..1d3d7f8 --- /dev/null +++ b/src/services/_utils/validate_user_and_bot.py @@ -0,0 +1,442 @@ +from datetime import datetime, timedelta +from pydantic.v1 import UUID4 +from tortoise.transactions import in_transaction +from uuid import UUID +from ...models import User, Bot, Message +from ...messages import MESSAGES + + +# async def validate_user_and_bot(id: int | None, bot_id: int, user_id: UUID4) -> tuple: +# now = datetime.utcnow() +# one_hour_ago = now - timedelta(hours=1) + +# # Fetch user +# user = await User.get_or_none(id=user_id) +# if user is None: +# return ( +# False, +# { +# "error": True, +# "status_code": 404, +# "detail": MESSAGES["user"]["not_found"], +# }, +# None, +# None, +# ) + +# # Fetch bot +# bot = await Bot.get_or_none(id=bot_id) +# if bot is None: +# return ( +# False, +# {"error": True, "status_code": 404, "detail": "Bot not found"}, +# None, +# None, +# ) + +# # Count messages in the last hour +# message_count = await Message.filter( +# chatbot_id=id, created_at__gte=one_hour_ago, created_at__lte=now +# ).count() + +# # Corrected limit check: error if message_count >= bot.limit +# if bot.limit > -1 and message_count >= bot.limit: +# return ( +# False, +# {"error": True, "status_code": 429, "detail": MESSAGES["chat"]["limit"]}, +# None, +# None, +# ) + +# total_credit = user.credit + user.free_credit + +# # Check and deduct credit +# if total_credit < bot.cost: +# return ( +# False, +# { +# "error": True, +# "status_code": 403, +# "detail": MESSAGES["chat"]["no_credit"], +# }, +# None, +# None, +# ) + +# if user.credit >= bot.cost: +# user.credit -= bot.cost +# else: +# deduct_from_free = bot.cost - user.credit +# user.free_credit -= deduct_from_free +# user.credit = 0 + +# return True, None, user, bot + + +# async def validate_user_and_bot(id: int | None, bot_id: int, user_id: UUID4) -> tuple: +# now = datetime.utcnow() +# one_hour_ago = now - timedelta(hours=1) + +# # دیکشنری برای نگهداری گزارش دقیق کسر اعتبار +# usage_report = { +# "gift": 0, +# "credit": 0, +# "free": 0 +# } + +# # شروع تراکنش دیتابیس برای امنیت مالی +# async with in_transaction() as conn: + +# # 1. دریافت اطلاعات اولیه کاربر و والد +# user_initial = await User.get_or_none(id=user_id).select_related("parent").using_db(conn) + +# if user_initial is None: +# return ( +# False, +# {"error": True, "status_code": 404, "detail": MESSAGES["user"]["not_found"]}, +# None, None, usage_report +# ) + +# # دریافت ربات +# bot = await Bot.get_or_none(id=bot_id).using_db(conn) +# if bot is None: +# return ( +# False, +# {"error": True, "status_code": 404, "detail": "Bot not found"}, +# None, None, usage_report +# ) + +# # بررسی محدودیت تعداد پیام در ساعت +# message_count = await Message.filter( +# chatbot_id=id, created_at__gte=one_hour_ago, created_at__lte=now +# ).using_db(conn).count() + +# if bot.limit > -1 and message_count >= bot.limit: +# return ( +# False, +# {"error": True, "status_code": 429, "detail": MESSAGES["chat"]["limit"]}, +# None, None, usage_report +# ) + +# cost = bot.cost +# remaining_cost = cost +# paid_by_parent = False +# final_user = user_initial + +# # --------------------------------------------------------- +# # بخش اول: تلاش برای پرداخت توسط والد (اگر کاربر زیرمجموعه باشد) +# # --------------------------------------------------------- +# if user_initial.parent: +# # قفل کردن رکورد والد +# parent = await User.filter(id=user_initial.parent.id).select_for_update().using_db(conn).first() + +# # هندل کردن مقدار None برای gift_credit والد +# parent_gift = parent.gift_credit if parent and parent.gift_credit is not None else 0 + +# if parent and parent_gift >= cost: +# # کسر از والد +# parent.gift_credit = parent_gift - cost +# await parent.save(using_db=conn) + +# # به‌روزرسانی همگانی: سکه باسا تمام زیرمجموعه‌های این والد +# await User.filter(parent_id=parent.id).update(gift_credit=parent.gift_credit, using_db=conn) + +# # پر کردن گزارش مصرف +# usage_report["gift"] = cost +# remaining_cost = 0 +# paid_by_parent = True + +# # آپدیت کردن آبجکت یوزر فعلی در حافظه +# final_user.gift_credit = parent.gift_credit + +# # --------------------------------------------------------- +# # بخش دوم: پرداخت توسط خود کاربر (اگر والد پرداخت نکرد) +# # --------------------------------------------------------- +# if not paid_by_parent: +# # قفل کردن رکورد خود کاربر +# user = await User.filter(id=user_id).select_for_update().using_db(conn).first() +# final_user = user + +# # تبدیل مقادیر None به 0 برای جلوگیری از خطا +# my_gift = user.gift_credit if user.gift_credit is not None else 0 +# my_credit = user.credit if user.credit is not None else 0 +# my_free = user.free_credit if user.free_credit is not None else 0 + +# # محاسبه دارایی قابل استفاده +# available_assets = 0 +# if user_initial.parent: +# # اگر زیرمجموعه است و والد پول نداشت، فقط اعتبار اصلی و هدیه خودش +# available_assets = my_credit + my_free +# else: +# # کاربر مستقل: همه دارایی‌ها +# available_assets = my_gift + my_credit + my_free + +# if available_assets < cost: +# return ( +# False, +# {"error": True, "status_code": 403, "detail": MESSAGES["chat"]["no_credit"]}, +# None, None, usage_report +# ) + +# # اولویت ۱: کسر از سکه باسا (فقط اگر کاربر مستقل باشد) +# if not user_initial.parent and my_gift > 0 and remaining_cost > 0: +# deduct = min(my_gift, remaining_cost) +# user.gift_credit = my_gift - deduct +# remaining_cost -= deduct +# usage_report["gift"] += deduct +# # آپدیت متغیر محلی برای محاسبات بعدی (اگر نیاز شد) +# my_gift = user.gift_credit + +# # اولویت ۲: کسر از اعتبار اصلی (Credit) +# if remaining_cost > 0 and my_credit > 0: +# deduct = min(my_credit, remaining_cost) +# user.credit = my_credit - deduct +# remaining_cost -= deduct +# usage_report["credit"] += deduct + +# # اولویت ۳: کسر از اعتبار هدیه (Free Credit) +# if remaining_cost > 0: +# user.free_credit = my_free - remaining_cost +# usage_report["free"] += remaining_cost +# remaining_cost = 0 + +# # ذخیره تغییرات کاربر در دیتابیس +# await user.save(using_db=conn) + +# # بازگرداندن نتیجه +# return True, None, final_user, bot, usage_report + +# async def validate_user_and_bot(id: int | None, bot_id: int, user_id: UUID) -> tuple: +# now = datetime.utcnow() +# one_hour_ago = now - timedelta(hours=1) + +# usage_report = { +# "gift": 0, +# "credit": 0, +# "free": 0 +# } + +# async with in_transaction() as conn: +# # دریافت کاربر و والد (اگر وجود داشته باشد) +# user_initial = await User.get_or_none(id=user_id).select_related("parent").using_db(conn) +# if user_initial is None: +# return ( +# False, +# {"error": True, "status_code": 404, "detail": "User not found"}, +# None, None, usage_report +# ) + +# # دریافت ربات +# bot = await Bot.get_or_none(id=bot_id).using_db(conn) +# if bot is None: +# return ( +# False, +# {"error": True, "status_code": 404, "detail": "Bot not found"}, +# None, None, usage_report +# ) + +# # محدودیت تعداد پیام در یک ساعت +# message_count = await Message.filter( +# chatbot_id=id, created_at__gte=one_hour_ago, created_at__lte=now +# ).using_db(conn).count() + +# if bot.limit > -1 and message_count >= bot.limit: +# return ( +# False, +# {"error": True, "status_code": 429, "detail": "Hourly limit exceeded"}, +# None, None, usage_report +# ) + +# cost = bot.cost +# remaining_cost = cost +# final_user = user_initial + +# # --------------------------------------------------------- +# # اگر کاربر خودش والد است +# # --------------------------------------------------------- +# if not user_initial.parent: +# gift_before = final_user.gift_credit or 0 +# deduct = min(gift_before, cost) + +# final_user.gift_credit = gift_before - deduct +# await final_user.save(using_db=conn) + +# # همگام‌سازی gift_credit با زیرمجموعه‌ها +# await User.filter(parent_id=final_user.id).using_db(conn).update(gift_credit=final_user.gift_credit) + +# usage_report["gift"] = deduct +# remaining_cost -= deduct + +# # --------------------------------------------------------- +# # اگر کاربر زیرمجموعه است +# # --------------------------------------------------------- +# elif user_initial.parent: +# parent = await User.filter(id=user_initial.parent.id).select_for_update().using_db(conn).first() +# if parent: +# parent_gift = parent.gift_credit or 0 +# deduct = min(parent_gift, cost) + +# # کسر هزینه از والد +# parent.gift_credit = parent_gift - cost +# await parent.save(using_db=conn) + +# # همگام‌سازی gift_credit والد با همه زیرمجموعه‌ها +# await User.filter(parent_id=parent.id).using_db(conn).update(gift_credit=parent.gift_credit) + +# usage_report["gift"] = deduct +# remaining_cost -= deduct + +# # --------------------------------------------------------- +# # پرداخت توسط خود کاربر اگر هنوز هزینه باقی مانده +# # --------------------------------------------------------- +# if remaining_cost > 0: +# user = await User.filter(id=user_id).select_for_update().using_db(conn).first() +# final_user = user + +# my_gift = user.gift_credit or 0 +# my_credit = user.credit or 0 +# my_free = user.free_credit or 0 + +# # تعیین دارایی قابل استفاده +# if user_initial.parent: +# available_assets = my_credit + my_free +# else: +# available_assets = my_gift + my_credit + my_free + +# if available_assets < remaining_cost: +# return ( +# False, +# {"error": True, "status_code": 403, "detail": "Insufficient credit"}, +# None, None, usage_report +# ) + +# # اولویت ۱: gift_credit (اگر کاربر مستقل) +# if not user_initial.parent and my_gift > 0 and remaining_cost > 0: +# deduct = min(my_gift, remaining_cost) +# user.gift_credit -= deduct +# remaining_cost -= deduct +# usage_report["gift"] += deduct +# my_gift = user.gift_credit + +# # اولویت ۲: credit اصلی +# if remaining_cost > 0 and my_credit > 0: +# deduct = min(my_credit, remaining_cost) +# user.credit -= deduct +# remaining_cost -= deduct +# usage_report["credit"] += deduct + +# # اولویت ۳: free_credit +# if remaining_cost > 0: +# user.free_credit -= remaining_cost +# usage_report["free"] += remaining_cost +# remaining_cost = 0 + +# # ذخیره تغییرات کاربر +# await user.save(using_db=conn) + +# return True, None, final_user, bot, usage_report + +async def validate_user_and_bot(id: int | None, bot_id: int, user_id: UUID4) -> tuple: + now = datetime.utcnow() + one_hour_ago = now - timedelta(hours=1) + + usage_report = { + "gift": 0, + "credit": 0, + "free": 0 + } + + async with in_transaction() as conn: + + # دریافت اطلاعات اولیه کاربر + user_initial = await User.get_or_none(id=user_id).select_related("parent").using_db(conn) + + if user_initial is None: + return (False, {"error": True, "status_code": 404, "detail": MESSAGES["user"]["not_found"]}, None, None, usage_report) + + bot = await Bot.get_or_none(id=bot_id).using_db(conn) + if bot is None: + return (False, {"error": True, "status_code": 404, "detail": "Bot not found"}, None, None, usage_report) + + # بررسی لیمیت + message_count = await Message.filter( + chatbot_id=id, created_at__gte=one_hour_ago, created_at__lte=now + ).using_db(conn).count() + + if bot.limit > -1 and message_count >= bot.limit: + return (False, {"error": True, "status_code": 429, "detail": MESSAGES["chat"]["limit"]}, None, None, usage_report) + + cost = bot.cost + remaining_cost = cost + paid_by_parent = False + final_user = user_initial + + # --------------------------------------------------------- + # بخش اول: کاربر زیرمجموعه است (Child) + # --------------------------------------------------------- + if user_initial.parent: + parent = await User.filter(id=user_initial.parent.id).select_for_update().using_db(conn).first() + + parent_gift = parent.gift_credit if parent and parent.gift_credit is not None else 0 + + if parent and parent_gift >= cost: + parent.gift_credit = parent_gift - cost + await parent.save(using_db=conn) + + # اصلاح شده: using_db قبل از update قرار گرفت + await User.filter(parent_id=parent.id).using_db(conn).update(gift_credit=parent.gift_credit) + + usage_report["gift"] = cost + remaining_cost = 0 + paid_by_parent = True + + final_user.gift_credit = parent.gift_credit + + # --------------------------------------------------------- + # بخش دوم: پرداخت توسط خود کاربر + # --------------------------------------------------------- + if not paid_by_parent: + user = await User.filter(id=user_id).select_for_update().using_db(conn).first() + + my_gift = user.gift_credit if user.gift_credit is not None else 0 + my_credit = user.credit if user.credit is not None else 0 + my_free = user.free_credit if user.free_credit is not None else 0 + + available_assets = 0 + if user_initial.parent: + available_assets = my_credit + my_free + else: + available_assets = my_gift + my_credit + my_free + + if available_assets < cost: + return (False, {"error": True, "status_code": 403, "detail": MESSAGES["chat"]["no_credit"]}, None, None, usage_report) + + # اولویت ۱: کسر از سکه باسا + gift_deducted_amount = 0 + if not user_initial.parent and my_gift > 0 and remaining_cost > 0: + gift_deducted_amount = min(my_gift, remaining_cost) + user.gift_credit = my_gift - gift_deducted_amount + remaining_cost -= gift_deducted_amount + usage_report["gift"] += gift_deducted_amount + + if gift_deducted_amount > 0: + # اصلاح شده: using_db قبل از update قرار گرفت + await User.filter(parent_id=user.id).using_db(conn).update(gift_credit=user.gift_credit) + + # اولویت ۲: کسر از اعتبار اصلی + if remaining_cost > 0 and my_credit > 0: + deduct = min(my_credit, remaining_cost) + user.credit = my_credit - deduct + remaining_cost -= deduct + usage_report["credit"] += deduct + + # اولویت ۳: کسر از اعتبار هدیه + if remaining_cost > 0: + user.free_credit = my_free - remaining_cost + usage_report["free"] += remaining_cost + remaining_cost = 0 + + await user.save(using_db=conn) + final_user = user + + return True, None, final_user, bot, usage_report \ No newline at end of file diff --git a/src/services/advertisement.py b/src/services/advertisement.py new file mode 100644 index 0000000..57dbd40 --- /dev/null +++ b/src/services/advertisement.py @@ -0,0 +1,50 @@ +from datetime import datetime, timedelta, timezone + +from uuid import UUID +import logging + +from src.models.advertisement import Advertisement +from src.models.user import User +from src.schemas.advertisement import AdEnum + +COOLDOWN_PERIOD = timedelta(hours=1) # todo: read from database +logger = logging.getLogger(__name__) + + +async def remaining(user_id: UUID) -> int: + last_video = await Advertisement.filter( + user_id=user_id, + ad_type=AdEnum.REWARDED_VIDEO.value + ).order_by("-timestamp").first() + + if not last_video: + return 0 # No video watched yet, so it's ready + + now = datetime.now(timezone.utc) + elapsed = now - last_video.timestamp + remaining = COOLDOWN_PERIOD - elapsed + + if remaining.total_seconds() <= 0: + return 0 # Cooldown passed + return int(remaining.total_seconds()) + +async def mark_seen(user_id: UUID, ad_type: str): + if ad_type == AdEnum.REWARDED_VIDEO: + r = await remaining(user_id) + if r > 0: + return None + + user: User = await User.get_or_none(id=user_id) + if user: + logger.info(f"adding credit to user {user.id} due to video advertisement") + user.free_credit += 2 + await user.save() + else: + logger.warning(f"user {user_id} not found") + return None + + return await Advertisement.create( + user_id=user_id, + ad_type=ad_type, + timestamp=datetime.now(timezone.utc) + ) \ No newline at end of file diff --git a/src/services/app_metadata.py b/src/services/app_metadata.py new file mode 100644 index 0000000..026420e --- /dev/null +++ b/src/services/app_metadata.py @@ -0,0 +1,26 @@ +from src.models import AppMetadata + + +async def findall(): + metadata_list = await AppMetadata.all() + return [ + {"tag": metadata.tag, "value": metadata.value} + for metadata in metadata_list + ] + +async def upsert(tag: str, value: str | None = None): + metadata, created = await AppMetadata.get_or_create( + tag=tag, + defaults={"value": value} + ) + # If the record existed and the value needs updating, save the change + if not created and metadata.value != value: + metadata.value = value + await metadata.save() + return {"tag": metadata.tag, "value": metadata.value} + +async def delete(tag: str): + deleted_count = await AppMetadata.filter(tag=tag).delete() + if deleted_count == 0: + raise ValueError(f"Metadata with tag '{tag}' not found") + return deleted_count diff --git a/src/services/auth.py b/src/services/auth.py new file mode 100644 index 0000000..66fcdf3 --- /dev/null +++ b/src/services/auth.py @@ -0,0 +1,119 @@ +import jwt +from uuid import UUID +from fastapi import HTTPException, status +from datetime import timedelta, datetime +from jwt.exceptions import InvalidTokenError +from ..config import settings +import logging + +logger = logging.getLogger(__name__) + +async def create_access_token(data: dict, expires_delta: timedelta | None = None): + to_encode = data.copy() + + if expires_delta: + expire = datetime.now() + expires_delta + else: + expire = datetime.now() + timedelta(days=15) + + to_encode.update({"exp": expire}) + + encoded_jwt = jwt.encode( + to_encode, settings.jwt_secret_key, algorithm=settings.jwt_algorithm + ) + + return encoded_jwt + + +async def verify_token(token: str) -> UUID: + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + payload = jwt.decode( + token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm] + ) + + user_id = payload.get("id") + + if user_id is None: + raise credentials_exception + + return UUID(user_id) + + except InvalidTokenError: + raise credentials_exception + +# async def verify_token(token: str) -> UUID: +# logger.info(f"[DEBUG] Verifying token: {token}") + +# credentials_exception = HTTPException( +# status_code=status.HTTP_401_UNAUTHORIZED, +# detail="Could not validate credentials", +# headers={"WWW-Authenticate": "Bearer"}, +# ) + +# try: +# payload = jwt.decode( +# token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm] +# ) +# logger.info(f"[DEBUG] Decoded JWT payload: {payload}") + +# user_id = payload.get("id") + +# if user_id is None: +# logger.warning("[WARNING] Token payload missing 'id' field") +# raise credentials_exception + +# logger.info(f"[DEBUG] User ID from token: {user_id}") +# return UUID(user_id) + +# except InvalidTokenError as e: +# logger.error(f"[ERROR] Invalid token: {str(e)}", exc_info=True) +# raise credentials_exception +# except Exception as e: +# logger.error(f"[ERROR] Unexpected error during token verification: {str(e)}", exc_info=True) +# raise credentials_exception + + +async def create_admin_access_token(data: dict, expires_delta: timedelta | None = None): + to_encode = data.copy() + + if expires_delta: + expire = datetime.now() + expires_delta + else: + expire = datetime.now() + timedelta(days=15) + + to_encode.update({"exp": expire}) + + encoded_jwt = jwt.encode( + to_encode, settings.jwt_admin_secret_key, algorithm=settings.jwt_algorithm + ) + + return encoded_jwt + + +async def verify_admin_token(token: str) -> UUID: + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + payload = jwt.decode( + token, settings.jwt_admin_secret_key, algorithms=[settings.jwt_algorithm] + ) + + user_id = payload.get("id") + + if user_id is None: + raise credentials_exception + + return UUID(user_id) + + except InvalidTokenError: + raise credentials_exception diff --git a/src/services/banner.py b/src/services/banner.py new file mode 100644 index 0000000..ff2fcb1 --- /dev/null +++ b/src/services/banner.py @@ -0,0 +1,5 @@ +from ..models import Banner + + +async def get_all(): + return await Banner.all().prefetch_related("bot", "bot__category").order_by("id") diff --git a/src/services/bot.py b/src/services/bot.py new file mode 100644 index 0000000..8b88b7f --- /dev/null +++ b/src/services/bot.py @@ -0,0 +1,728 @@ +import os +import math +from uuid import UUID +from tortoise.expressions import Q +from tortoise.queryset import Prefetch +from tortoise.functions import Count, Avg +from tortoise.transactions import in_transaction +from fastapi import HTTPException, UploadFile +from langchain_community.vectorstores.utils import filter_complex_metadata +from langchain_community.document_loaders import ( + WebBaseLoader, + PyPDFLoader, + Docx2txtLoader, + UnstructuredExcelLoader, + TextLoader, +) +from ..models import Bot, Mark, BotStatus, Rate, Message, Notification, User +from ..services import storage as storage_service, firebase +from ..services.chroma import chroma_service +from ..messages import MESSAGES + + +async def get_primary_bots(query: str | None): + bot_query = Bot.filter( + status=BotStatus.CONFIRMED, user_id=None, deleted=False + ).filter(Q(tool=False) | (Q(tool=True) & Q(cost=0))) + + if query is not None: + bot_query = bot_query.filter(model__icontains=query) + + return await bot_query.prefetch_related("category").order_by("order").all() + + +async def get_my_bots(user_id: UUID): + return ( + await Bot.filter(user_id=user_id, deleted=False) + .annotate(comments=Count("rates"), score=Avg("rates__score")) + .order_by("-updated_at") + .all() + ) + + +async def get_my_bot(id: int, user_id: UUID): + bot = await Bot.get_or_none(id=id, user_id=user_id, deleted=False).select_related( + "category" + ) + + if bot is None: + raise HTTPException(status_code=404, detail="Bot not found") + + return bot + + +async def get_other_bots(user_id: UUID, category: int | None, marked: bool | None): + query = Bot.filter( + tool=True, public=True, status=BotStatus.CONFIRMED, deleted=False + ).exclude(user_id=None) + + if category: + query = query.filter(category_id=category) + + if marked: + query = query.filter(marks__user_id=user_id) + + bots = await query.prefetch_related( + "category", + Prefetch("marks", queryset=Mark.filter(user_id=user_id), to_attr="user_marks"), + ).order_by("-created_at") + + categories = {} + for bot in bots: + category_name = bot.category.name if bot.category else "سایر" + if category_name not in categories: + categories[category_name] = [] + + categories[category_name].append( + { + **bot.__dict__, + "category": {"id": bot.category.id, "name": bot.category.name}, + "marked": len(bot.user_marks) > 0, + } + ) + + result = [ + {"category_name": category_name, "bots": bots} + for category_name, bots in categories.items() + ] + + return result + + +# async def get_bots_by_message_count(user_id: UUID, category: int | None, marked: bool | None): +# # Base query for bots with same filters as original +# query = Bot.filter( +# tool=True, public=True, status=BotStatus.CONFIRMED, deleted=False +# ).exclude(user_id=None) + +# if category: +# query = query.filter(category_id=category) + +# if marked: +# query = query.filter(marks__user_id=user_id) + +# # Get bots with message count annotation +# bots = await query.prefetch_related( +# "category", +# "chatbots__messages", # Prefetch messages through chatbots +# Prefetch("marks", queryset=Mark.filter(user_id=user_id), to_attr="user_marks"), +# ) + +# # Dictionary to store categories and their bots +# categories = {} + +# for bot in bots: +# category_name = bot.category.name if bot.category else "سایر" +# if category_name not in categories: +# categories[category_name] = [] + +# # Count messages for this bot +# message_count = sum(len(chatbot.messages) for chatbot in bot.chatbots) + +# categories[category_name].append( +# { +# **bot.__dict__, +# "category": {"id": bot.category.id, "name": bot.category.name} if bot.category else None, +# "marked": len(bot.user_marks) > 0, +# "messages": message_count, +# } +# ) + +# # Process categories to keep only top 5 bots by message count +# for category_name in categories: +# # Sort bots by message count in descending order +# categories[category_name].sort(key=lambda x: x["messages"], reverse=True) + +# # Format result +# result = [ +# {"category_name": category_name, "bots": bots} +# for category_name, bots in categories.items() +# ] + +# return result +# async def get_bots_by_message_count(user_id: UUID, category: int | None, marked: bool | None): +# print(f"\n========== DEBUG: get_bots_by_message_count START (User: {user_id}) ==========") + +# # 1. دریافت اطلاعات کاربر +# user = await User.get_or_none(id=user_id) +# user_level = int(user.level) if user and user.level else 1 + +# # لاگ لول کاربر +# print(f"[INFO] User Level: {user_level}") + +# # تعریف نقشه اولویت‌ها (Priority Map) +# priority_map = { +# 1: [1, 2, 3], # لول 1: اول 1، بعد 2، بعد 3 +# 2: [2, 1, 3], # لول 2: اول 2، بعد 1، بعد 3 +# 3: [3, 1, 2] # لول 3: اول 3، بعد 1، بعد 2 +# } + +# target_order = priority_map.get(user_level, [1, 2, 3]) +# print(f"[INFO] Target Priority Order: {target_order} (Rank 0 = No Level/All)") + +# # Base query +# query = Bot.filter( +# tool=True, public=True, status=BotStatus.CONFIRMED, deleted=False +# ).exclude(user_id=None) + +# if category: +# query = query.filter(category_id=category) + +# if marked: +# query = query.filter(marks__user_id=user_id) + +# # Get bots +# bots = await query.prefetch_related( +# "category", +# "chatbots__messages", +# Prefetch("marks", queryset=Mark.filter(user_id=user_id), to_attr="user_marks"), +# ) +# print(f"[INFO] Total bots fetched: {len(bots)}") + +# categories = {} + +# for bot in bots: +# category_name = bot.category.name if bot.category else "سایر" +# if category_name not in categories: +# categories[category_name] = [] + +# message_count = sum(len(chatbot.messages) for chatbot in bot.chatbots) + +# # دریافت لول‌های مجاز بات +# bot_levels = bot.allowed_levels if bot.allowed_levels else [] + +# # === محاسبه رتبه (Rank) === +# sort_rank = 999 # مقدار پیش‌فرض (ته لیست) + +# if not bot_levels or len(bot_levels) == 0: +# sort_rank = 0 # اولویت مطلق (نمایش برای همه) +# else: +# found = False +# for i, p_level in enumerate(target_order): +# if p_level in bot_levels: +# sort_rank = i + 1 # رنک 1، 2 یا 3 +# found = True +# break + +# if not found: +# sort_rank = 999 # اگر هیچکدام از اولویت‌ها مچ نشد + +# # لاگ دقیق برای هر بات +# # print(f" -> Bot: {bot.name} | Levels: {bot_levels} | Msg: {message_count} | Calculated Rank: {sort_rank}") + +# categories[category_name].append( +# { +# **bot.__dict__, +# "category": {"id": bot.category.id, "name": bot.category.name} if bot.category else None, +# "marked": len(bot.user_marks) > 0, +# "messages": message_count, +# "sort_rank": sort_rank, +# } +# ) + +# # 3. سورت کردن و نمایش نتیجه نهایی +# result = [] + +# for category_name, bots_list in categories.items(): +# print(f"\n--- Sorting Category: {category_name} ---") + +# # قبل از سورت (اختیاری) +# # print(" [Before Sort] " + ", ".join([f"{b['name']}(R:{b['sort_rank']})" for b in bots_list])) + +# bots_list.sort( +# key=lambda x: (x["sort_rank"], -x["messages"]) +# ) + +# # لاگ بعد از سورت (نمایش ترتیب نهایی) +# print(" [FINAL ORDER]:") +# for idx, b in enumerate(bots_list): +# print(f" {idx+1}. {b.get('name')} (Rank: {b['sort_rank']}, Lvl: {b.get('allowed_levels')}, Msgs: {b['messages']})") + +# # حذف فیلد کمکی sort_rank قبل از ارسال به کلاینت (اختیاری) +# # for b in bots_list: +# # del b["sort_rank"] + +# result.append({"category_name": category_name, "bots": bots_list}) + +# print("\n========== DEBUG: get_bots_by_message_count END ==========\n") +# return result + +async def get_bots_by_message_count(user_id: UUID, category: int | None, marked: bool | None): + print(f"\n========== DEBUG: get_bots_by_message_count START (User: {user_id}) ==========") + + # 1. دریافت اطلاعات کاربر + user = await User.get_or_none(id=user_id) + user_level = int(user.level) if user and user.level else 1 + + print(f"[INFO] User Level: {user_level}") + + priority_map = { + 1: [1, 2, 3], + 2: [2, 1, 3], + 3: [3, 1, 2] + } + + target_order = priority_map.get(user_level, [1, 2, 3]) + print(f"[INFO] Target Priority Order: {target_order}") + + # ====================================================== + # تغییر اصلی اینجاست (اضافه کردن فیلترهای جدول Category) + # ====================================================== + query = Bot.filter( + tool=True, + public=True, + status=BotStatus.CONFIRMED, + deleted=False, + # فیلتر کردن بر اساس فیلدهای جدول Category + category__tool=False, # دسته بندی ابزار نباشد + category__character=False, # دسته بندی کاراکتر نباشد + category__media=False # دسته بندی مدیا نباشد + ).exclude(user_id=None) + + # سایر فیلترهای ورودی + if category: + query = query.filter(category_id=category) + + if marked: + query = query.filter(marks__user_id=user_id) + + # دریافت بات‌ها + bots = await query.prefetch_related( + "category", + "chatbots__messages", + Prefetch("marks", queryset=Mark.filter(user_id=user_id), to_attr="user_marks"), + ) + print(f"[INFO] Total bots fetched (Filtered): {len(bots)}") + + categories = {} + + for bot in bots: + category_name = bot.category.name if bot.category else "سایر" + if category_name not in categories: + categories[category_name] = [] + + message_count = sum(len(chatbot.messages) for chatbot in bot.chatbots) + + bot_levels = bot.allowed_levels if bot.allowed_levels else [] + + # === محاسبه رتبه (Rank) === + sort_rank = 999 + + if not bot_levels or len(bot_levels) == 0: + sort_rank = 0 + else: + found = False + for i, p_level in enumerate(target_order): + if p_level in bot_levels: + sort_rank = i + 1 + found = True + break + + if not found: + sort_rank = 999 + + categories[category_name].append( + { + **bot.__dict__, + "category": {"id": bot.category.id, "name": bot.category.name} if bot.category else None, + "marked": len(bot.user_marks) > 0, + "messages": message_count, + "sort_rank": sort_rank, + } + ) + + # مرتب‌سازی نهایی + result = [] + + for category_name, bots_list in categories.items(): + print(f"\n--- Sorting Category: {category_name} ---") + + bots_list.sort( + key=lambda x: (x["sort_rank"], -x["messages"]) + ) + + print(" [FINAL ORDER]:") + for idx, b in enumerate(bots_list): + print(f" {idx+1}. {b.get('name')} (Rank: {b['sort_rank']}, Lvl: {b.get('allowed_levels')}, Msgs: {b['messages']})") + + result.append({"category_name": category_name, "bots": bots_list}) + + print("\n========== DEBUG: get_bots_by_message_count END ==========\n") + return result + +async def get_bot(id: int, user_id: UUID): + bot = ( + await Bot.get_or_none(id=id, status=BotStatus.CONFIRMED, deleted=False) + .annotate( + comments=Count("rates", distinct=True), + score=Avg("rates__score"), + users=Count("chatbots__user_id", distinct=True), + messages=Count("chatbots__messages"), + ) + .prefetch_related("user", "category") + ) + + rate = await Rate.get_or_none(user_id=user_id, bot_id=id) + + return { + **bot.__dict__, + "user": bot.user, + "category": bot.category, + "user_comment": rate, + } + + +async def mark(bot_id: int, user_id: UUID, marked: bool): + if marked: + await Mark.create(bot_id=bot_id, user_id=user_id) + else: + await Mark.filter(bot_id=bot_id, user_id=user_id).delete() + + +async def get_comments(id: int, page: int): + limit = 10 + offset = (page - 1) * limit + + comments = ( + await Rate.filter(bot_id=id) + .select_related("user") + .order_by("-created_at") + .offset(offset) + .limit(limit) + ) + + total_count = await Rate.filter(bot_id=id).count() + + return { + "comments": comments, + "page": page, + "total_count": total_count, + "last_page": math.ceil(total_count / limit), + } + + +async def send_comment(bot_id: int, user_id: UUID, score: float, text: str): + messages_count = await Message.filter( + chatbot__user_id=user_id, chatbot__bot_id=bot_id + ).count() + + if messages_count == 0: + raise HTTPException(status_code=403, detail=MESSAGES["comment"]["not_allow"]) + + await Rate.update_or_create( + defaults={"score": score, "text": text}, + user_id=user_id, + bot_id=bot_id, + ) + + +async def create_bot( + bot_id: int, + user_id: UUID, + category_id: int, + name: str, + description: str, + prompt: str, + public: bool, + links: list[str], + docs: list[[str, str]], +): + bot: Bot = await Bot.get_or_none(id=bot_id) + + if bot is None: + raise HTTPException(status_code=404, detail="Bot not found") + + async with in_transaction(): + user_bot: Bot = await Bot.create( + name=name, + model=bot.model, + prompt=prompt, + description=description, + attachment=bot.attachment, + attachment_type=bot.attachment_type, + tool=True, + public=public, + cost=bot.cost, + limit=bot.limit, + status=BotStatus.PENDING, + user_id=user_id, + category_id=category_id, + docs=[doc[0] for doc in docs], + links=links, + ) + + collection_name = f"user-{user_id}-bot-{user_bot.id}" + + for doc in docs: + loader = None + if ".pdf" in doc[0]: + loader = PyPDFLoader(doc[1]) + + elif ".docx" in doc[0]: + loader = Docx2txtLoader(doc[1]) + + elif ".xlsx" in doc[0] or ".xls" in doc[0]: + loader = UnstructuredExcelLoader(doc[1], mode="elements") + + elif ".txt" in doc[0]: + loader = TextLoader(doc[1]) + + if loader: + pages = [] + async for page in loader.alazy_load(): + pages.append(page) + + pages = filter_complex_metadata(pages) + await chroma_service.write(pages, collection_name) + + if os.path.exists(doc[1]): + os.remove(doc[1]) + + if len(links) > 0: + loader = WebBaseLoader(links) + + pages = [] + async for doc in loader.alazy_load(): + pages.append(doc) + + await chroma_service.write(pages, collection_name) + + +async def update_allowed_levels(bot_id: int, user_id: UUID, allowed_levels: list[int]): + # دریافت بات و چک کردن مالکیت آن + bot = await Bot.get_or_none(id=bot_id, user_id=user_id) + + if bot is None: + raise HTTPException(status_code=404, detail="Bot not found") + + # آپدیت فیلد سطوح + bot.allowed_levels = allowed_levels + await bot.save() + + +async def edit_bot( + id: int, + bot_id: int, + user_id: UUID, + category_id: int, + name: str, + description: str, + prompt: str, + public: bool, + links: list[str], + docs: list[[str, str]], +): + bot: Bot = await Bot.get_or_none(id=bot_id) + user_bot: Bot = await Bot.get_or_none(id=id, user_id=user_id) + + if bot is None or user_bot is None: + raise HTTPException(status_code=404, detail="Bot not found") + + for doc in user_bot.docs: + await storage_service.delete(doc) + + user_bot.update_from_dict( + { + "name": name, + "model": bot.model, + "prompt": prompt, + "description": description, + "attachment": bot.attachment, + "attachment_type": bot.attachment_type, + "public": public, + "cost": bot.cost, + + "limit": bot.limit, + "status": BotStatus.PENDING, + "category_id": category_id, + "docs": [doc[0] for doc in docs], + "links": links, + } + ) + + collection_name = f"user-{user_id}-bot-{user_bot.id}" + chroma_service.delete(collection_name) + + for doc in docs: + loader = None + if ".pdf" in doc[0]: + loader = PyPDFLoader(doc[1]) + + elif ".docx" in doc[0]: + loader = Docx2txtLoader(doc[1]) + + elif ".xlsx" in doc[0] or ".xls" in doc[0]: + loader = UnstructuredExcelLoader(doc[1], mode="elements") + + elif ".txt" in doc[0]: + loader = TextLoader(doc[1]) + + if loader: + pages = [] + async for page in loader.alazy_load(): + pages.append(page) + + pages = filter_complex_metadata(pages) + await chroma_service.write(pages, collection_name) + + if os.path.exists(doc[1]): + os.remove(doc[1]) + + if len(links) > 0: + loader = WebBaseLoader(links) + + pages = [] + async for doc in loader.alazy_load(): + pages.append(doc) + + await chroma_service.write(pages, collection_name) + + await user_bot.save() + + +async def delete_bot( + id: int, + user_id: UUID, +): + bot: Bot = await Bot.get_or_none(id=id, user_id=user_id) + + if bot is None: + raise HTTPException(status_code=404, detail="Bot not found") + + if bot.image: + await storage_service.delete(bot.image) + + for doc in bot.docs: + await storage_service.delete(doc) + + collection_name = f"user-{user_id}-bot-{bot.id}" + chroma_service.delete(collection_name) + + bot.image = None + bot.docs = None + bot.links = None + bot.deleted = True + await bot.save() + + +##### Admin Services ##### + + +async def get_assistants( + category: int | None, status: BotStatus, page: int, q: str | None +): + limit = 10 + offset = (page - 1) * limit + + query = Bot.filter(tool=True, status=status).exclude(user_id=None) + + if category: + query = query.filter(category_id=category) + + if q: + query = query.filter(name__icontains=q) + + total_count = await query.count() + assistants = ( + await query.select_related("category") + .order_by("-updated_at") + .offset(offset) + .limit(limit) + .all() + ) + + return { + "assistants": assistants, + "page": page, + "total_count": total_count, + "last_page": math.ceil(total_count / limit), + } + + +async def get_assistant(id: int): + return await Bot.get(tool=True, id=id).select_related("user", "category") + + +async def confirm_assistant( + id: int, + cost: int, + message: str, + allowed_levels: list[int] | None = None + ): + assistant: Bot = await Bot.get(tool=True, id=id) + user: User = await User.get(id=assistant.user_id) + + print(f"------------ DEBUG ------------") + print(f"Function confirm_assistant called!") + print(f"Assistant ID: {id}") + print(f"Received allowed_levels: {allowed_levels}") + print(f"Type of allowed_levels: {type(allowed_levels)}") + print(f"-------------------------------") + + assistant.cost = cost + assistant.status = BotStatus.CONFIRMED + if allowed_levels: + assistant.allowed_levels = allowed_levels + await assistant.save() + + + + title = f"نتیجه بررسی دستیار {assistant.name}" + + if user.firebase_token: + await firebase.send_notification( + token=user.firebase_token, + title=title, + body=f"دستیار شما با نام {assistant.name} ارزیابی شد", + ) + + await Notification.create( + title=title, + message=message if message else f"دستیار شما با نام {assistant.name} تایید شد", + user_id=assistant.user_id, + ) + + +async def reject_assistant(id: int, message: str): + assistant: Bot = await Bot.get(tool=True, id=id) + user: User = await User.get(id=assistant.user_id) + + assistant.status = BotStatus.REJECTED + await assistant.save() + + if message: + title = f"نتیجه بررسی دستیار {assistant.name}" + + if user.firebase_token: + await firebase.send_notification( + token=user.firebase_token, + title=title, + body=f"دستیار شما با نام {assistant.name} ارزیابی شد", + ) + + await Notification.create( + title=title, + message=message, + user_id=assistant.user_id, + ) + + +async def edit_image(id: int, file: UploadFile): + bot: Bot = await Bot.get(tool=True, id=id) + + if bot.image: + await storage_service.delete(bot.image) + + url = await storage_service.upload(prefix="bot", id=id, file=file) + + bot.image = url + await bot.save() + + return url + +async def delete_bot(id: int): + # دریافت بات + bot = await Bot.get(id=id) + + # حذف کامل رکورد از دیتابیس + await bot.delete() \ No newline at end of file diff --git a/src/services/category.py b/src/services/category.py new file mode 100644 index 0000000..b194ada --- /dev/null +++ b/src/services/category.py @@ -0,0 +1,77 @@ +from tortoise.queryset import Prefetch +from ..models import Category, Bot + + +async def get_categories(): + return await Category.filter(tool=False).all() + + +async def get_tool_categories(): + return ( + await Category.filter(id__in=[5, 6, 7, 8, 14]) + .prefetch_related( + Prefetch( + "bots", + queryset=Bot.filter(deleted=False) + .all() + .prefetch_related("category") + .order_by("order"), + ) + ) + .all() + ) + + +async def get_character_categories(): + return ( + await Category.filter(tool=True, character=True) + .prefetch_related( + Prefetch( + "bots", + queryset=Bot.filter(deleted=False) + .all() + .prefetch_related("category") + .order_by("order"), + ) + ) + .all() + ) + + +async def get_media_categories(): + return await Category.filter(tool=True, media=True, parent_category=None).all() + + +async def get_media_category(id: str): + return ( + await Category.filter( + tool=True, parent_category__media=True, parent_category_id=id + ) + .prefetch_related( + Prefetch( + "bots", + queryset=Bot.filter(deleted=False) + .all() + .prefetch_related("category") + .order_by("order"), + ) + ) + .all() + .order_by("id") + ) + + +async def get_tool_categories_v2(): + return ( + await Category.filter(id__in=[8, 31, 32]) + .prefetch_related( + Prefetch( + "bots", + queryset=Bot.filter(deleted=False) + .all() + .prefetch_related("category") + .order_by("order"), + ) + ) + .all() + ) diff --git a/src/services/chatbot.py b/src/services/chatbot.py new file mode 100644 index 0000000..1c7b133 --- /dev/null +++ b/src/services/chatbot.py @@ -0,0 +1,723 @@ +import os +import json +import math +from uuid import UUID +from datetime import date, datetime, timedelta, UTC +from fastapi import HTTPException +from tortoise.expressions import Q +from langchain_openai import ChatOpenAI +from langchain_anthropic import ChatAnthropic +from langchain_google_genai import ChatGoogleGenerativeAI +from langgraph.graph import START, END, MessagesState, StateGraph +from langchain_core.tools import tool +from langchain_community.tools import DuckDuckGoSearchResults +from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver +from langchain_community.document_loaders.blob_loaders import Blob +from langchain_community.document_loaders.parsers import OpenAIWhisperParser +from langchain_community.document_loaders import ( + PyPDFLoader, + Docx2txtLoader, + UnstructuredExcelLoader, + TextLoader, +) +from langchain_core.messages import ( + HumanMessage, + AIMessage, + SystemMessage, + trim_messages, + RemoveMessage, + ToolMessage, +) + +from .database.service import DatabaseService +from ..config import settings +from ..models import Chatbot, Message, BotStatus, BotType +from ..messages import MESSAGES +from ._utils.get_or_create_chatbot import get_or_create_chatbot +from ._utils.validate_user_and_bot import validate_user_and_bot + +connection_kwargs = { + "autocommit": False, + "prepare_threshold": 0, +} + + +@tool +async def web_search_tool(query: str) -> str: + """Performs a web search for the given query.""" + + search = DuckDuckGoSearchResults() + result = await search.arun(query) + + return result + + +def get_model(model: str): + if "claude" in model: + # return ChatAnthropic(model=model, api_key=settings.anthropic_api_key) + return ChatOpenAI( + model=model, + api_key=settings.liara_api_key, + base_url=settings.liara_api_url + ) + + if model in ["gpt-4o", "gpt-4o-mini","openai/gpt-4o-mini"]: + return ChatOpenAI(model=model, api_key=settings.openai_api_key) + # return ChatOpenAI( + # model=model, + # api_key=settings.liara_api_key, + # base_url=settings.liara_api_url + # ) + + + if model in ["GPT-5.2", "openai/gpt-5-mini"]: + return ChatOpenAI( + model=model, + api_key=settings.liara_api_key, + base_url=settings.liara_api_url + ) + + if model in ["Grok-3","Grok-4-Fast","x-ai/grok-3-beta","x-ai/grok-4-fast","anthropic/claude-3.5-sonnet","x-ai/grok-4","anthropic/claude-sonnet-4"]: + return ChatOpenAI( + model=model, + api_key=settings.liara_api_key, + base_url=settings.liara_api_url + ) + + if model in ["gemini","google/gemini-3-pro-preview"]: + return ChatGoogleGenerativeAI( + model=model, google_api_key=settings.google_api_key + ) + # return ChatOpenAI( + # model=model, + # api_key=settings.liara_api_key, + # base_url=settings.liara_api_url + # ) + + if "deepseek" in model: + return ChatOpenAI( + model=model, + api_key=settings.deepseek_api_key, + base_url="https://api.deepseek.com", + ) + + + + +def document_loader(content, query: str, doc: [str | None, str | None]): + loader = None + if ".pdf" in doc[0]: + loader = PyPDFLoader(doc[1]) + + elif ".docx" in doc[0]: + loader = Docx2txtLoader(doc[1]) + + elif ".xlsx" in doc[0] or ".xls" in doc[0]: + loader = UnstructuredExcelLoader(doc[1], mode="elements") + + elif ".txt" in doc[0]: + loader = TextLoader(doc[1]) + + if loader: + pages = [] + for page in loader.lazy_load(): + pages.append(page.page_content) + + content[0]["text"] = ( + f"attached file: {' '.join(pages)}\n\n\nquery: {query}" + ) + + content[0]["pdf_url"] = { + "url": doc[0], + "attachment": True, + "query": query, + } + + if os.path.exists(doc[1]): + os.remove(doc[1]) + + return content + +def audio_loader(content, query: str, audio: [str | None, str | None]): + parser = OpenAIWhisperParser() + + audio_text = "" + for document in parser.lazy_parse(Blob.from_path(audio[1])): + audio_text += document.page_content + + if query is None: + content[0]["text"] = audio_text + content[0]["audio_url"] = { + "url": audio[0], + "attachment": False, + } + else: + content[0]["text"] = ( + f"attached audio: {audio_text}\n\n\nquery: {query}" + ) + content[0]["audio_url"] = { + "url": audio[0], + "attachment": True, + "query": query, + } + + if os.path.exists(audio[1]): + os.remove(audio[1]) + + return content + +def chatbot_workflow(bot, now): + + print(f"[get_model] Using model: {bot}") + model = get_model(model=bot.model) + print(f"[get_model] Using model: {model}") + trimmer = get_trimmer() + + if bot.web_search: + model = model.bind_tools([web_search_tool]) + + async def call_model(state: MessagesState): + kwargs = {} + if "gemini" not in bot.model: + kwargs["stream_usage"] = True + + trimmed_messages = await trimmer.ainvoke(state["messages"]) + input_messages = [ + SystemMessage( + f"Today: {now.isoformat()}." + "You are an AI assistant designed to answer user queries conversationally, using tools when necessary." + "Follow these instructions carefully: " + "1. **Mathematical Formatting**: When responding to questions involving mathematics, format all mathematical expressions and formulas using Markdown with LaTeX notation for clarity and readability. Use inline LaTeX (e.g., `$x^2 + 2x + 1$`) for expressions within a sentence, and display LaTeX (e.g., `$$x^2 + 2x + 1 = 0$$`) for standalone equations or complex formulas. Ensure the formatting is compatible with Markdown renderers that support LaTeX." + f"{bot.prompt if bot.web_search else ''}" + "Follow these guidelines to ensure a seamless and informative conversation with the user." + ) + ] + trimmed_messages + + response = await model.ainvoke(input=input_messages, **kwargs) + + ai_msg_created_at = datetime.now(UTC).isoformat() + response.additional_kwargs["created_at"] = ai_msg_created_at + + return {"messages": [response]} + + async def tool_node(state: MessagesState): + last_message = state["messages"][-1] + messages = [] + + for tool_call in last_message.tool_calls: + if tool_call["name"] == "web_search_tool": + result = await web_search_tool.ainvoke( + tool_call["args"]["query"] + ) + + messages.append( + ToolMessage( + content=result, tool_call_id=tool_call["id"] + ) + ) + + return {"messages": messages} + + workflow = StateGraph(state_schema=MessagesState) + + workflow.add_node("model", call_model) + workflow.add_node("tool", tool_node) + + workflow.add_edge(START, "model") + workflow.add_conditional_edges( + "model", + lambda state: "tool" + if isinstance(state["messages"][-1], AIMessage) and state["messages"][-1].tool_calls + else "end", + {"tool": "tool", "end": END}, + ) + workflow.add_edge("tool", "model") + + return workflow + + +# async def send_message( +# id: int | None, +# query: str, +# bot_id: int, +# user_id: UUID, +# retry: bool, +# ghost: bool, +# image: str | None, +# audio: [str | None, str | None], +# doc: [str | None, str | None], +# ): +# db_service = DatabaseService(conninfo=settings.database_url, kwargs=connection_kwargs) +# pool = await db_service.get_pool() + +# now = datetime.now(UTC) +# chat_user_id = user_id +# human_msg_created_at = now.isoformat() +# content = [{"type": "text", "text": query}] + +# # --- اصلاح شده: دریافت usage_report --- +# success, error, user, bot, usage_report = await validate_user_and_bot(id=id, bot_id=bot_id, user_id=user_id) +# print("Usage report: ------ ", usage_report) +# if not success: +# yield json.dumps(error) +# return + +# if image: +# content.append({"type": "image_url", "image_url": {"url": image}}) +# if audio[0]: +# content = audio_loader(content, query=query, audio=audio) +# if doc[0]: +# content = document_loader(content, query=query, doc=doc) + +# title = await generate_title(content[0]["text"]) if id is None else None +# try: +# chatbot = await get_or_create_chatbot(id, bot_id, chat_user_id, title) +# except ValueError as e: +# yield str(e) +# return + +# workflow = chatbot_workflow(bot, now) +# checkpointer = AsyncPostgresSaver(pool) +# try: +# await checkpointer.setup() +# except Exception as e: +# yield json.dumps({"error": True, "detail": f"Failed to setup checkpointer: {str(e)}"}, ensure_ascii=False) +# return + +# app = workflow.compile(checkpointer=checkpointer) +# app.checkpointer = checkpointer +# config = {"configurable": {"thread_id": str(chatbot.id)}} + +# if retry: +# state = await app.aget_state(config) +# messages = state.values.get("messages") +# if messages: +# last_human_index = max(i for i, msg in enumerate(messages) if isinstance(msg, HumanMessage)) +# await app.aupdate_state( +# config, +# {"messages": [RemoveMessage(id=msg.id) for msg in messages[last_human_index + 1:]]}, +# ) + +# yield json.dumps({"chat_id": chatbot.id, "chat_title": chatbot.title}, ensure_ascii=False) + +# input_tokens = 0 +# output_tokens = 0 +# human_msg = HumanMessage(content=content) +# human_msg.additional_kwargs["created_at"] = human_msg_created_at +# input_messages = [human_msg] + +# async for chunk, metadata in app.astream({"messages": input_messages}, config, stream_mode="messages"): +# if isinstance(chunk, AIMessage): +# if chunk.usage_metadata: +# input_tokens += chunk.usage_metadata["input_tokens"] +# output_tokens += chunk.usage_metadata["output_tokens"] +# yield json.dumps({"content": chunk.content}, ensure_ascii=False) + +# await user.save() + +# # --- اصلاح شده: ذخیره ریز مصرف در جدول Message --- +# await Message.create( +# input_tokens=input_tokens, +# output_tokens=output_tokens, +# chatbot_id=chatbot.id, +# cost=bot.cost, +# cost_from_gift=usage_report["gift"], +# cost_from_credit=usage_report["credit"], +# cost_from_free=usage_report["free"], +# ) + +# state = await app.aget_state(config) +# messages = state.values["messages"] +# human_msg_id, ai_msg_id = get_message_id(messages) + +# yield json.dumps({ +# "credit": user.credit, +# "free": user.free_credit, +# "gift": user.gift_credit if user.gift_credit is not None else 0, # ارسال گیفت به فرانت +# "ai_message_id": ai_msg_id, +# "human_message_id": human_msg_id, +# "ai_message_created_at": messages[-1].additional_kwargs.get("created_at"), +# "human_message_created_at": human_msg_created_at, +# }) + +async def send_message( + id: int | None, + query: str, + bot_id: int, + user_id: UUID, + retry: bool, + ghost: bool, + image: str | None, + audio: [str | None, str | None], + doc: [str | None, str | None], +): + db_service = DatabaseService(conninfo=settings.database_url, kwargs=connection_kwargs) + pool = await db_service.get_pool() + + now = datetime.now(UTC) + + # تغییر ۱: همیشه از شناسه واقعی کاربر استفاده می‌کنیم (حتی در حالت روح) + chat_user_id = user_id + + human_msg_created_at = now.isoformat() + content = [{"type": "text", "text": query}] + + success, error, user, bot, usage_report = await validate_user_and_bot(id=id, bot_id=bot_id, user_id=user_id) + print("Usage report: ------ ", usage_report) + if not success: + yield json.dumps(error) + return + + if image: + content.append({"type": "image_url", "image_url": {"url": image}}) + if audio[0]: + content = audio_loader(content, query=query, audio=audio) + if doc[0]: + content = document_loader(content, query=query, doc=doc) + + title = await generate_title(content[0]["text"]) if id is None else None + + try: + # تغییر ۲: پاس دادن مقدار ghost به تابع سازنده چت + chatbot = await get_or_create_chatbot( + id=id, + bot_id=bot_id, + user_id=chat_user_id, + title=title, + is_ghost=ghost + ) + except ValueError as e: + yield str(e) + return + + workflow = chatbot_workflow(bot, now) + checkpointer = AsyncPostgresSaver(pool) + try: + await checkpointer.setup() + except Exception as e: + yield json.dumps({"error": True, "detail": f"Failed to setup checkpointer: {str(e)}"}, ensure_ascii=False) + return + + app = workflow.compile(checkpointer=checkpointer) + app.checkpointer = checkpointer + config = {"configurable": {"thread_id": str(chatbot.id)}} + + if retry: + state = await app.aget_state(config) + messages = state.values.get("messages") + if messages: + last_human_index = max(i for i, msg in enumerate(messages) if isinstance(msg, HumanMessage)) + await app.aupdate_state( + config, + {"messages": [RemoveMessage(id=msg.id) for msg in messages[last_human_index + 1:]]}, + ) + + yield json.dumps({"chat_id": chatbot.id, "chat_title": chatbot.title}, ensure_ascii=False) + + input_tokens = 0 + output_tokens = 0 + human_msg = HumanMessage(content=content) + human_msg.additional_kwargs["created_at"] = human_msg_created_at + input_messages = [human_msg] + + async for chunk, metadata in app.astream({"messages": input_messages}, config, stream_mode="messages"): + if isinstance(chunk, AIMessage): + if chunk.usage_metadata: + input_tokens += chunk.usage_metadata["input_tokens"] + output_tokens += chunk.usage_metadata["output_tokens"] + yield json.dumps({"content": chunk.content}, ensure_ascii=False) + + await user.save() + + # تغییر ۳: ذخیره payer_id برای گزارش‌گیری دقیق مالی + await Message.create( + input_tokens=input_tokens, + output_tokens=output_tokens, + chatbot_id=chatbot.id, + cost=bot.cost, + cost_from_gift=usage_report["gift"], + cost_from_credit=usage_report["credit"], + cost_from_free=usage_report["free"], + ) + + state = await app.aget_state(config) + messages = state.values["messages"] + human_msg_id, ai_msg_id = get_message_id(messages) + + yield json.dumps({ + "credit": user.credit, + "free": user.free_credit, + "gift": user.gift_credit if user.gift_credit is not None else 0, + "ai_message_id": ai_msg_id, + "human_message_id": human_msg_id, + "ai_message_created_at": messages[-1].additional_kwargs.get("created_at"), + "human_message_created_at": human_msg_created_at, + }) + + +async def get_chats( + user_id: UUID, + query: str | None, + archive: bool, + date: date | None, + page: int, + type: BotType | None = None, +): + limit = 20 + offset = (page - 1) * limit + filters = Q(archive=archive, user_id=user_id, bot__status=BotStatus.CONFIRMED, is_deleted=False,is_ghost=False) + + if query is not None: + filters &= Q(title__icontains=query) + + if date is not None: + start_date = datetime.combine(date, datetime.min.time()) + start_date -= timedelta(hours=3, minutes=30) + filters &= Q(created_at__gte=start_date, created_at__lte=start_date + timedelta(days=1)) + + if type is not None: + filters &= Q(bot__type=type) + + total_count = await Chatbot.filter(filters).count() + chats = ( + await Chatbot.filter(filters) + .prefetch_related("bot", "bot__category") + .order_by("-created_at") + .offset(offset) + .limit(limit) + ) + + return { + "chats": chats, + "page": page, + "total_count": total_count, + "last_page": math.ceil(total_count / limit), + } + + +async def get_chat(id: int, user_id: UUID): + print(f"=== get_chat START | id: {id}, user_id: {user_id} ===") + + db_service = DatabaseService(conninfo=settings.database_url, kwargs=connection_kwargs) + pool = await db_service.get_pool() + print("[1] DB Pool initialized") + + try: + print(f"[2] Fetching Chatbot with id={id} and user_id={user_id}...") + chatbot: Chatbot = await Chatbot.get_or_none( + id=id, user_id=user_id, bot__status=BotStatus.CONFIRMED, is_deleted=False + ).prefetch_related("bot", "bot__category") + + if chatbot is None: + print(f"❌ [Error] Chatbot not found for id={id}") + raise HTTPException(404, detail=MESSAGES["chat"]["not_found"]) + + print(f"✅ [3] Chatbot found: {chatbot.id}") + + checkpointer = AsyncPostgresSaver(pool) + await checkpointer.setup() + print("[4] Checkpointer setup done") + + config = {"configurable": {"thread_id": str(id)}} + print(f"[5] Fetching history with config: {config}") + + history = await checkpointer.aget_tuple(config) + print(f"[6] History retrieved (checkpoint found: {history is not None})") + if history: + # چاپ جزئیات بیشتر در صورت نیاز (مثلاً آخرین پیام) + print(f" History metadata: {history.metadata if hasattr(history, 'metadata') else 'N/A'}") + + print("=== get_chat END (Success) ===") + return history, chatbot + + except HTTPException as e: + print(f"⚠️ [Handled Exception] HTTP {e.status_code}: {e.detail}") + raise e + except Exception as e: + print(f"❌ [Critical Error] in get_chat: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + +async def delete_chat(id: int, user_id: UUID): + db_service = DatabaseService(conninfo=settings.database_url, kwargs=connection_kwargs) + pool = await db_service.get_pool() + + try: + chatbot: Chatbot = await Chatbot.get_or_none(id=id, user_id=user_id) + + if chatbot is None: + raise HTTPException(404, detail=MESSAGES["chat"]["not_found"]) + + checkpointer = AsyncPostgresSaver(pool) + await checkpointer.setup() + + config = {"configurable": {"thread_id": str(id)}} + workflow = StateGraph(state_schema=MessagesState) + app = workflow.compile(checkpointer=checkpointer) + + state = await app.aget_state(config) + messages = state.values["messages"] + + await app.aupdate_state( + config, + {"messages": [RemoveMessage(id=message.id) for message in messages]}, + ) + + + # await chatbot.delete() + chatbot.is_deleted = True + await chatbot.save() + await Message.filter(chatbot_id=chatbot.id).update(is_deleted=True) + + except HTTPException as e: + raise e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +async def delete_chats(user_id: UUID, archive: bool): + db_service = DatabaseService(conninfo=settings.database_url, kwargs=connection_kwargs) + pool = await db_service.get_pool() + + try: + chatbots: list[Chatbot] = await Chatbot.filter(user_id=user_id, archive=archive).all() + + checkpointer = AsyncPostgresSaver(pool) + await checkpointer.setup() + + for chatbot in chatbots: + config = {"configurable": {"thread_id": str(chatbot.id)}} + workflow = StateGraph(state_schema=MessagesState) + app = workflow.compile(checkpointer=checkpointer) + + state = await app.aget_state(config) + messages = state.values.get("messages", None) + + if messages: + await app.aupdate_state( + config, + {"messages": [RemoveMessage(id=message.id) for message in messages]}, + ) + + # await chatbot.delete() + chatbot.is_deleted = True + await chatbot.save() + await Message.filter(chatbot_id=chatbot.id).update(is_deleted=True) + + except HTTPException as e: + raise e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +async def delete_message(id: int, message_id: str, user_id: UUID): + db_service = DatabaseService(conninfo=settings.database_url, kwargs=connection_kwargs) + pool = await db_service.get_pool() + + try: + chatbot: Chatbot = await Chatbot.get_or_none(id=id, user_id=user_id) + + if chatbot is None: + raise HTTPException(404, detail=MESSAGES["chat"]["not_found"]) + + checkpointer = AsyncPostgresSaver(pool) + await checkpointer.setup() + + config = {"configurable": {"thread_id": str(id)}} + workflow = StateGraph(state_schema=MessagesState) + app = workflow.compile(checkpointer=checkpointer) + + await app.aupdate_state(config, {"messages": RemoveMessage(id=message_id)}) + + except HTTPException as e: + raise e + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +async def like_message(id: int, message_id: str, user_id: UUID, like: bool | None): + db_service = DatabaseService(conninfo=settings.database_url, kwargs=connection_kwargs) + pool = await db_service.get_pool() + + try: + chatbot: Chatbot = await Chatbot.get_or_none(id=id, user_id=user_id) + + if chatbot is None: + raise HTTPException(404, detail="Chat not found") + + checkpointer = AsyncPostgresSaver(pool) + await checkpointer.setup() + + config = {"configurable": {"thread_id": str(id)}} + workflow = StateGraph(state_schema=MessagesState) + app = workflow.compile(checkpointer=checkpointer) + + state = await app.aget_state(config) + messages = state.values["messages"] + message = next((message for message in messages if message.id == message_id), None) + + if message is None: + raise HTTPException(404, detail=MESSAGES["message"]["not_found"]) + + message.additional_kwargs["like"] = like + await app.aupdate_state(config, {"messages": message}) + + except HTTPException as e: + raise e + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +async def edit_title(id: int, user_id: UUID, title: str): + chatbot: Chatbot = await Chatbot.get_or_none(id=id, user_id=user_id) + + if chatbot is None: + raise HTTPException(404, detail=MESSAGES["chat"]["not_found"]) + + chatbot.title = title + await chatbot.save() + + +async def archive(id: int, user_id: UUID, archive: bool): + chatbot: Chatbot = await Chatbot.get_or_none(id=id, user_id=user_id) + + if chatbot is None: + raise HTTPException(404, detail=MESSAGES["chat"]["not_found"]) + + chatbot.archive = archive + await chatbot.save() + + +async def related_questions(query: str): + model = ChatOpenAI(model="gpt-4o-mini", api_key=settings.openai_api_key) + instruction = "Generate 3 related questions based on the following query." + res = await model.ainvoke([SystemMessage(content=instruction), HumanMessage(content=query)]) + related_questions = [q.strip()[3:] for q in res.content.split("\n") if q.strip()] + return related_questions + + +async def generate_title(query: str): + model = ChatOpenAI(model="gpt-4o-mini", api_key=settings.openai_api_key) + instruction = "Generate a concise title in Farsi from the following prompt." + res = await model.ainvoke([SystemMessage(content=instruction), HumanMessage(content=query)]) + return res.content + + +def get_trimmer(): + token_counter = get_model("gpt-4o-mini") + return trim_messages( + max_tokens=6000, + strategy="last", + token_counter=token_counter, + include_system=True, + allow_partial=True, + start_on="human", + ) + + +def get_message_id(messages): + human_messages = [msg for msg in messages if isinstance(msg, HumanMessage)] + ai_messages = [msg for msg in messages if isinstance(msg, AIMessage) and not msg.tool_calls] + return human_messages[-1].id, ai_messages[-1].id \ No newline at end of file diff --git a/src/services/chroma.py b/src/services/chroma.py new file mode 100644 index 0000000..bd2cff4 --- /dev/null +++ b/src/services/chroma.py @@ -0,0 +1,56 @@ +import chromadb +from chromadb.config import Settings +from langchain_chroma import Chroma +from langchain_openai import OpenAIEmbeddings +from ..config import settings + + +class ChromaService: + def __init__(self): + self.embeddings = OpenAIEmbeddings(model="text-embedding-3-large") + self.client = chromadb.HttpClient( + host=settings.chroma_url, + port=80, + ssl=True, + settings=Settings( + chroma_client_auth_provider="chromadb.auth.token_authn.TokenAuthClientProvider", + chroma_client_auth_credentials=settings.chroma_credentials, + ), + ) + + async def write(self, docs, collection_name): + vector_store = Chroma( + embedding_function=self.embeddings, + collection_name=collection_name, + client=self.client, + ) + + await vector_store.aadd_documents(docs) + + def read(self, collection_name): + vector_store = Chroma( + embedding_function=self.embeddings, + collection_name=collection_name, + client=self.client, + ) + + return vector_store.as_retriever() + + async def similarity_search(self, collection_name: str, query: str, k: int): + vector_store = Chroma( + embedding_function=self.embeddings, + collection_name=collection_name, + client=self.client, + ) + + return await vector_store.asimilarity_search(query, k) + + def delete(self, collection_name): + Chroma( + embedding_function=self.embeddings, + collection_name=collection_name, + client=self.client, + ).delete_collection() + + +chroma_service = ChromaService() diff --git a/src/services/database/__init__.py b/src/services/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/services/database/service.py b/src/services/database/service.py new file mode 100644 index 0000000..622952e --- /dev/null +++ b/src/services/database/service.py @@ -0,0 +1,43 @@ +from psycopg_pool import AsyncConnectionPool + +class DatabasePool: + _pool = None + + @classmethod + async def get_pool(cls, conninfo: str, kwargs: dict): + if cls._pool is None or cls._pool.closed: + cls._pool = AsyncConnectionPool( + conninfo=conninfo, + kwargs=kwargs, + max_size=20, # Adjust based on your PostgreSQL max_connections + ) + await cls._pool.open() + return cls._pool + + @classmethod + async def close_pool(cls): + if cls._pool and not cls._pool.closed: + await cls._pool.close() + +class DatabaseService: + def __init__(self, conninfo: str, kwargs: dict): + self.conninfo = conninfo + self.kwargs = kwargs + self._pool = None + + async def get_pool(self): + if self._pool is None or self._pool.closed: + self._pool = await DatabasePool.get_pool(self.conninfo, self.kwargs) + return self._pool + + async def execute(self, query: str, *args): + pool = await self.get_pool() + async with pool.connection() as conn: + async with conn.transaction(): + return await conn.execute(query, *args) + + async def fetch(self, query: str, *args): + pool = await self.get_pool() + async with pool.connection() as conn: + async with conn.transaction(): + return await conn.fetch(query, *args) \ No newline at end of file diff --git a/src/services/discount.py b/src/services/discount.py new file mode 100644 index 0000000..892dada --- /dev/null +++ b/src/services/discount.py @@ -0,0 +1,46 @@ +import jwt +from uuid import UUID +from datetime import datetime, timedelta +from fastapi import HTTPException +from ..models import Discount, Billing, BillingStatus, Plan +from ..messages import MESSAGES +from ..config import settings + + +async def verify(user_id: UUID, code: str, product_id: str): + billing: Billing = await Billing.filter( + user_id=user_id, code=code, status=BillingStatus.SUCCESS + ).first() + + if billing: + raise HTTPException(status_code=500, detail=MESSAGES["discount"]["not_allow"]) + + discount: Discount = await Discount.get_or_none(code=code.upper()) + + if discount is None: + raise HTTPException(status_code=500, detail=MESSAGES["discount"]["not_found"]) + + if discount.expires_at and discount.expires_at < datetime.utcnow(): + raise HTTPException(status_code=400, detail=MESSAGES["discount"]["expired"]) + + plan: Plan = await Plan.get(id=product_id) + price = plan.amount + + discount_val = 0 + if discount.percent: + discount_val = min(price * discount.percent * 0.01, discount.max_value) + else: + discount_val = discount.value + + bazzar_token = jwt.encode( + { + "price": (price - discount_val) * 10, + "package_name": "com.example.hoshan", + "sku": product_id, + "exp": (datetime.now() + timedelta(minutes=10)).timestamp(), + }, + settings.cafebazaar_price_key, + algorithm="HS256", + ) + + return discount, bazzar_token diff --git a/src/services/email.py b/src/services/email.py new file mode 100644 index 0000000..ef13863 --- /dev/null +++ b/src/services/email.py @@ -0,0 +1,29 @@ +from mailersend import emails +from ..config import settings + + +mailer = emails.NewEmail(settings.mailersend_api_key) + +mail_body = {} + +mail_from = { + "name": "دستیار هوش مصنوعی هوشان", + "email": "no-reply@houshan.ai", +} + + +def send_otp(email: str, otp: str): + try: + recipients = [{"name": "", "email": email}] + personalization = [{"email": email, "data": {"otp": otp}}] + + mailer.set_mail_from(mail_from, mail_body) + mailer.set_mail_to(recipients, mail_body) + mailer.set_subject("کد یکبار مصرف ورود به هوشان", mail_body) + mailer.set_template("3z0vklop80xg7qrx", mail_body) + mailer.set_personalization(personalization, mail_body) + + mailer.send(mail_body) + + except Exception as e: + print(str(e)) diff --git a/src/services/event.py b/src/services/event.py new file mode 100644 index 0000000..5595be6 --- /dev/null +++ b/src/services/event.py @@ -0,0 +1,32 @@ +from ..models import Event + + +async def get_all(): + events = await Event.all().prefetch_related("winners", "winners__user") + + event_data = [] + for event in events: + # Get winner details for each event + winners_list = [ + {"rank": winner.rank, "username": winner.user.username} + for winner in event.winners + ] + + event_data.append( + { + "id": event.id, + "title": event.title, + "description": event.description, + "awards": event.awards, + "image": event.image, + "start_at": event.start_at, + "end_at": event.end_at, + "is_open": event.is_open, + "total_participants": event.total_participants, + "total_received_works": event.total_received_works, + "winners": winners_list, + "subtitle": event.subtitle, + } + ) + + return event_data diff --git a/src/services/falai/__init__.py b/src/services/falai/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/services/falai/service.py b/src/services/falai/service.py new file mode 100644 index 0000000..cc98684 --- /dev/null +++ b/src/services/falai/service.py @@ -0,0 +1,45 @@ +import fal_client +from .._utils.generate_en_prompt import generate_en_prompt + + +class FallAIService: + async def image_generation(self, query: str, bot): + en_prompt = await generate_en_prompt(query) + + args = {"image_size": "square", "prompt": en_prompt, "style": bot.style} + + handler = await fal_client.submit_async( + f"fal-ai/{bot.model}", + arguments=args, + ) + + result = await handler.get() + return result["images"][0]["url"] + + async def image_to_image_generation(self, image: str, bot): + args = {"image_url": image} + + handler = await fal_client.submit_async( + f"fal-ai/{bot.model}", + arguments=args, + ) + + result = await handler.get() + return result.get("image", {}).get("url") or result.get("images", [{}])[0].get( + "url" + ) + + async def image_personalization(self, image: str, bot): + args = { + "prompt": bot.prompt, + "reference_image_url": image, + "image_size": "square", + } + + handler = await fal_client.submit_async( + f"fal-ai/{bot.model}", + arguments=args, + ) + + result = await handler.get() + return result["images"][0]["url"] diff --git a/src/services/falai/workflow.py b/src/services/falai/workflow.py new file mode 100644 index 0000000..1f6ae24 --- /dev/null +++ b/src/services/falai/workflow.py @@ -0,0 +1,20 @@ +from datetime import datetime +from langchain_core.messages import AIMessage +from langgraph.graph import StateGraph, START, MessagesState + + +async def setup_fal_ai_workflow(service, query: str, bot): + async def call_tool(state: MessagesState): + image_url = await service(query, bot) + + ai_msg_created_at = datetime.utcnow().isoformat() + ai_msg = AIMessage(image_url) + ai_msg.additional_kwargs["created_at"] = ai_msg_created_at + + return {"messages": ai_msg} + + workflow = StateGraph(state_schema=MessagesState) + workflow.add_edge(START, "tool") + workflow.add_node("tool", call_tool) + + return workflow diff --git a/src/services/firebase.py b/src/services/firebase.py new file mode 100644 index 0000000..9195764 --- /dev/null +++ b/src/services/firebase.py @@ -0,0 +1,25 @@ +import firebase_admin +from firebase_admin import credentials, messaging + +# Initialize Firebase Admin +cred = credentials.Certificate("./serviceAccountKey.json") +firebase_admin.initialize_app(cred) + + +async def send_notification( + token: str, title: str, body: str, data: dict | None = None +): + try: + message = messaging.Message( + token=token, + notification=messaging.Notification( + title=title, + body=body, + ), + data=data or {}, + ) + + response = messaging.send(message) + print("FCM: ", {"success": True, "response": response}) + except Exception as e: + print("FCM: ", {"success": False, "error": str(e)}) diff --git a/src/services/forum.py b/src/services/forum.py new file mode 100644 index 0000000..77da366 --- /dev/null +++ b/src/services/forum.py @@ -0,0 +1,150 @@ +import math +from uuid import UUID +from typing import Literal +from tortoise.expressions import Q +from tortoise.functions import Count +from ..models import Feedback, Comment, FeedbackStatus + + +async def send_comment( + text: str, + image_url: str | None, + user_id: UUID, + category_id: int, + parent_id: int | None, + replied_user_id: int | None, +): + comment = await Comment.create( + text=text, + image=image_url, + user_id=user_id, + category_id=category_id, + parent_id=parent_id, + replied_user_id=replied_user_id, + ) + + return comment + + +async def feedback(user_id: UUID, comment_id: int, status: int): + if status == -1: + await Feedback.filter(user_id=user_id, comment_id=comment_id).delete() + else: + await Feedback.update_or_create( + defaults={ + "status": FeedbackStatus.LIKED + if status == 1 + else FeedbackStatus.DISLIKED + }, + user_id=user_id, + comment_id=comment_id, + ) + + +async def get_comments( + category_id: int, user_id: UUID, page: int, order_by: Literal["date", "replies"] +): + limit = 10 + offset = (page - 1) * limit + + comments = ( + await Comment.filter(category_id=category_id, parent_id=None) + .annotate( + replies_count=Count("replies", distinct=True), + likes=Count( + "feedbacks", + _filter=Q(feedbacks__status=FeedbackStatus.LIKED), + distinct=True, + ), + dislikes=Count( + "feedbacks", + _filter=Q(feedbacks__status=FeedbackStatus.DISLIKED), + distinct=True, + ), + ) + .prefetch_related("user") + .order_by("-created_at" if order_by == "date" else "-replies_count") + .offset(offset) + .limit(limit) + ) + + total_count = await Comment.filter(category_id=category_id, parent_id=None).count() + + result = [] + for comment in comments: + user_feedback = await Feedback.get_or_none( + comment_id=comment.id, user_id=user_id + ) + + result.append( + { + **comment.__dict__, + "user": comment.user, + "replies": comment.replies_count, + "user_feedback": ( + 1 if user_feedback.status == FeedbackStatus.LIKED else 0 + ) + if user_feedback + else -1, + } + ) + + return { + "comments": result, + "page": page, + "total_count": total_count, + "last_page": math.ceil(total_count / limit), + } + + +async def get_replies(parent_id: int, user_id: UUID, page: int): + limit = 10 + offset = (page - 1) * limit + + replies = ( + await Comment.filter(parent_id=parent_id) + .annotate( + likes=Count( + "feedbacks", + _filter=Q(feedbacks__status=FeedbackStatus.LIKED), + distinct=True, + ), + dislikes=Count( + "feedbacks", + _filter=Q(feedbacks__status=FeedbackStatus.DISLIKED), + distinct=True, + ), + ) + .prefetch_related("user", "replied_user") + .order_by("created_at") + .offset(offset) + .limit(limit) + ) + + total_count = await Comment.filter(parent_id=parent_id).count() + + result = [] + for comment in replies: + user_feedback = await Feedback.get_or_none( + comment_id=comment.id, user_id=user_id + ) + + result.append( + { + **comment.__dict__, + "user": comment.user, + "replied_user": comment.replied_user, + "user_feedback": ( + 1 if user_feedback.status == FeedbackStatus.LIKED else 0 + ) + if user_feedback + else -1, + } + ) + + return { + "replies": result, + "page": page, + "total_count": total_count, + "last_page": math.ceil(total_count / limit), + } diff --git a/src/services/others/__init__.py b/src/services/others/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/services/others/service.py b/src/services/others/service.py new file mode 100644 index 0000000..40d07a2 --- /dev/null +++ b/src/services/others/service.py @@ -0,0 +1,86 @@ +import os +from langchain_community.document_loaders.blob_loaders import Blob +from langchain_community.document_loaders.parsers import OpenAIWhisperParser +from langchain_community.document_loaders import ( + PyPDFLoader, + Docx2txtLoader, + UnstructuredExcelLoader, + TextLoader, +) + + +class OtherService: + def __init__(self, query=None, image=None, audio=None, doc=None): + self.query = query + self.image = image + self.audio = audio + self.doc = doc + + def create_content(self): + content = [{"type": "text", "text": self.query}] + + if self.image: + content.append( + {"type": "image_url", "image_url": {"url": self.image}} + ) + + if self.audio[0]: + parser = OpenAIWhisperParser() + + audio_text = "" + for document in parser.lazy_parse(Blob.from_path(self.audio[1])): + audio_text += document.page_content + + if self.query is None: + content[0]["text"] = audio_text + content[0]["audio_url"] = { + "url": self.audio[0], + "attachment": False, + } + + else: + content[0]["text"] = ( + f"attached audio: {audio_text}\n\n\nquery: {self.query}" + ) + content[0]["audio_url"] = { + "url": self.audio[0], + "attachment": True, + "query": self.query, + } + + if os.path.exists(self.audio[1]): + os.remove(self.audio[1]) + + if self.doc[0]: + loader = None + if ".pdf" in self.doc[0]: + loader = PyPDFLoader(self.doc[1]) + + elif ".docx" in self.doc[0]: + loader = Docx2txtLoader(self.doc[1]) + + elif ".xlsx" in self.doc[0] or ".xls" in self.doc[0]: + loader = UnstructuredExcelLoader(self.doc[1], mode="elements") + + elif ".txt" in self.doc[0]: + loader = TextLoader(self.doc[1]) + + if loader: + pages = [] + for page in loader.lazy_load(): + pages.append(page.page_content) + + content[0]["text"] = ( + f"attached file: {' '.join(pages)}\n\n\nquery: {self.query}" + ) + + content[0]["pdf_url"] = { + "url": self.doc[0], + "attachment": True, + "query": self.query, + } + + if os.path.exists(self.doc[1]): + os.remove(self.doc[1]) + + return content \ No newline at end of file diff --git a/src/services/others/workflow.py b/src/services/others/workflow.py new file mode 100644 index 0000000..08c368a --- /dev/null +++ b/src/services/others/workflow.py @@ -0,0 +1,114 @@ +from datetime import datetime, UTC +import logging + +from langgraph.graph import StateGraph, END, MessagesState +from langgraph.prebuilt import tools_condition, ToolNode +from langchain_core.messages import SystemMessage +from langchain_core.tools import tool + +from ...services.chatbot import get_trimmer +from ...services.chroma import chroma_service +from ..chatbot import get_model + + +async def other_workflow(bot, bot_owner, bot_id): + trimmer = get_trimmer() + + @tool(response_format="content_and_artifact") + async def retrieve(query: str): + """ + Retrieve relevant documents based on a query from a user's bot-specific collection. + + Args: + query (str): The search query to find relevant documents. + + Returns: + tuple: A tuple containing a serialized string of documents and the list of retrieved documents. + """ + retrieved_docs = await chroma_service.similarity_search( + f"user-{bot_owner.id}-bot-{bot_id}", query, k=5 + ) + + serialized = "\n\n".join( + (f"Source: {doc.metadata}\nContent: {doc.page_content}") + for doc in retrieved_docs + ) + + return serialized, retrieved_docs + + model = get_model(model=bot.model) + tools = ToolNode([retrieve]) + + async def query_or_respond(state: MessagesState): + kwargs = {} + if "gemini" not in bot.model: + kwargs["stream_usage"] = True + + system_message = f"{bot.prompt}" + trimmed_messages = await trimmer.ainvoke(state["messages"]) + input_messages = [ + SystemMessage(system_message) + ] + trimmed_messages + + # Log the messages for debugging + logging.basicConfig(level=logging.INFO) + logging.debug(f"Input messages: {input_messages}") + + llm_with_tools = model.bind_tools([retrieve]) + response = await llm_with_tools.ainvoke( + input_messages, **kwargs + ) + + return {"messages": [response]} + + async def generate(state: MessagesState): + kwargs = {} + if "gemini" not in bot.model: + kwargs["stream_usage"] = True + + recent_tool_messages = [] + for message in reversed(state["messages"]): + if message.type == "tool": + recent_tool_messages.append(message) + else: + break + + tool_messages = recent_tool_messages[::-1] + docs_content = "\n\n".join(doc.content for doc in tool_messages) + system_message = f"{bot.prompt} \n\n {docs_content}" + + conversation_messages = [ + message + for message in state["messages"] + if message.type in ("human", "system") + or (message.type == "ai" and not message.tool_calls) + ] + + trimmed_messages = await trimmer.ainvoke(conversation_messages) + input_message = [ + SystemMessage(system_message) + ] + trimmed_messages + + response = await model.ainvoke(input_message, **kwargs) + ai_msg_created_at = datetime.now(UTC) + response.additional_kwargs["created_at"] = ai_msg_created_at + + return {"messages": [response]} + + workflow = StateGraph(state_schema=MessagesState) + + workflow.add_node(query_or_respond) + workflow.add_node(tools) + workflow.add_node(generate) + + workflow.set_entry_point("query_or_respond") + workflow.add_conditional_edges( + "query_or_respond", + tools_condition, + {END: END, "tools": "tools"}, + ) + + workflow.add_edge("tools", "generate") + workflow.add_edge("generate", END) + + return workflow \ No newline at end of file diff --git a/src/services/otp.py b/src/services/otp.py new file mode 100644 index 0000000..71521b2 --- /dev/null +++ b/src/services/otp.py @@ -0,0 +1,106 @@ +import re +import random +from fastapi import HTTPException +from datetime import datetime, timedelta, timezone +from ..models import User +from ..messages import MESSAGES +from ..services import sms as sms_service, user as user_service, email as email_service + +async def generate(mobile_number: str): + # ۱. جستجوی کاربر در دیتابیس + user: User = await User.get_or_none(mobile_number=mobile_number) + + # ۲. بررسی وجود کاربر (اگر نبود، پیامک ارسال نمی‌شود) + if user is None: + print(f"--- [WARNING] ---") + print(f"Time: {datetime.now()}") + print(f"Action: OTP Request") + print(f"Status: Failed - User Not Found") + print(f"Target Mobile: {mobile_number}") + print(f"------------------") + + raise HTTPException( + status_code=404, + detail=MESSAGES["user"]["not_found"] + ) + + if user.parent_id is not None and user.verified is False: + raise HTTPException( + status_code=404, + detail=MESSAGES["user"]["not_found"] + ) + # ۳. کاربر پیدا شد -> چاپ اطلاعات کاربر برای مانیتورینگ + print(f"--- [INFO] ---") + print(f"Time: {datetime.now()}") + print(f"Action: OTP Generation") + print(f"User ID: {user.id}") + print(f"User Name: {getattr(user, 'name', 'N/A')}") # اگر فیلد نام دارد + print(f"Mobile: {mobile_number}") + + # ۴. تولید کد ۶ رقمی + otp = random.randint(100000, 999999) + + # ۵. بررسی محدودیت زمانی (Rate Limiting) + current_time = datetime.now(timezone.utc) + + if not user.otp_expires_at or user.otp_expires_at < current_time: + user.otp = otp + user.otp_expires_at = current_time + timedelta(minutes=2) + + # ارسال فیزیکی پیامک + sms_service.send_otp(mobile_number, otp) + await user.save() + + print(f"Status: Success - OTP {otp} sent to {mobile_number}") + print(f"------------------") + else: + time_difference = (user.otp_expires_at - current_time).total_seconds() + + print(f"Status: Rate Limited - {int(time_difference)} seconds remaining") + print(f"------------------") + + raise HTTPException( + status_code=429, + detail=MESSAGES["user"]["try_later"](int(time_difference)) + ) + + +##### V2 Services ##### + + +async def generate_v2(contact: str): + # Check if the input is a valid email + is_email = re.match(r"^[^@]+@[^@]+\.[^@]+$", contact) is not None + is_mobile = re.match(r"^09\d{9}$", contact) is not None + + if not is_email and not is_mobile: + raise HTTPException(status_code=400, detail=MESSAGES["user"]["wrong_input"]) + + filter_param = {"email": contact} if is_email else {"mobile_number": contact} + user: User = await User.get_or_none(**filter_param) + + if user is None: + user = await user_service.create_user(contact, is_email) + + otp = random.randint(100000, 999999) + + if not user.otp_expires_at or user.otp_expires_at < datetime.now(timezone.utc): + user.otp = otp + user.otp_expires_at = datetime.now(timezone.utc) + timedelta(minutes=2) + + if is_email: + email_service.send_otp(contact, otp) + else: + sms_service.send_otp(contact, otp) + + await user.save() + + return not user.verified + else: + timeـdifference = ( + user.otp_expires_at - datetime.now(timezone.utc) + ).total_seconds() + + raise HTTPException( + status_code=429, detail=MESSAGES["user"]["try_later"](int(timeـdifference)) + ) diff --git a/src/services/payment.py b/src/services/payment.py new file mode 100644 index 0000000..9f372f1 --- /dev/null +++ b/src/services/payment.py @@ -0,0 +1,344 @@ +import aiohttp +from uuid import UUID +from datetime import datetime +from fastapi import HTTPException +from zeep import Client +from ..config import settings +from ..messages import MESSAGES +from ..models import ( + Billing, + BillingStatus, + BillingType, + User, + Discount, + Settlement, + SettlementType, + Plan, +) + + +def get_credit_from_amount(amount: int): + if amount == 20_000: + return 70 + else: + return int(amount / 300 * 1.1) + + +async def request(user_id: UUID, amount: int, code: str | None): + client = Client("https://www.zarinpal.com/pg/services/WebGate/wsdl") + + discount = None + if code: + billing: Billing = await Billing.filter( + user_id=user_id, code=code, status=BillingStatus.SUCCESS + ).first() + + if not billing: + discount: Discount = await Discount.get_or_none(code=code) + + discount_val = 0 + if discount and ( + not discount.expires_at or discount.expires_at > datetime.utcnow() + ): + if discount.percent: + discount_val = min(amount * discount.percent * 0.01, discount.max_value) + else: + discount_val = discount.value + + try: + result = client.service.PaymentRequest( + settings.mmerchant_id, + amount - discount_val, + "خرید اعتبار هوشان", + None, + None, + "https://api.houshan.ai/paymant/verify", + ) + + if result.Status == 100: + await Billing.create( + user_id=user_id, + amount=amount, + code=code, + discount=discount_val, + authority=result.Authority, + credit=get_credit_from_amount(amount), + ) + + return "https://www.zarinpal.com/pg/StartPay/" + result.Authority + + else: + raise HTTPException(status_code=500, detail="Exception from Zarinpal") + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# async def history(user_id: UUID): +# billings = ( +# await Billing.filter(user_id=user_id, type__in=[BillingType.BASA, BillingType.FREE]) +# .all() +# .order_by("-created_at") +# ) + +# return billings + +async def history(user_id: UUID): + billings = ( + await Billing + .filter( + user_id=user_id, + type__in=[BillingType.BASA, BillingType.FREE] + ) + .order_by("-created_at") + ) + + return [ + { + "id": b.id, + "amount": b.amount, + "credit": b.credit, + "discount": b.discount, + "ref_id": b.authority, # ✅ دقیقاً این + "type": b.type, + "status": b.status, + "created_at": b.created_at, + } + for b in billings + ] + + + +async def verify(status: str, authority: str): + client = Client("https://www.zarinpal.com/pg/services/WebGate/wsdl") + + billing: Billing = await Billing.get_or_none( + status=BillingStatus.PENDING, authority=authority + ) + + if billing is None: + raise HTTPException(status_code=404, detail="Billing not found") + + user: User = await User.get(id=billing.user_id) + + if status == "OK": + result = client.service.PaymentVerification( + settings.mmerchant_id, authority, billing.amount - billing.discount + ) + + if result.Status == 100: + user.credit = user.credit + billing.credit + + billing.status = BillingStatus.SUCCESS + billing.ref_id = result.RefID + + await user.save() + await billing.save() + + return ( + "success", + f"پرداخت با موفقیت انجام شد.\nشماره پیگیری: {result.RefID}", + user.credit, + ) + + elif result.Status == 101: + billing.status = BillingStatus.SUCCESS + await billing.save() + + return "success", f"Transaction submitted : {result.Status}", user.credit + + else: + billing.status = BillingStatus.FAILED + await billing.save() + + return "failed", "عملیات پرداخت با خطا مواجه شد", user.credit + + else: + billing.status = BillingStatus.FAILED + await billing.save() + + return "failed", "عملیات پرداخت با خطا مواجه شد", user.credit + + +async def bazzar(user_id: UUID, order_id: str, purchase_token: str, product_id: str): + user: User = await User.get(id=user_id) + + url = f"https://pardakht.cafebazaar.ir/devapi/v2/api/validate/com.example.hoshan/inapp/{product_id}/purchases/{purchase_token}/" + + headers = { + "CAFEBAZAAR-PISHKHAN-API-SECRET": settings.cafebazaar_pishkhan_api_secret, + "Content-Type": "application/json", + } + + async with aiohttp.ClientSession() as session: + async with session.get(url, headers=headers) as response: + response.raise_for_status() + data = await response.json() + + if data["purchaseState"] == 0 and data["consumptionState"] == 1: + url = "https://pardakht.cafebazaar.ir/devapi/v2/api/consume/com.example.hoshan/purchases/" + + body = {"token": purchase_token} + + async with session.post(url, headers=headers, json=body) as response: + response.raise_for_status() + + code = data["developerPayload"] + discount: Discount = await Discount.get_or_none(code=code) + plan: Plan = await Plan.get(id=product_id) + credit = plan.coins + plan.free_coins + amount = plan.price + + discount_val = 0 + if discount: + if discount.percent: + discount_val = min( + amount * discount.percent * 0.01, discount.max_value + ) + else: + discount_val = discount.value + + user.credit = user.credit + credit + + await Billing.create( + user_id=user_id, + amount=amount, + authority=order_id, + credit=credit, + code=code, + discount=discount_val, + status=BillingStatus.SUCCESS, + ) + + await user.save() + + return credit + else: + raise HTTPException( + status_code=400, detail="The purchase has already been consumed" + ) + + +async def myket(user_id: UUID, order_id: str, purchase_token: str, product_id: str): + user: User = await User.get(id=user_id) + + url = f"https://developer.myket.ir/api/partners/applications/com.example.hoshan/purchases/products/{product_id}/verify" + + headers = { + "X-Access-Token": settings.myket_access_token, + "Content-Type": "application/json", + } + + body = {"tokenId": purchase_token} + + async with aiohttp.ClientSession() as session: + async with session.post(url, headers=headers, json=body) as response: + response.raise_for_status() + data = await response.json() + + if data["purchaseState"] == 0 and data["consumptionState"] == 0: + url = f"https://developer.myket.ir/api/partners/applications/com.example.hoshan/purchases/products/{product_id}/tokens/{purchase_token}/consume" + + async with session.put(url, headers=headers) as response: + response.raise_for_status() + + plan: Plan = await Plan.get(id=product_id) + credit = plan.coins + plan.free_coins + amount = plan.price + user.credit = user.credit + credit + + await Billing.create( + user_id=user_id, + amount=amount, + authority=order_id, + credit=credit, + status=BillingStatus.SUCCESS, + ) + + await user.save() + + return credit + else: + raise HTTPException( + status_code=400, detail="The purchase has already been consumed" + ) + + +async def settlement(user_id: UUID, type: SettlementType): + user: User = await User.get(id=user_id) + + if user.income >= 500: + await Settlement.create(user_id=user_id, income=user.income, type=type) + + user.income = 0 + await user.save() + else: + raise HTTPException(status_code=400, detail=MESSAGES["settlement"]["failed"]) + + +async def get_plans(): + plans = await Plan.filter(active=True).order_by("price").all() + + return { + "plans": [ + { + "name": "برنزی", + "prices": plans[0:2], + }, + { + "name": "نقره‌ای", + "prices": plans[2:4], + }, + { + "name": "طلایی", + "prices": plans[4:6], + }, + ] + } + + +##### V2 Services ##### + + +async def get_plans_v2(): + return await Plan.filter(active=True).all().order_by("price") + + + +# ... (imports) + +async def add_basa_credit(user_id: UUID, amount: int): + # ۱. یافتن کاربر + user = await User.get_or_none(id=user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # ۲. محاسبه مقدار سکه بر اساس مبلغ (یا ورودی مستقیم) + credit_amount = amount + + # ۳. افزایش اعتبار هدیه (سکه باسا) برای خود کاربر + user.gift_credit += credit_amount + await user.save() + + # ۴. همگام‌سازی اعتبار تمام زیرمجموعه‌ها با اعتبار جدید والد + # (همه زیرمجموعه‌ها دقیقاً موجودی والد را خواهند داشت) + await User.filter(parent_id=user.id).update(gift_credit=user.gift_credit) + + # ۵. ثبت تراکنش + billing = await Billing.create( + user_id=user_id, + amount=amount, + credit=credit_amount, + type=BillingType.BASA, + status=BillingStatus.SUCCESS, + authority="basa_api_call", + description="افزایش اعتبار باسا و همگام‌سازی زیرمجموعه‌ها" + ) + + return { + "status": "success", + "user_credit": user.credit, + "user_gift_credit": user.gift_credit, # نمایش اعتبار باسا + "added_credit": credit_amount, + "billing_id": billing.id + } \ No newline at end of file diff --git a/src/services/pixverse_img2vid/__init__.py b/src/services/pixverse_img2vid/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/services/pixverse_img2vid/service.py b/src/services/pixverse_img2vid/service.py new file mode 100644 index 0000000..c5000b0 --- /dev/null +++ b/src/services/pixverse_img2vid/service.py @@ -0,0 +1,132 @@ +from uuid import uuid4 +from aiohttp import ClientSession, FormData +from ...config import settings +import fal_client +import logging +import json +logger = logging.getLogger(__name__) +pixverse_effects = { + 'Hug Your Love': 303624424723200, + 'Hot Harley Quinn': 307489434436288, + 'Muscle Surge': 308621408717184, +} + + +class PixVerseService: + def __init__(self, db_service, image, effect: str): + # self.db_service = db_service + # self.image = image + # self.effect = pixverse_effects.get(effect, 308621408717184) + # self.image_id = None # image id from pixverse + # self.video_id = None # video id from pixverse + # self.video_url = None # video url from pixverse + # self.pixverse_api_key = settings.pixverse_api_key + self.db_service = db_service + self.image_url = image + self.effect = effect + + async def upload_image(self): + url = "https://app-api.pixverse.ai/openapi/v2/image/upload" + headers = { + 'API-KEY': self.pixverse_api_key, + 'Ai-trace-id': str(uuid4()), + } + + async with ClientSession() as session: + data = FormData() + data.add_field('image', self.image, filename='image', content_type='application/octet-stream') + + async with session.post(url, headers=headers, data=data) as response: + if response.status == 200: + response_data = await response.json() + if response_data.get("ErrCode") == 0: + self.image_id = response_data["Resp"]["img_id"] + return self.image_id + else: + raise Exception(f"Upload failed: {response_data.get('ErrMsg')}") + else: + raise Exception(f"HTTP Error: {response.status} - {await response.text()}") + + # async def video_generation(self): + # url = "https://app-api.pixverse.ai/openapi/v2/video/img/generate" + # headers = { + # 'API-KEY': self.pixverse_api_key, + # 'Ai-trace-id': str(uuid4()), + # 'Content-Type': 'application/json', + # } + # payload = { + # "duration": 5, + # "img_id": self.image_id, + # "model": "v3.5", + # "motion_mode": "normal", + # "negative_prompt": "string", + # "prompt": "string", + # "quality": "540p", + # "seed": 0, + # "style": None, # todo: add to options + # "template_id": self.effect, + # "water_mark": False + # } + + # async with ClientSession() as session: + # async with session.post(url, headers=headers, json=payload) as response: + # if response.status == 200: + # response_data = await response.json() + # if response_data.get("ErrCode") == 0: + # self.video_id = response_data["Resp"]["video_id"] + # return self.video_id + # else: + # raise Exception(f"Generation failed: {response_data.get('ErrMsg')}") + # else: + # raise Exception(f"HTTP Error: {response.status} - {await response.text()}") + + async def video_generation(self): + logger.info(f"Starting PixVerse Image-to-Video generation. Image URL: {self.image_url}") + try: + handler = await fal_client.submit_async( + "fal-ai/pixverse/v3.5/image-to-video", + arguments={ + "prompt":f"Create a video with this effect: {self.effect}", + "image_url": self.image_url, + "duration": "5", + "aspect_ratio": "16:9", + "model": "v3.5", + }, + ) + + logger.info("Request submitted to Fal AI. Waiting for result...") + result = await handler.get() + + logger.info(f"Fal AI Result received: {json.dumps(result)}") + + if "video" in result and "url" in result["video"]: + return result["video"]["url"] + else: + logger.error(f"Unexpected result format from Fal AI: {result}") + raise Exception(f"Unexpected result format: {result}") + + except Exception as e: + logger.error(f"Error in PixVerseService (Img2Vid): {str(e)}", exc_info=True) + raise e + + async def check_state(self): + url = f"https://app-api.pixverse.ai/openapi/v2/video/result/{self.video_id}" + headers = { + 'API-KEY': self.pixverse_api_key, + 'Ai-trace-id': str(uuid4()), + } + + async with ClientSession() as session: + async with session.get(url, headers=headers) as response: + if response.status == 200: + response_data = await response.json() + if response_data.get("ErrCode") == 0: + self.video_url = response_data["Resp"]["url"] + return { + "status": "SUCCESS", + "video_url": self.video_url + } + else: + raise Exception("pixverse img2vid failed") + else: + raise Exception(f"HTTP Error: {response.status} - {await response.text()}") \ No newline at end of file diff --git a/src/services/pixverse_img2vid/workflow.py b/src/services/pixverse_img2vid/workflow.py new file mode 100644 index 0000000..e078aea --- /dev/null +++ b/src/services/pixverse_img2vid/workflow.py @@ -0,0 +1,86 @@ +import asyncio +from datetime import datetime +from langchain_core.messages import AIMessage, ToolMessage +from langgraph.graph import StateGraph, START, END, MessagesState +from .service import PixVerseService +from uuid import uuid4 + +# async def pixverse_workflow(pixverse_service: PixVerseService, chatbot, user): +# async def upload_node(state: MessagesState): +# try: +# image_id = await pixverse_service.upload_image() +# return { +# "messages": [ToolMessage( +# tool_name="pixverse_i2v_generator", +# tool_call_id=uuid4(), +# content=f"Image uploaded successfully: {image_id}", +# )] +# } +# except Exception as e: +# print(f"pixverse_img2vid upload_node failed: {e}") +# raise e + +# async def generate_node(state: MessagesState): +# try: +# video_id = await pixverse_service.video_generation() +# return { +# "messages": [ToolMessage( +# tool_name="pixverse_i2v_generator", +# tool_call_id=uuid4(), +# content=f"Video generation started: {video_id}", +# )] +# } +# except Exception as e: +# print(f"pixverse_img2vid generate_node failed: {e}") +# raise e + +# async def check_node(state: MessagesState): +# while True: +# try: +# result = await pixverse_service.check_state() +# if result["status"] == "SUCCESS" and result["video_url"]: +# created_at = datetime.now().isoformat() +# return { +# "messages": [AIMessage( +# content=f"{result['video_url']}", +# additional_kwargs={"created_at": created_at} +# )] +# } +# elif result["status"] == "FAILED": +# print("pixverse_img2vid check_node failed") +# raise Exception("pixverse_img2vid check_node failed") +# await asyncio.sleep(5) +# except Exception as e: +# print(f"pixverse_img2vid check_node failed: {e}") +# raise e + +# workflow = StateGraph(state_schema=MessagesState) + +# # Add nodes +# workflow.add_node("upload", upload_node) +# workflow.add_node("generate", generate_node) +# workflow.add_node("check", check_node) + +# # Define edges +# workflow.add_edge(START, "upload") +# workflow.add_edge("upload", "generate") +# workflow.add_edge("generate", "check") +# workflow.add_edge("check", END) + +# return workflow +async def pixverse_workflow(pixverse_service: PixVerseService, chatbot, user): + async def call_tool(state: MessagesState): + video_url = await pixverse_service.video_generation() + + ai_msg_created_at = datetime.utcnow().isoformat() + ai_msg = AIMessage(video_url) + ai_msg.additional_kwargs["created_at"] = ai_msg_created_at + + return {"messages": ai_msg} + + workflow = StateGraph(state_schema=MessagesState) + workflow.add_edge(START, "tool") + workflow.add_node("tool", call_tool) + workflow.add_edge("tool", END) + + return workflow \ No newline at end of file diff --git a/src/services/pixverse_text2vid/__init__.py b/src/services/pixverse_text2vid/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/services/pixverse_text2vid/service.py b/src/services/pixverse_text2vid/service.py new file mode 100644 index 0000000..976b692 --- /dev/null +++ b/src/services/pixverse_text2vid/service.py @@ -0,0 +1,90 @@ +from uuid import uuid4 +from aiohttp import ClientSession, FormData +from ...config import settings +import fal_client +import logging +import json +logger = logging.getLogger(__name__) + +class PixVerseText2VideoService: + def __init__(self, prompt: str): + # self.prompt = prompt + # self.video_id = None # video id from pixverse + # self.video_url = None # video url from pixverse + # self.pixverse_api_key = settings.pixverse_api_key + self.prompt = prompt + + + # async def video_generation(self): + # url = "https://app-api.pixverse.ai/openapi/v2/video/text/generate" + # headers = { + # 'API-KEY': self.pixverse_api_key, + # 'Ai-trace-id': str(uuid4()), + # 'Content-Type': 'application/json', + # } + # payload = { + # "aspect_ratio": "16:9", + # "duration": 5, + # "model": "v3.5", + # "motion_mode": "normal", + # "negative_prompt": "string", + # "prompt": self.prompt, + # "quality": "540p", + # "water_mark": False + # } + + # async with ClientSession() as session: + # async with session.post(url, headers=headers, json=payload) as response: + # if response.status == 200: + # response_data = await response.json() + # if response_data.get("ErrCode") == 0: + # self.video_id = response_data["Resp"]["video_id"] + # return self.video_id + # else: + # raise Exception(f"Generation failed: {response_data.get('ErrMsg')}") + # else: + # raise Exception(f"HTTP Error: {response.status} - {await response.text()}") + + async def video_generation(self): + logger.info(f"FalAI Text2Vid: Starting generation for prompt: {self.prompt}") + try: + handler = await fal_client.submit_async( + "fal-ai/pixverse/v3.5/text-to-video", + arguments={ + "prompt": self.prompt, + "duration": "5", + "aspect_ratio": "16:9", + "model": "v3.5", + }, + ) + + logger.info("FalAI Text2Vid: Request submitted, waiting for result...") + result = await handler.get() + logger.info(f"FalAI Text2Vid: Result received: {json.dumps(result)}") + + return result["video"]["url"] + except Exception as e: + logger.error(f"FalAI Text2Vid Error: {e}", exc_info=True) + raise e + + async def check_state(self): + url = f"https://app-api.pixverse.ai/openapi/v2/video/result/{self.video_id}" + headers = { + 'API-KEY': self.pixverse_api_key, + 'Ai-trace-id': str(uuid4()), + } + + async with ClientSession() as session: + async with session.get(url, headers=headers) as response: + if response.status == 200: + response_data = await response.json() + if response_data.get("ErrCode") == 0: + self.video_url = response_data["Resp"]["url"] + return { + "status": "SUCCESS", + "video_url": self.video_url + } + else: + return {"status": "FAILED"} + else: + raise Exception(f"HTTP Error: {response.status} - {await response.text()}") \ No newline at end of file diff --git a/src/services/pixverse_text2vid/workflow.py b/src/services/pixverse_text2vid/workflow.py new file mode 100644 index 0000000..daebd6b --- /dev/null +++ b/src/services/pixverse_text2vid/workflow.py @@ -0,0 +1,71 @@ +import asyncio +from datetime import datetime +from langchain_core.messages import AIMessage, ToolMessage +from langgraph.graph import StateGraph, START, END, MessagesState +from .service import PixVerseText2VideoService +from uuid import uuid4 + + +# async def pixverse_text2video_workflow(pixverse_service: PixVerseText2VideoService, chatbot, user): +# async def generate_node(state: MessagesState): +# try: +# video_id = await pixverse_service.video_generation() +# return { +# "messages": [ToolMessage( +# tool_name="pixverse_t2v_generator", +# tool_call_id=uuid4(), +# content=f"Video generation started: {video_id}", +# )] +# } +# except Exception as e: +# print(f"pixverse text2video failed: {e}") +# raise e + +# async def check_node(state: MessagesState): +# while True: +# try: +# result = await pixverse_service.check_state() +# if result["status"] == "SUCCESS" and result["video_url"]: +# created_at = datetime.now().isoformat() +# return { +# "messages": [AIMessage( +# content=f"{result['video_url']}", +# additional_kwargs={"created_at": created_at} +# )] +# } +# elif result["status"] == "FAILED": +# print("pixverse text2video status failed") +# raise Exception("pixverse text2video status failed") +# await asyncio.sleep(5) +# except Exception as e: +# print(f"pixverse text2video failed: {e}") +# raise Exception(f"pixverse text2video failed: {e}") + +# workflow = StateGraph(state_schema=MessagesState) + +# # Add nodes +# workflow.add_node("generate", generate_node) +# workflow.add_node("check", check_node) + +# # Define edges +# workflow.add_edge(START, "generate") +# workflow.add_edge("generate", "check") +# workflow.add_edge("check", END) + +# return workflow +async def pixverse_text2video_workflow(pixverse_service: PixVerseText2VideoService, chatbot, user): + async def call_tool(state: MessagesState): + video_url = await pixverse_service.video_generation() + + ai_msg_created_at = datetime.utcnow().isoformat() + ai_msg = AIMessage(video_url) + ai_msg.additional_kwargs["created_at"] = ai_msg_created_at + + return {"messages": ai_msg} + + workflow = StateGraph(state_schema=MessagesState) + workflow.add_edge(START, "tool") + workflow.add_node("tool", call_tool) + workflow.add_edge("tool", END) + + return workflow \ No newline at end of file diff --git a/src/services/sms.py b/src/services/sms.py new file mode 100644 index 0000000..45df36d --- /dev/null +++ b/src/services/sms.py @@ -0,0 +1,64 @@ +from fastapi import HTTPException +import kavenegar +from ..config import settings +import logging + + +api = kavenegar.KavenegarAPI(settings.kavenegar_api_key) + +logger = logging.getLogger(__name__) + +def send_otp(receptor: str, otp: str): + try: + params = { + "receptor": receptor, + "token": otp, + "template": "hoshanOTP", + "type": "sms", + } + + api.verify_lookup(params) + + except kavenegar.APIException: + raise HTTPException(status_code=500, detail="APIException from Kavenegar") + except kavenegar.HTTPException: + raise HTTPException(status_code=500, detail="HTTPException from Kavenegar") + +def safe_token(value: str, default="هوشان"): + if not value: + return default + + value = str(value).strip() # حذف فاصله اول و آخر + value = value.replace(" ", "") # حذف یا جایگزینی فاصله + + if not value: + return default + + return value[:30] + + +def send_welcome_sub_user(receptor: str, parent_name: str): + print("=== SEND_WELCOME_SUB_USER ===") + print("receptor:", receptor) + print("parent_name:", parent_name) + print("============================") + + try: + params = { + "receptor": receptor, + "token": safe_token(parent_name), + "token2": "https://basaplans.houshan.ai/app", + "template": "welcomeSubUser", + "type": "sms", + } + + logger.info(f"Kavenegar params: {params}") + + api.verify_lookup(params) + + logger.info("Kavenegar request sent successfully") + + except kavenegar.APIException as e: + logger.error(f"Kavenegar APIException: {e}") + except kavenegar.HTTPException as e: + logger.error(f"Kavenegar HTTPException: {e}") \ No newline at end of file diff --git a/src/services/storage.py b/src/services/storage.py new file mode 100644 index 0000000..7a1cbf0 --- /dev/null +++ b/src/services/storage.py @@ -0,0 +1,81 @@ +import aioboto3 +from uuid import UUID +from io import BufferedReader +from fastapi import UploadFile +from datetime import datetime +from ..config import settings + +session = aioboto3.Session() + + +async def upload(id: str | UUID, file: UploadFile, prefix: str = "user"): + async with session.client( + "s3", + endpoint_url=settings.st_endpoint, + aws_access_key_id=settings.st_access_key, + aws_secret_access_key=settings.st_secret_key, + ) as s3: + mime_type = file.content_type.split("/")[0] + extension = file.filename.split(".")[-1] + + timestamp = datetime.utcnow().strftime("%Y-%m-%dT%H-%M-%S") + filename = f"{mime_type}_{timestamp}.{extension}" + + await s3.upload_fileobj( + file.file, settings.st_bucket_name, f"{prefix}/{id}/{filename}" + ) + + return f"/{prefix}/{id}/{filename}" + + +async def upload_buffer( + user_id: UUID, file: BufferedReader, mime_type: str, extension: str +): + async with session.client( + "s3", + endpoint_url=settings.st_endpoint, + aws_access_key_id=settings.st_access_key, + aws_secret_access_key=settings.st_secret_key, + ) as s3: + timestamp = datetime.utcnow().strftime("%Y-%m-%dT%H-%M-%S") + filename = f"{mime_type}_{timestamp}.{extension}" + + await s3.upload_fileobj( + file, settings.st_bucket_name, f"user/{user_id}/{filename}" + ) + + return f"/user/{user_id}/{filename}" + + +async def delete(key: str): + async with session.client( + "s3", + endpoint_url=settings.st_endpoint, + aws_access_key_id=settings.st_access_key, + aws_secret_access_key=settings.st_secret_key, + ) as s3: + await s3.delete_object(Bucket=settings.st_bucket_name, Key=key) + + +async def get(key: str): + async with session.client( + "s3", + endpoint_url=settings.st_endpoint, + aws_access_key_id=settings.st_access_key, + aws_secret_access_key=settings.st_secret_key, + ) as s3: + obj = await s3.get_object(Bucket=settings.st_bucket_name, Key=key) + + async for chunk in obj["Body"].iter_chunks(): + yield chunk + + +async def get_file_size(key: str): + async with session.client( + "s3", + endpoint_url=settings.st_endpoint, + aws_access_key_id=settings.st_access_key, + aws_secret_access_key=settings.st_secret_key, + ) as s3: + response = await s3.head_object(Bucket=settings.st_bucket_name, Key=key) + return response["ContentLength"] diff --git a/src/services/stt/__init__.py b/src/services/stt/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/services/stt/service.py b/src/services/stt/service.py new file mode 100644 index 0000000..9512f39 --- /dev/null +++ b/src/services/stt/service.py @@ -0,0 +1,9 @@ +from openai import OpenAI + +from ..database.service import DatabaseService + + +class STTService: + def __init__(self, db_service: DatabaseService): + self.db_service = db_service + self.client = OpenAI() diff --git a/src/services/stt/workflow.py b/src/services/stt/workflow.py new file mode 100644 index 0000000..c9de738 --- /dev/null +++ b/src/services/stt/workflow.py @@ -0,0 +1,33 @@ +import os +from datetime import datetime, UTC + +from langchain_community.document_loaders.parsers import OpenAIWhisperParser +from langgraph.graph import START, MessagesState, StateGraph, END +from langchain_core.messages import AIMessage +from langchain_community.document_loaders.blob_loaders import Blob + +from ..stt.service import STTService + + +async def stt_workflow(stt_service: STTService, audio: [str | None, str | None]): + async def generate_text(state: MessagesState): + parser = OpenAIWhisperParser() + + audio_text = "" + for document in parser.lazy_parse(Blob.from_path(audio[1])): + audio_text += document.page_content + + if os.path.exists(audio[1]): + os.remove(audio[1]) + + ai_msg_created_at = datetime.now(UTC) + ai_msg = AIMessage(audio_text) + ai_msg.additional_kwargs["created_at"] = ai_msg_created_at + + return {"messages": ai_msg} + + workflow = StateGraph(state_schema=MessagesState) + workflow.add_node("tool", generate_text) + workflow.add_edge(START, "tool") + workflow.add_edge("tool", END) + return workflow diff --git a/src/services/suno/__init__.py b/src/services/suno/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/services/suno/service.py b/src/services/suno/service.py new file mode 100644 index 0000000..f40684e --- /dev/null +++ b/src/services/suno/service.py @@ -0,0 +1,92 @@ +import aiohttp +import json + +from ...models import SunoTask +from ...services.database.service import DatabaseService +from ...config import settings + + +class SunoService: + def __init__(self, db_service: DatabaseService): + self.db_service = db_service + + async def initiate_suno_task(self, payload: dict, headers: dict, chatbot, user): + print("=== SunoService.initiate_suno_task START ===") + print("URL: https://api.sunoapi.org/api/v1/generate") + print("Payload:") + print(json.dumps(payload, ensure_ascii=False, indent=2)) + print("Headers:") + print(headers) + print("Chatbot:", chatbot) + print("User:", user) + + url = "https://api.sunoapi.org/api/v1/generate" + + async with aiohttp.ClientSession() as session: + async with session.post(url, json=payload, headers=headers) as resp: + print("Suno API HTTP status:", resp.status) + + raw_text = await resp.text() + print("Suno API RAW response:") + print(raw_text) + + if resp.status != 200: + raise RuntimeError( + f"Suno API error {resp.status}: {raw_text}" + ) + + data = json.loads(raw_text) + + print("Suno API PARSED response:") + print(json.dumps(data, ensure_ascii=False, indent=2)) + + task_id = data.get("data", {}).get("taskId") + print("Extracted task_id:", task_id) + + if not task_id: + raise RuntimeError("taskId not found in Suno API response") + + suno_task = await SunoTask.create( + task_id=task_id, + chatbot=chatbot, + user=user, + status="PENDING", + ) + + print("SunoTask DB record created:", suno_task) + print("=== SunoService.initiate_suno_task END ===") + + return task_id, suno_task + + @staticmethod + def prepare_suno_payload(query: str | None, bot) -> tuple[dict, dict]: + print("=== SunoService.prepare_suno_payload ===") + print("Query:", query) + print("Bot:", bot) + + payload = { + "customMode": False, + "instrumental": False, + "model": "V3_5", + "callBackUrl": "https://basa.houshan.ai/suno/callback", + "prompt": query, + "style": bot.style, + "title": bot.name, + "personaId": None, + "negativeTags": "", + "vocalGender": "m", + "styleWeight": 0.65, + "weirdnessConstraint": 0.65, + "audioWeight": 0.65, + } + + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {settings.suno_api_key}", + } + + print("Prepared payload:") + print(json.dumps(payload, ensure_ascii=False, indent=2)) + print("Prepared headers:", headers) + + return payload, headers diff --git a/src/services/suno/workflow.py b/src/services/suno/workflow.py new file mode 100644 index 0000000..acfc2f6 --- /dev/null +++ b/src/services/suno/workflow.py @@ -0,0 +1,115 @@ +import asyncio +import datetime + +from langchain_core.messages import ToolMessage, AIMessage +from langgraph.graph import StateGraph, START, END, MessagesState + +from ...services.suno.service import SunoService +import uuid + + +async def suno_workflow( + suno_service: SunoService, + payload: dict, + headers: dict, + chatbot, + user +): + print("=== suno_workflow INIT ===") + + # ----------------------------- + # Node: initiate_suno_task + # ----------------------------- + async def initiate_suno_task(state: MessagesState): + + print(">>> Node: initiate_suno_task START") + print("State messages:", state.get("messages")) + + task_id = None + ai_message_id = str(uuid.uuid4()) + + try: + task_id, suno_task = await suno_service.initiate_suno_task( + payload, + headers, + chatbot, + user + ) + + except Exception as e: + + print("ERROR calling Suno API:", str(e)) + + ai_placeholder = AIMessage( + id=ai_message_id, + content="❌ خطا در ساخت موزیک", + additional_kwargs={ + "status": "FAILED", + "error": str(e), + "created_at": datetime.datetime.now( + datetime.UTC + ).isoformat() + } + ) + + return {"messages": [ai_placeholder]} + + # ----------------------------- + # AI Placeholder Message + # ----------------------------- + created_at = datetime.datetime.now(datetime.UTC).isoformat() + + ai_placeholder = AIMessage( + id=ai_message_id, + content="🎧 در حال ساخت موزیک...", + additional_kwargs={ + "status": "PENDING", + "task_id": task_id, + "created_at": created_at, + } + ) + + # ----------------------------- + # Save mapping task → ai message + # ----------------------------- + suno_task.ai_message_id = ai_message_id + await suno_task.save() + + # ----------------------------- + # Internal Tool Message + # ----------------------------- + tool_message = ToolMessage( + content=str(task_id), + tool_name="suno_audio_generator", + tool_call_id=str(task_id), + additional_kwargs={ + "task_id": task_id, + "created_at": created_at + } + ) + + print(">>> Node: initiate_suno_task END") + + return { + "messages": [ + ai_placeholder, + tool_message + ] + } + + # ----------------------------- + # Build Graph + # ----------------------------- + graph = StateGraph(MessagesState) + + graph.add_node( + "initiate", + initiate_suno_task + ) + + graph.add_edge(START, "initiate") + graph.add_edge("initiate", END) + + print("=== suno_workflow GRAPH BUILT ===") + + return graph diff --git a/src/services/ticket.py b/src/services/ticket.py new file mode 100644 index 0000000..b4072c1 --- /dev/null +++ b/src/services/ticket.py @@ -0,0 +1,120 @@ +import math +from uuid import UUID +from collections import defaultdict, OrderedDict +from khayyam import JalaliDate +from typing import Dict +from fastapi import UploadFile +from tortoise.functions import Max, Count +from ..models import Ticket, User, TicketRole, Notification +from ..services import storage as storage_service, firebase + + +async def send(user_id: UUID, text: str, file: UploadFile | None): + file_url = None + if file: + file_url = await storage_service.upload(id=user_id, file=file) + + return await Ticket.create(user_id=user_id, text=text, file=file_url) + + +async def get_all(user_id: UUID) -> Dict[str, list[Ticket]]: + tickets: list[Ticket] = ( + await Ticket.filter(user_id=user_id).order_by("created_at").all() + ) + + grouped_tickets = defaultdict(list) + for ticket in tickets: + persian_date = JalaliDate(ticket.created_at).strftime("%d %B ماه %Y") + grouped_tickets[persian_date].append(ticket) + + sorted_grouped_tickets = OrderedDict( + sorted( + grouped_tickets.items(), + key=lambda x: JalaliDate.strptime(x[0], "%d %B ماه %Y"), + ) + ) + + return sorted_grouped_tickets + + +async def delete(user_id: UUID, id: int): + ticket: Ticket = await Ticket.get(user_id=user_id, id=id) + + if ticket.file: + await storage_service.delete(ticket.file) + + await ticket.delete() + + +##### Admin Services ##### + + +async def sendـadmin(user_id: UUID, text: str, file: UploadFile | None): + user: User = await User.get(id=user_id) + + file_url = None + if file: + file_url = await storage_service.upload(id=user_id, file=file) + + title = "تیکت جدید" + if user.firebase_token: + await firebase.send_notification( + token=user.firebase_token, + title=title, + body="پشتیبانی هوشان به تیکت شما پاسخ داد", + ) + + await Notification.create( + title=title, + message="برای مشاهده به تنظیمات، بخش تیکت‌ها مراجعه نمایید.", + user_id=user.id, + ) + + return await Ticket.create( + user_id=user_id, text=text, file=file_url, role=TicketRole.ADMIN + ) + + +async def get_all_admin(page: int): + limit = 10 + offset = (page - 1) * limit + + users_with_tickets: list[User] = ( + await User.annotate(last_ticket_time=Max("tickets__created_at")) + .filter(tickets__isnull=False) + .order_by("-last_ticket_time") + .offset(offset) + .limit(limit) + ) + + users = [] + for user in users_with_tickets: + last_ticket = await Ticket.filter(user=user).order_by("-created_at").first() + + users.append( + { + "id": user.id, + "name": user.name, + "username": user.username, + "mobile_number": user.mobile_number, + "image": user.image, + "last_ticket": { + "id": last_ticket.id, + "text": last_ticket.text, + "role": last_ticket.role, + "file": last_ticket.file, + "created_at": last_ticket.created_at, + }, + } + ) + + total_count = ( + await User.annotate(tickets=Count("tickets")).filter(tickets__gt=0).count() + ) + + return { + "users": users, + "page": page, + "total_count": total_count, + "last_page": math.ceil(total_count / limit), + } diff --git a/src/services/tool.py b/src/services/tool.py new file mode 100644 index 0000000..298311c --- /dev/null +++ b/src/services/tool.py @@ -0,0 +1,1059 @@ +import asyncio +import json +import logging +from uuid import UUID +from datetime import datetime, UTC +from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver +from langchain_core.messages import ( + HumanMessage, + AIMessage, + RemoveMessage, +) +from ._utils.validate_user_and_bot import validate_user_and_bot +from .database.service import DatabaseService +from .falai.service import FallAIService +from .falai.workflow import setup_fal_ai_workflow +from .others.service import OtherService +from .others.workflow import other_workflow +from .pixverse_img2vid.workflow import pixverse_workflow +from .pixverse_text2vid.workflow import pixverse_text2video_workflow +from .stt.service import STTService +from .stt.workflow import stt_workflow +from .suno.service import SunoService +from .suno.workflow import suno_workflow +from .translator.service import TranslatorService +from .translator.workflow import translator_workflow +from .tts.service import TTSService +from .tts.workflow import tts_workflow +from .pixverse_img2vid.service import PixVerseService +from .pixverse_text2vid.service import PixVerseText2VideoService +from .tts_openai_fm.service import OpenAIFMService +from .tts_openai_fm.workflow import tts_openai_fm_workflow +from ..config import settings +from ..models import User, Message +from .chatbot import ( + generate_title, + get_message_id, +) +from ._utils.get_or_create_chatbot import get_or_create_chatbot +from pydantic import UUID4 + + +# تنظیم لاگر +logger = logging.getLogger(__name__) + +connection_kwargs = { + "autocommit": False, + "prepare_threshold": 0, +} + + +async def _shared_logic_validate_user_and_bot( + id: int, ghost: bool, user_id: UUID4, bot_id: int, title: str = None +): + logger.info(f"Validating user {user_id} and bot {bot_id}") + + # Determine user ID and timestamp + chat_user_id = user_id + human_message_created_at = datetime.now(UTC) + + # Validate user and bot + validation_result = await validate_user_and_bot(id, bot_id, user_id) + success, error, user, bot, usage_report = validation_result + print("Usage report:", usage_report) + + # Unpack carefully - Allow extra values (Fix for 5 items return issue) + if len(validation_result) < 4: + logger.error(f"validate_user_and_bot returned too few values: {len(validation_result)}") + raise ValueError(f"Internal Error: validate_user_and_bot returned {len(validation_result)} items") + + success = validation_result[0] + error = validation_result[1] + user = validation_result[2] + bot = validation_result[3] + # Any extra values (like the 5th item) are ignored here + + if not success: + logger.warning(f"User/Bot validation failed: {error}") + raise ValueError(error) # Assuming error is a dict or string + + # Ensure chatbot exists + try: + title = title or (bot.name if id is None else None) + chatbot = await get_or_create_chatbot(id, bot_id, chat_user_id, title,ghost) + + logger.info(f"Validation successful. Chatbot ID: {chatbot.id}") + return user, chatbot, bot, human_message_created_at, chat_user_id + except ValueError as e: + logger.error(f"Error getting chatbot: {e}") + raise ValueError({"error": True, "status_code": 404, "detail": str(e)}) + + +async def _shared_logic_human_ai_messages(app, bot, query, human_message_created_at, config, chatbot): + try: + human_msg = HumanMessage( + content=query, + additional_kwargs={"created_at": human_message_created_at}, + ) + response = await app.ainvoke( + {"messages": [human_msg]}, + config, + stream_mode="messages", + ) + return json.dumps( + { + "chat_id": chatbot.id, + "chat_title": chatbot.title, + "content": response[-1][0].content, + }, + ensure_ascii=False, + ) + except asyncio.CancelledError: + # Handle cancellation gracefully + return json.dumps( + {"error": True, "detail": "Request was cancelled"}, ensure_ascii=False + ) + + +async def _shared_logic_save_messages( + app, + bot, + human_message_created_at, + config, + chatbot, + user, + input_tokens=-1, + output_tokens=-1, +): + print("==== _shared_logic_save_messages START ====") + + # 1️⃣ Get final state + state = await app.aget_state(config) + print("[A] STATE TYPE:", type(state)) + print("[A] STATE VALUES KEYS:", state.values.keys()) + + messages = state.values.get("messages", []) + print("[B] TOTAL MESSAGES COUNT:", len(messages)) + + for i, m in enumerate(messages): + print(f"[B.{i}] TYPE:", type(m)) + print(f"[B.{i}] CONTENT:", getattr(m, "content", None)) + print(f"[B.{i}] ADDITIONAL:", getattr(m, "additional_kwargs", None)) + + # 2️⃣ Split messages + human_messages = [m for m in messages if isinstance(m, HumanMessage)] + ai_messages = [m for m in messages if isinstance(m, AIMessage)] + + print("[C] HUMAN MESSAGES COUNT:", len(human_messages)) + print("[C] AI MESSAGES COUNT:", len(ai_messages)) + + # 3️⃣ Safe message IDs + human_msg_id = None + ai_msg_id = None + + if human_messages: + human_msg_id = getattr(human_messages[-1], "id", None) + + if ai_messages: + ai_msg_id = getattr(ai_messages[-1], "id", None) + + print("[D] human_msg_id:", human_msg_id) + print("[D] ai_msg_id:", ai_msg_id) + + # 4️⃣ Update user & log cost + print("[E] Saving user & message cost") + await user.save() + + await Message.create( + input_tokens=input_tokens, + output_tokens=output_tokens, + chatbot_id=chatbot.id, + cost=bot.cost, + cost_from_gift=bot.cost, + ) + + # 5️⃣ Build response safely + response = { + "credit": user.credit, + "free": user.free_credit, + "ai_message_id": ai_msg_id, + "human_message_id": human_msg_id, + "human_message_created_at": str(human_message_created_at), + } + + if messages: + response["ai_message_created_at"] = str( + messages[-1].additional_kwargs.get("created_at") + ) + else: + response["ai_message_created_at"] = None + + print("[F] FINAL RESPONSE:") + print(json.dumps(response, ensure_ascii=False, indent=2)) + + print("==== _shared_logic_save_messages END ====") + + return json.dumps(response, ensure_ascii=False) + + +async def pixverse_text2video_generator( + id: int | None, + prompt: str | None, + bot_id: int, + user_id: UUID4, + ghost: bool, +): + logger.info(f"Tool called: pixverse_text2video_generator. Prompt: {prompt}") + + # Initialize services + db_service = DatabaseService(conninfo=settings.database_url, kwargs=connection_kwargs) + pool = await db_service.get_pool() + + pixverse_service = PixVerseText2VideoService(prompt) + + try: + # 1. Validate + try: + validation_result = await _shared_logic_validate_user_and_bot(id, ghost, user_id, bot_id) + user, chatbot, bot, human_message_created_at, chat_user_id = validation_result + except ValueError as e: + logger.warning(f"Validation Error in tool: {e}") + yield json.dumps( + str(e) if isinstance(e, str) else e.args[0], ensure_ascii=False + ) + return + except Exception as e: + logger.error(f"Unexpected validation error: {e}", exc_info=True) + yield json.dumps({"error": True, "detail": f"Validation Error: {str(e)}"}, ensure_ascii=False) + return + + # 2. Setup and compile workflow + try: + workflow = await pixverse_text2video_workflow(pixverse_service, chatbot, user) + checkpointer = AsyncPostgresSaver(pool) + await checkpointer.setup() + except Exception as e: + logger.error(f"Workflow setup failed: {e}", exc_info=True) + yield json.dumps( + {"error": True, "detail": f"Failed to setup checkpointer: {str(e)}"}, + ensure_ascii=False, + ) + return + + app = workflow.compile(checkpointer=checkpointer) + config = {"configurable": {"thread_id": str(chatbot.id)}} + + content = [{"type": "text", "text": prompt, "query": prompt}] + + logger.info("Executing text2video workflow...") + result_2 = await _shared_logic_human_ai_messages( + app, bot, content, human_message_created_at, config, chatbot + ) + + result_3 = await _shared_logic_save_messages( + app, bot, human_message_created_at, config, chatbot, user + ) + + # Parse the JSON results into dictionaries + result_2_dict = json.loads(result_2) + result_3_dict = json.loads(result_3) + + # Merge the dictionaries into a single dictionary + combined_result = {**result_2_dict, **result_3_dict} + + logger.info("Workflow execution successful.") + yield json.dumps(combined_result, ensure_ascii=False) + + except Exception as e: + logger.error(f"CRITICAL ERROR in pixverse_text2video_generator: {e}", exc_info=True) + yield json.dumps( + {"error": True, "detail": f"Internal Server Error: {str(e)}"}, + ensure_ascii=False + ) + + +async def pixverse_image2video_generator( + id: int | None, + image_bytes: bytes | None, + bot_id: int, + user_id: UUID4, + ghost: bool, + effect: str, + user_image_url: str, +): + # === شروع لاگ‌گذاری برای دیباگ === + logger.info(f"Tool called: pixverse_image2video_generator. Image URL provided: {bool(user_image_url)}") + print(f"\n[DEBUG] --- Starting pixverse_image2video_generator ---") + print(f"[DEBUG] Input user_image_url: {user_image_url}") + print(f"[DEBUG] Input effect: {effect}") + # ================================= + + # Initialize services + db_service = DatabaseService(conninfo=settings.database_url, kwargs=connection_kwargs) + pool = await db_service.get_pool() + + # Pass user_image_url instead of image_bytes, as Fal AI works with URLs + if not user_image_url: + logger.warning("No image URL provided") + yield json.dumps({"error": True, "detail": "No image URL provided"}, ensure_ascii=False) + return + + # Initialize Service - (مطمئن شوید فایل service.py هم اصلاح شده باشد) + try: + pixverse_service = PixVerseService(db_service, user_image_url, effect) + print("[DEBUG] Service initialized successfully.") + except Exception as e: + print(f"[CRITICAL ERROR] Failed to initialize PixVerseService: {e}") + yield json.dumps({"error": True, "detail": f"Service Init Error: {str(e)}"}, ensure_ascii=False) + return + + try: + # 1. Validate + try: + validation_result = await _shared_logic_validate_user_and_bot(id, ghost, user_id, bot_id) + user, chatbot, bot, human_message_created_at, chat_user_id = validation_result + except ValueError as e: + logger.warning(f"Validation Error in tool: {e}") + yield json.dumps( + str(e) if isinstance(e, str) else e.args[0], ensure_ascii=False + ) + return + except Exception as e: + logger.error(f"Unexpected validation error: {e}", exc_info=True) + yield json.dumps({"error": True, "detail": f"Validation Error: {str(e)}"}, ensure_ascii=False) + return + + # 2. Setup and compile workflow + try: + workflow = await pixverse_workflow(pixverse_service, chatbot, user) + checkpointer = AsyncPostgresSaver(pool) + await checkpointer.setup() + except Exception as e: + logger.error(f"Workflow setup failed: {e}", exc_info=True) + yield json.dumps( + {"error": True, "detail": f"Failed to setup checkpointer: {str(e)}"}, + ensure_ascii=False, + ) + return + + app = workflow.compile(checkpointer=checkpointer) + config = {"configurable": {"thread_id": str(chatbot.id)}} + + content = [ + { + "type": "text", + "query": "", + "image_url": {"url": user_image_url, "attachment": False}, + } + ] + + logger.info("Executing img2video workflow...") + result_2 = await _shared_logic_human_ai_messages( + app, bot, content, human_message_created_at, config, chatbot + ) + + # do not reduce credit on failure + + result_3 = await _shared_logic_save_messages( + app, bot, human_message_created_at, config, chatbot, user + ) + + # Parse the JSON results into dictionaries + result_2_dict = json.loads(result_2) + result_3_dict = json.loads(result_3) + + # Merge the dictionaries into a single dictionary + combined_result = {**result_2_dict, **result_3_dict} + + logger.info("Workflow execution successful.") + yield json.dumps(combined_result, ensure_ascii=False) + + except NameError as e: + # اصلاح: این خط حالا داخل بلوک است و برنامه کرش نمی‌کند مگر اینکه raise کنید + print(f"[CRITICAL ERROR] Variable name mismatch inside logic: {e}") + raise e # این باعث می‌شود خطا در کنسول نمایش داده شود و پروسه متوقف شود + + except Exception as e: + logger.error(f"CRITICAL ERROR in pixverse_image2video_generator: {e}", exc_info=True) + yield json.dumps( + {"error": True, "detail": f"Internal Server Error: {str(e)}"}, + ensure_ascii=False + ) + + +async def tts_openai( + id: int | None, query: str, bot_id: int, user_id: UUID, ghost: bool, +): + # Initialize services + db_service = DatabaseService(conninfo=settings.database_url, kwargs=connection_kwargs) + pool = await db_service.get_pool() + + # Shared logic + try: + ( + user, + chatbot, + bot, + human_message_created_at, + chat_user_id, + ) = await _shared_logic_validate_user_and_bot(id, ghost, user_id, bot_id) + except ValueError as e: + yield json.dumps( + str(e) if isinstance(e, str) else e.args[0], ensure_ascii=False + ) + return + + # payload + instruction_id = bot.style.split(":")[0] + voice_id = bot.style.split(":")[1] + tts_service = OpenAIFMService(db_service, query, instruction_id, voice_id, user_id) + + # Setup and compile workflow + workflow = await tts_openai_fm_workflow(tts_service, chat_user_id) + checkpointer = AsyncPostgresSaver(pool) + + # Now set up the checkpointer + try: + await checkpointer.setup() + except Exception as e: + yield json.dumps( + {"error": True, "detail": f"Failed to setup checkpointer: {str(e)}"}, + ensure_ascii=False, + ) + return + + app = workflow.compile(checkpointer=checkpointer) + config = {"configurable": {"thread_id": str(chatbot.id)}} + + result_2 = await _shared_logic_human_ai_messages( + app, bot, query, human_message_created_at, config, chatbot + ) + + result_3 = await _shared_logic_save_messages( + app, bot, human_message_created_at, config, chatbot, user + ) + + # Parse the JSON results into dictionaries + result_2_dict = json.loads(result_2) + result_3_dict = json.loads(result_3) + + # Merge the dictionaries into a single dictionary + combined_result = {**result_2_dict, **result_3_dict} + + # Yield the combined result + yield json.dumps(combined_result, ensure_ascii=False) + + +async def suno_ai_audio_generator( + id: int | None, + query: str | None, + bot_id: int, + user_id: UUID4, + ghost: bool, +): + print("==== suno_ai_audio_generator START ====") + print("INPUTS:", { + "id": id, + "query": query, + "bot_id": bot_id, + "user_id": str(user_id), + "ghost": ghost, + }) + + # Initialize services + print("[1] Initializing services...") + db_service = DatabaseService( + conninfo=settings.database_url, + kwargs=connection_kwargs + ) + pool = await db_service.get_pool() + suno_service = SunoService(db_service) + print("[1] Services initialized") + + # Shared logic + print("[2] Running shared logic validation...") + try: + ( + user, + chatbot, + bot, + human_message_created_at, + chat_user_id, + ) = await _shared_logic_validate_user_and_bot( + id, ghost, user_id, bot_id + ) + + print("[2] Shared logic OK") + print(" user:", user) + print(" chatbot:", chatbot) + print(" bot:", bot) + print(" human_message_created_at:", human_message_created_at) + print(" chat_user_id:", chat_user_id) + + except ValueError as e: + print("[2][ERROR] Shared logic failed:", str(e)) + yield json.dumps( + str(e) if isinstance(e, str) else e.args[0], + ensure_ascii=False, + ) + return + + # Prepare Suno API payload + print("[3] Preparing Suno payload...") + suno_payload, headers = SunoService.prepare_suno_payload(query, bot) + print("[3] Suno payload:", json.dumps(suno_payload, ensure_ascii=False)) + print("[3] Suno headers:", headers) + + # Setup and compile workflow + print("[4] Creating Suno workflow...") + workflow = await suno_workflow( + suno_service, suno_payload, headers, chatbot, user + ) + print("[4] Workflow created:", workflow) + + checkpointer = AsyncPostgresSaver(pool) + + # Setup checkpointer + print("[5] Setting up checkpointer...") + try: + await checkpointer.setup() + print("[5] Checkpointer setup OK") + except Exception as e: + print("[5][ERROR] Checkpointer setup failed:", str(e)) + yield json.dumps( + {"error": True, "detail": f"Failed to setup checkpointer: {str(e)}"}, + ensure_ascii=False, + ) + return + + app = workflow.compile(checkpointer=checkpointer) + config = {"configurable": {"thread_id": str(chatbot.id)}} + print("[6] Workflow compiled") + print(" config:", config) + + # Human → AI messages + print("[7] Running _shared_logic_human_ai_messages...") + result_2 = await _shared_logic_human_ai_messages( + app, bot, query, human_message_created_at, config, chatbot + ) + print("[7] result_2 RAW:", result_2) + + # Save messages + print("[8] Running _shared_logic_save_messages...") + result_3 = await _shared_logic_save_messages( + app, bot, human_message_created_at, config, chatbot, user + ) + print("[8] result_3 RAW:", result_3) + + # Parse JSON + print("[9] Parsing results...") + result_2_dict = json.loads(result_2) + result_3_dict = json.loads(result_3) + + print("[9] result_2 PARSED:", result_2_dict) + print("[9] result_3 PARSED:", result_3_dict) + + # Merge results + combined_result = {**result_2_dict, **result_3_dict} + print("[10] COMBINED RESULT:") + print(json.dumps(combined_result, ensure_ascii=False, indent=2)) + + print("==== suno_ai_audio_generator END ====") + + yield json.dumps(combined_result, ensure_ascii=False) + + + +async def text_to_speech( + id: int | None, query: str, bot_id: int, user_id: UUID, ghost: bool +): + # Initialize services + db_service = DatabaseService(conninfo=settings.database_url, kwargs=connection_kwargs) + pool = await db_service.get_pool() + tts_service = TTSService(db_service) + + # Shared logic + try: + ( + user, + chatbot, + bot, + human_message_created_at, + chat_user_id, + ) = await _shared_logic_validate_user_and_bot(id, ghost, user_id, bot_id) + except ValueError as e: + yield json.dumps( + str(e) if isinstance(e, str) else e.args[0], ensure_ascii=False + ) + return + + # Setup and compile workflow + workflow = await tts_workflow(tts_service, chat_user_id) + checkpointer = AsyncPostgresSaver(pool) + + # Now set up the checkpointer + try: + await checkpointer.setup() + except Exception as e: + yield json.dumps( + {"error": True, "detail": f"Failed to setup checkpointer: {str(e)}"}, + ensure_ascii=False, + ) + return + + app = workflow.compile(checkpointer=checkpointer) + config = {"configurable": {"thread_id": str(chatbot.id)}} + + result_2 = await _shared_logic_human_ai_messages( + app, bot, query, human_message_created_at, config, chatbot + ) + + result_3 = await _shared_logic_save_messages( + app, bot, human_message_created_at, config, chatbot, user + ) + + # Parse the JSON results into dictionaries + result_2_dict = json.loads(result_2) + result_3_dict = json.loads(result_3) + + # Merge the dictionaries into a single dictionary + combined_result = {**result_2_dict, **result_3_dict} + + # Yield the combined result + yield json.dumps(combined_result, ensure_ascii=False) + + +async def speech_to_text( + id: int | None, + bot_id: int, + user_id: UUID, + ghost: bool, + audio: [str | None, str | None], +): + # Initialize services + db_service = DatabaseService(conninfo=settings.database_url, kwargs=connection_kwargs) + pool = await db_service.get_pool() + stt_service = STTService(db_service) + + # Shared logic + try: + ( + user, + chatbot, + bot, + human_message_created_at, + chat_user_id, + ) = await _shared_logic_validate_user_and_bot(id, ghost, user_id, bot_id) + except ValueError as e: + yield json.dumps( + str(e) if isinstance(e, str) else e.args[0], ensure_ascii=False + ) + return + + # payload + content = [ + { + "type": "text", + "text": "", + "audio_url": {"url": audio[0], "attachment": False}, + } + ] + + # Setup and compile workflow + workflow = await stt_workflow(stt_service, audio) + checkpointer = AsyncPostgresSaver(pool) + + # Now set up the checkpointer + try: + await checkpointer.setup() + except Exception as e: + yield json.dumps( + {"error": True, "detail": f"Failed to setup checkpointer: {str(e)}"}, + ensure_ascii=False, + ) + return + + app = workflow.compile(checkpointer=checkpointer) + config = {"configurable": {"thread_id": str(chatbot.id)}} + + yield await _shared_logic_human_ai_messages( + app, bot, content, human_message_created_at, config, chatbot + ) + + yield await _shared_logic_save_messages( + app, bot, human_message_created_at, config, chatbot, user + ) + + +async def translator( + id: int | None, query: str, bot_id: int, user_id: UUID, ghost: bool +): + # Initialize services + db_service = DatabaseService(conninfo=settings.database_url, kwargs=connection_kwargs) + pool = await db_service.get_pool() + translator_service = TranslatorService(db_service) + + # Shared logic + try: + ( + user, + chatbot, + bot, + human_message_created_at, + chat_user_id, + ) = await _shared_logic_validate_user_and_bot(id, ghost, user_id, bot_id) + except ValueError as e: + yield json.dumps( + str(e) if isinstance(e, str) else e.args[0], ensure_ascii=False + ) + return + + # Setup and compile workflow + workflow = await translator_workflow(translator_service, bot) + checkpointer = AsyncPostgresSaver(pool) + + # Now set up the checkpointer + try: + await checkpointer.setup() + except Exception as e: + yield json.dumps( + {"error": True, "detail": f"Failed to setup checkpointer: {str(e)}"}, + ensure_ascii=False, + ) + return + + app = workflow.compile(checkpointer=checkpointer) + config = {"configurable": {"thread_id": str(chatbot.id)}} + + # custom logic + yield json.dumps( + { + "chat_id": chatbot.id, + "chat_title": chatbot.title, + }, + ensure_ascii=False, + ) + + input_tokens = 0 + output_tokens = 0 + content = [{"type": "text", "text": query}] + human_msg = HumanMessage(content=content) + human_msg.additional_kwargs["created_at"] = human_message_created_at + input_messages = [human_msg] + + async for chunk, metadata in app.astream( + {"messages": input_messages}, config, stream_mode="messages" + ): + if isinstance(chunk, AIMessage): + if chunk.usage_metadata: + input_tokens += chunk.usage_metadata["input_tokens"] + output_tokens += chunk.usage_metadata["output_tokens"] + + yield json.dumps( + {"content": chunk.content}, + ensure_ascii=False, + ) + + yield await _shared_logic_save_messages( + app, bot, human_message_created_at, config, chatbot, user + ) + + +async def fal_ai_image_generation( + id: int | None, query: str, bot_id: int, user_id: UUID, ghost: bool +): + # Initialize services + db_service = DatabaseService(conninfo=settings.database_url, kwargs=connection_kwargs) + pool = await db_service.get_pool() + + # Shared logic + try: + ( + user, + chatbot, + bot, + human_message_created_at, + chat_user_id, + ) = await _shared_logic_validate_user_and_bot(id, ghost, user_id, bot_id) + except ValueError as e: + yield json.dumps( + str(e) if isinstance(e, str) else e.args[0], ensure_ascii=False + ) + return + + # Setup and compile workflow + workflow = await setup_fal_ai_workflow(FallAIService().image_generation, query, bot) + checkpointer = AsyncPostgresSaver(pool) + + # Now set up the checkpointer + try: + await checkpointer.setup() + except Exception as e: + yield json.dumps( + {"error": True, "detail": f"Failed to setup checkpointer: {str(e)}"}, + ensure_ascii=False, + ) + return + + app = workflow.compile(checkpointer=checkpointer) + config = {"configurable": {"thread_id": str(chatbot.id)}} + + content = [{"type": "text", "text": query, "query": query}] + result_2 = await _shared_logic_human_ai_messages( + app, bot, content, human_message_created_at, config, chatbot + ) + + result_3 = await _shared_logic_save_messages( + app, bot, human_message_created_at, config, chatbot, user + ) + + # Parse the JSON results into dictionaries + result_2_dict = json.loads(result_2) + result_3_dict = json.loads(result_3) + + # Merge the dictionaries into a single dictionary + combined_result = {**result_2_dict, **result_3_dict} + + # Yield the combined result + yield json.dumps(combined_result, ensure_ascii=False) + + +async def fal_ai_image_to_image_generation( + id: int | None, image: str, bot_id: int, user_id: UUID, ghost: bool +): + # Initialize services + db_service = DatabaseService(conninfo=settings.database_url, kwargs=connection_kwargs) + pool = await db_service.get_pool() + + # Shared logic + try: + ( + user, + chatbot, + bot, + human_message_created_at, + chat_user_id, + ) = await _shared_logic_validate_user_and_bot(id, ghost, user_id, bot_id) + except ValueError as e: + yield json.dumps( + str(e) if isinstance(e, str) else e.args[0], ensure_ascii=False + ) + return + + # payload + content = [ + { + "type": "text", + "query": "", + "image_url": {"url": image, "attachment": False}, + } + ] + + # Setup and compile workflow + workflow = await setup_fal_ai_workflow( + FallAIService().image_to_image_generation, image, bot + ) + checkpointer = AsyncPostgresSaver(pool) + + # Now set up the checkpointer + try: + await checkpointer.setup() + except Exception as e: + yield json.dumps( + {"error": True, "detail": f"Failed to setup checkpointer: {str(e)}"}, + ensure_ascii=False, + ) + return + + app = workflow.compile(checkpointer=checkpointer) + config = {"configurable": {"thread_id": str(chatbot.id)}} + + result_2 = await _shared_logic_human_ai_messages( + app, bot, content, human_message_created_at, config, chatbot + ) + + result_3 = await _shared_logic_save_messages( + app, bot, human_message_created_at, config, chatbot, user + ) + + # Parse the JSON results into dictionaries + result_2_dict = json.loads(result_2) + result_3_dict = json.loads(result_3) + + # Merge the dictionaries into a single dictionary + combined_result = {**result_2_dict, **result_3_dict} + + # Yield the combined result + yield json.dumps(combined_result, ensure_ascii=False) + + +async def fal_ai_image_personalization( + id: int | None, image: str, bot_id: int, user_id: UUID, ghost: bool +): + # Initialize services + db_service = DatabaseService(conninfo=settings.database_url, kwargs=connection_kwargs) + pool = await db_service.get_pool() + + user, chatbot, bot, human_message_created_at, chat_user_id = await _shared_logic_validate_user_and_bot( + id, ghost, user_id, bot_id + ) + + content = [ + { + "type": "text", + "query": "", + "image_url": {"url": image, "attachment": False}, + } + ] + + # Setup and compile workflow + workflow = await setup_fal_ai_workflow( + FallAIService().image_personalization, image, bot + ) + checkpointer = AsyncPostgresSaver(pool) + + # Now set up the checkpointer + try: + await checkpointer.setup() + except Exception as e: + yield json.dumps( + {"error": True, "detail": f"Failed to setup checkpointer: {str(e)}"}, + ensure_ascii=False, + ) + return + + app = workflow.compile(checkpointer=checkpointer) + config = {"configurable": {"thread_id": str(chatbot.id)}} + + result_2 = await _shared_logic_human_ai_messages( + app, bot, content, human_message_created_at, config, chatbot + ) + + result_3 = await _shared_logic_save_messages( + app, bot, human_message_created_at, config, chatbot, user + ) + + # Parse the JSON results into dictionaries + result_2_dict = json.loads(result_2) + result_3_dict = json.loads(result_3) + + # Merge the dictionaries into a single dictionary + combined_result = {**result_2_dict, **result_3_dict} + + # Yield the combined result + yield json.dumps(combined_result, ensure_ascii=False) + + +async def others( + id: int | None, + query: str, + bot_id: int, + user_id: UUID4, + retry: bool, + ghost: bool, + image: str | None, + audio: [str | None, str | None], + doc: [str | None, str | None], +): + # Initialize services + db_service = DatabaseService(conninfo=settings.database_url, kwargs=connection_kwargs) + pool = await db_service.get_pool() + others_service = OtherService(query=query, image=image, audio=audio, doc=doc) + + content = others_service.create_content() + + title = None + if id is None: + title = await generate_title(content[0]["text"]) + + # Shared logic + try: + ( + user, + chatbot, + bot, + human_message_created_at, + chat_user_id, + ) = await _shared_logic_validate_user_and_bot(id, ghost, user_id, bot_id, title) + except ValueError as e: + yield json.dumps( + str(e) if isinstance(e, str) else e.args[0], ensure_ascii=False + ) + return + + # payload + bot_owner: User = await User.get_or_none(id=bot.user_id) + + # Setup and compile workflow + workflow = await other_workflow(bot, bot_owner, bot_id) + checkpointer = AsyncPostgresSaver(pool) + + # Now set up the checkpointer + try: + await checkpointer.setup() + except Exception as e: + yield json.dumps( + {"error": True, "detail": f"Failed to setup checkpointer: {str(e)}"}, + ensure_ascii=False, + ) + return + + app = workflow.compile(checkpointer=checkpointer) + config = {"configurable": {"thread_id": str(chatbot.id)}} + + if retry: + state = await app.aget_state(config) + messages = state.values["messages"] + + if messages: + last_human_index = max( + i for i, msg in enumerate(messages) if isinstance(msg, HumanMessage) + ) + + await app.aupdate_state( + config, + { + "messages": [ + RemoveMessage(id=msg.id) + for msg in messages[last_human_index + 1 :] + ] + }, + ) + + yield json.dumps( + { + "chat_id": chatbot.id, + "chat_title": chatbot.title, + }, + ensure_ascii=False, + ) + + input_tokens = 0 + output_tokens = 0 + + human_msg = HumanMessage(content=content) + human_msg.additional_kwargs["created_at"] = human_message_created_at + input_messages = [human_msg] + + async for chunk, metadata in app.astream( + {"messages": input_messages}, + config, + stream_mode="messages", + ): + if isinstance(chunk, AIMessage): + if chunk.usage_metadata: + input_tokens += chunk.usage_metadata["input_tokens"] + output_tokens += chunk.usage_metadata["output_tokens"] + + yield json.dumps( + {"content": chunk.content}, + ensure_ascii=False, + ) + + # if bot_owner and bot_owner.id != user_id: + # bot_owner.income += bot.cost * 0.1 + # await bot_owner.save() + + yield await _shared_logic_save_messages( + app, bot, human_message_created_at, config, chatbot, user + ) \ No newline at end of file diff --git a/src/services/translator/__init__.py b/src/services/translator/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/services/translator/service.py b/src/services/translator/service.py new file mode 100644 index 0000000..3cb7c83 --- /dev/null +++ b/src/services/translator/service.py @@ -0,0 +1,9 @@ +from ..database.service import DatabaseService +from ...config import settings + + +class TranslatorService: + def __init__(self, db_service: DatabaseService): + self.db_service = db_service + self.model="gpt-4o-mini" + self.api_key=settings.openai_api_key \ No newline at end of file diff --git a/src/services/translator/workflow.py b/src/services/translator/workflow.py new file mode 100644 index 0000000..48f834a --- /dev/null +++ b/src/services/translator/workflow.py @@ -0,0 +1,33 @@ +from datetime import datetime, UTC + +from langchain_openai import ChatOpenAI +from langgraph.graph import START, MessagesState, StateGraph, END +from langchain_core.messages import HumanMessage, SystemMessage + +from ..translator.service import TranslatorService + + +async def translator_workflow(translator_service: TranslatorService, bot): + async def translate(state: MessagesState): + kwargs = { "stream_usage": True } + model = ChatOpenAI( + model=translator_service.model, + api_key=translator_service.api_key + ) + response = await model.ainvoke( + [ + SystemMessage(bot.prompt), + HumanMessage(state["messages"][-1].content[0]["text"]), + ], + **kwargs, + ) + ai_msg_created_at = datetime.now(UTC).__str__() + response.additional_kwargs["created_at"] = ai_msg_created_at + + return {"messages": response} + + workflow = StateGraph(state_schema=MessagesState) + workflow.add_node("tool", translate) + workflow.add_edge(START, "tool") + workflow.add_edge("tool", END) + return workflow diff --git a/src/services/tts/__init__.py b/src/services/tts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/services/tts/service.py b/src/services/tts/service.py new file mode 100644 index 0000000..ab08c60 --- /dev/null +++ b/src/services/tts/service.py @@ -0,0 +1,9 @@ +from openai import OpenAI + +from ..database.service import DatabaseService + + +class TTSService: + def __init__(self, db_service: DatabaseService): + self.db_service = db_service + self.client = OpenAI() diff --git a/src/services/tts/workflow.py b/src/services/tts/workflow.py new file mode 100644 index 0000000..01d55df --- /dev/null +++ b/src/services/tts/workflow.py @@ -0,0 +1,43 @@ +import os +from pathlib import Path +from datetime import datetime, UTC + +from langgraph.graph import START, MessagesState, StateGraph, END +from langchain_core.messages import AIMessage + +from ..tts.service import TTSService +from .._utils.local_upload_file import LocalUploadFile +from .. import storage as storage_service +from ...config import settings + + +async def tts_workflow(tts_service: TTSService, chat_user_id: str): + async def generate_audio(state: MessagesState): + speech_file_path = str(Path(__file__).parent / f"${chat_user_id}.mp3") + response = tts_service.client.audio.speech.create( + model="tts-1", + voice="echo", + input=state["messages"][-1].content, + ) + + await response.astream_to_file(speech_file_path) + + file = LocalUploadFile(speech_file_path) + file_url = await storage_service.upload( + id=chat_user_id, file=file + ) + + if os.path.exists(speech_file_path): + os.remove(speech_file_path) + + ai_msg_created_at = datetime.now(UTC) + ai_msg = AIMessage(settings.base_url + file_url) + ai_msg.additional_kwargs["created_at"] = ai_msg_created_at + + return {"messages": ai_msg} + + workflow = StateGraph(state_schema=MessagesState) + workflow.add_node("tool", generate_audio) + workflow.add_edge(START, "tool") + workflow.add_edge("tool", END) + return workflow diff --git a/src/services/tts_openai_fm/__init__.py b/src/services/tts_openai_fm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/services/tts_openai_fm/service.py b/src/services/tts_openai_fm/service.py new file mode 100644 index 0000000..c7e6967 --- /dev/null +++ b/src/services/tts_openai_fm/service.py @@ -0,0 +1,72 @@ +import os + +from openai import AsyncOpenAI +from uuid import uuid4, UUID + +from ..database.service import DatabaseService +from .._utils.local_upload_file import LocalUploadFile +from .. import storage as storage_service + +instructions = { + "Mad Scientist": """Delivery: Exaggerated and theatrical, with dramatic pauses, sudden outbursts, and gleeful cackling.\n\nVoice: High-energy, eccentric, and slightly unhinged, with a manic enthusiasm that rises and falls unpredictably.\n\nTone: Excited, chaotic, and grandiose, as if reveling in the brilliance of a mad experiment.\n\nPronunciation: Sharp and expressive, with elongated vowels, sudden inflections, and an emphasis on big words to sound more diabolical.""", + "Noir Detective": """Affect: a mysterious noir detective\n\nTone: Cool, detached, but subtly reassuring—like they've seen it all and know how to handle a missing package like it's just another case.\n\nDelivery: Slow and deliberate, with dramatic pauses to build suspense, as if every detail matters in this investigation.\n\nEmotion: A mix of world-weariness and quiet determination, with just a hint of dry humor to keep things from getting too grim.\n\nPunctuation: Short, punchy sentences with ellipses and dashes to create rhythm and tension, mimicking the inner monologue of a detective piecing together clues.""", + "Sympathetic": """Voice: Warm, empathetic, and professional, reassuring the customer that their issue is understood and will be resolved.\n\nPunctuation: Well-structured with natural pauses, allowing for clarity and a steady, calming flow.\n\nDelivery: Calm and patient, with a supportive and understanding tone that reassures the listener.\n\nPhrasing: Clear and concise, using customer-friendly language that avoids jargon while maintaining professionalism.\n\nTone: Empathetic and solution-focused, emphasizing both understanding and proactive assistance.""", + "Calm": """Voice Affect: Calm, composed, and reassuring; project quiet authority and confidence.\n\nTone: Sincere, empathetic, and gently authoritative—express genuine apology while conveying competence.\n\nPacing: Steady and moderate; unhurried enough to communicate care, yet efficient enough to demonstrate professionalism.\n\nEmotion: Genuine empathy and understanding; speak with warmth, especially during apologies ("I'm very sorry for any disruption...").\n\nPronunciation: Clear and precise, emphasizing key reassurances ("smoothly," "quickly," "promptly") to reinforce confidence.\n\nPauses: Brief pauses after offering assistance or requesting details, highlighting willingness to listen and support.""", + "True Crime Buff": """Voice: Deep, hushed, and enigmatic, with a slow, deliberate cadence that draws the listener in.\n\nPhrasing: Sentences are short and rhythmic, building tension with pauses and carefully placed suspense.\n\nPunctuation: Dramatic pauses, ellipses, and abrupt stops enhance the feeling of unease and anticipation.\n\nTone: Dark, ominous, and foreboding, evoking a sense of mystery and the unknown.""", + "Smooth Jazz DJ": """Voice: The voice should be deep, velvety, and effortlessly cool, like a late-night jazz radio host.\n\nTone: The tone is smooth, laid-back, and inviting, creating a relaxed and easygoing atmosphere.\n\nPersonality: The delivery exudes confidence, charm, and a touch of playful sophistication, as if guiding the listener through a luxurious experience.\n\nPronunciation: Words should be drawn out slightly with a rhythmic, melodic quality, emphasizing key phrases with a silky flow.\n\nPhrasing: Sentences should be fluid, conversational, and slightly poetic, with pauses that let the listener soak in the cool, jazzy vibe.""", +} + +voices = { + "Alloy": "alloy", + "Ash": "ash", + "Ballad": "ballad", + "Coral": "coral", + "Echo": "echo", + "Fable": "fable", + "Onyx": "onyx", + "Nova": "nova", + "Sage": "sage", + "Shimmer": "shimmer", + "Verse": "verse", +} + +class OpenAIFMService: + def __init__(self, db_service: DatabaseService, text: str, instruction_id: str, voice_id: str, chat_user_id: UUID): + self.db_service = db_service + self.client = AsyncOpenAI() + self.text = text + self.voice = voices[voice_id] + self.instruction = instructions[instruction_id] + self.chat_user_id = chat_user_id + + async def generate_speech(self) -> dict: + # Create the speech + response = await self.client.audio.speech.create( + model="gpt-4o-mini-tts", + voice=self.voice, + input=self.text, + instructions=self.instruction, + response_format="mp3" + ) + + # Save the audio content + output_file = f"{uuid4()}.mp3" + with open(output_file, "wb") as f: + f.write(response.content) + + # Upload using LocalUploadFile and storage_service + local_file = LocalUploadFile(output_file) + uploaded_url = await storage_service.upload( + id=self.chat_user_id, + file=local_file, + ) + + # Clean up local file + os.remove(output_file) + + # close client + await self.client.close() + + return { + "url": uploaded_url, + } diff --git a/src/services/tts_openai_fm/workflow.py b/src/services/tts_openai_fm/workflow.py new file mode 100644 index 0000000..1273fe8 --- /dev/null +++ b/src/services/tts_openai_fm/workflow.py @@ -0,0 +1,25 @@ +from datetime import datetime, UTC + +from langgraph.graph import START, MessagesState, StateGraph, END +from langchain_core.messages import AIMessage + +from .service import OpenAIFMService +from ...config import settings + + +async def tts_openai_fm_workflow(tts_service: OpenAIFMService, chat_user_id: str): + async def generate_audio(state: MessagesState): + response = await tts_service.generate_speech() + file_url = response.get("url") + + ai_msg_created_at = datetime.now(UTC) + ai_msg = AIMessage(settings.base_url + file_url) + ai_msg.additional_kwargs["created_at"] = ai_msg_created_at + + return {"messages": ai_msg} + + workflow = StateGraph(state_schema=MessagesState) + workflow.add_node("tool", generate_audio) + workflow.add_edge(START, "tool") + workflow.add_edge("tool", END) + return workflow diff --git a/src/services/user.py b/src/services/user.py new file mode 100644 index 0000000..5c77a63 --- /dev/null +++ b/src/services/user.py @@ -0,0 +1,686 @@ +import re +import math +import string +import secrets +from uuid import UUID +from tortoise.expressions import Q ,F +from tortoise.functions import Sum +from collections import defaultdict +from tortoise.queryset import Prefetch +from tortoise import Tortoise +from fastapi import HTTPException, UploadFile +from datetime import datetime, timezone, date, timedelta +from ..dependencies import pwd_context +from ..messages import MESSAGES +from ..services import ( + auth as auth_service, + otp as otp_service, + storage as storage_service, + sms as sms_service, + +) +from ..models import ( + User, + Billing, + BillingType, + BillingStatus, + Message, + Notification, + UserRole, + Code, +) +from ..schemas.user import TelegramSyncRequest + +# ... (توابع کمکی و احراز هویت بدون تغییر باقی می‌مانند) ... +async def generate_unique_code() -> str: + while True: + characters = string.ascii_letters + string.digits + code = "".join(secrets.choice(characters) for _ in range(6)) + user = await User.get_or_none(code=code.lower()) + if user is None: + return code.lower() + +# async def get_user(id: UUID): +# user = await User.get_or_none(id=id).prefetch_related( +# Prefetch("notifications", queryset=Notification.all().order_by("-created_at")) +# ).select_related("parent") +# if user is None: +# raise HTTPException(status_code=404, detail="User not found") +# return {**user.__dict__, "notifications": user.notifications[:20], "parent": user.parent} + +# async def get_userAndVerify(id: UUID): +# user = await User.get_or_none(id=id,verified=True).prefetch_related( +# Prefetch("notifications", queryset=Notification.all().order_by("-created_at")) +# ).select_related("parent") +# if user is None: +# raise HTTPException(status_code=404, detail="User not found") +# return {**user.__dict__, "notifications": user.notifications[:20], "parent": user.parent} +async def get_userAndVerify(id: UUID): + print("==== get_userAndVerify START ====") + print("INPUT ID:", id) + + user = await User.get_or_none(id=id, verified=True) \ + .prefetch_related( + Prefetch( + "notifications", + queryset=Notification.all().order_by("-created_at") + ) + ) \ + .select_related("parent") + + print("USER (verified=True):", user) + + if user is None: + user_any = await User.get_or_none(id=id) + print("USER ANY:", user_any) + print("VERIFIED:", getattr(user_any, "verified", None)) + print("==== get_userAndVerify END (NOT FOUND) ====") + raise HTTPException(status_code=404, detail="User not found or not verified") + + print("NOTIFICATIONS COUNT:", len(user.notifications)) + print("PARENT:", user.parent) + print("==== get_userAndVerify END (OK) ====") + + return { + **user.__dict__, + "notifications": user.notifications[:20], + "parent": user.parent, + } + + + +async def get_admin_user(username: str): + user = await User.get_or_none(username=username) + return {**user.__dict__, "notifications": []} + +async def create_user(contact: str, is_email: bool = False): + code = await generate_unique_code() + user_data = {"code": code, "username": code} + if is_email: + user_data["email"] = contact + else: + user_data["mobile_number"] = contact + return await User.create(**user_data) + +async def create_telegram_user(telegram_id: str): + code = await generate_unique_code() + user_data = {"code": code, "username": code, "telegram_id": telegram_id} + return await User.create(**user_data) + +async def register(mobile_number: str): + user: User = await User.get_or_none(mobile_number=mobile_number) + if user is not None and user.verified: + raise HTTPException(status_code=409, detail="The mobile number is already registered") + elif user is None: + await create_user(mobile_number) + await otp_service.generate(mobile_number) + +async def telegram_login(telegram_id: str): + user: User = await User.get_or_none(telegram_id=telegram_id) + if user is None: + user = await create_telegram_user(telegram_id) + access_token = await auth_service.create_access_token({"id": str(user.id)}) + return { "user": user, "access_token": access_token } + +async def telegram_sync(request: TelegramSyncRequest): + telegram_user: User = await User.get_or_none(telegram_id=request.telegram_id) + if not telegram_user: + return None + email_user: User | None = None + phone_user: User | None = None + if request.email: + email_user= await User.get_or_none(email=request.email) + if request.mobile_number: + phone_user = await User.get_or_none(mobile_number=request.mobile_number) + user = email_user if email_user is not None else phone_user + if not user: + return None + if telegram_user.mobile_number or telegram_user.email: + return None + if user.id == telegram_user.telegram_id: + return None + if request.password: + if not pwd_context.verify(request.password, user.password): + return None + if user.otp != request.otp: + return None + elif user.otp_expires_at < datetime.now(timezone.utc): + return None + user.telegram_id = request.telegram_id + user.credit = user.credit + telegram_user.credit + await user.save() + await telegram_user.delete() + return user + +async def login(username: str, password: str): + user: User = await User.get_or_none(username=username) + if user is None: + raise HTTPException(status_code=404, detail=MESSAGES["user"]["not_found"]) + if not pwd_context.verify(password, user.password): + raise HTTPException(status_code=403, detail=MESSAGES["user"]["wrong_pass"]) + # if not user.verified: + # await Notification.create(title=MESSAGES["user"]["welcome"][0], message=MESSAGES["user"]["welcome"][1], user_id=user.id) + user.verified = True + await user.save() + return await auth_service.create_access_token({"id": str(user.id)}) + +async def login_with_otp(contact: str, otp: str): + is_email = re.match(r"^[^@]+@[^@]+\.[^@]+$", contact) is not None + filter_param = {"mobile_number": contact} + + user: User = await User.get_or_none(**filter_param) + + # اگر کاربر نباشد خطا می‌دهد (درست است) + if user is None: + raise HTTPException(status_code=404, detail="User not found") + + if user.otp != otp: + raise HTTPException(status_code=403, detail="OTP is wrong") + elif user.otp_expires_at < datetime.now(timezone.utc): + raise HTTPException(status_code=403, detail="OTP is expired") + + # if not user.verified: + # await Notification.create( + # title=MESSAGES["user"]["welcome"][0], + # message=MESSAGES["user"]["welcome"][1], + # user_id=user.id + # ) + + # اصلاح مهم: کاربر بعد از لاگین موفق باید وریفای شود، نه فالس! + user.verified = True + user.otp = None + user.otp_expires_at = None + await user.save() + + return await auth_service.create_access_token({"id": str(user.id)}) + +async def check_username(username: str): + user = await User.get_or_none(username=username) + return user is None + +async def edit_username(user_id: UUID, username: str): + user: User = await User.get_or_none(id=user_id) + if user is None: + raise HTTPException(status_code=404, detail="User not found") + user.username = username + await user.save() + +async def edit_card_number(user_id: UUID, card_number: str): + user: User = await User.get_or_none(id=user_id) + if user is None: + raise HTTPException(status_code=404, detail="User not found") + user.card_number = card_number + await user.save() + +async def set_firebase_token(user_id: UUID, token: str): + user: User = await User.get_or_none(id=user_id) + if user is None: + raise HTTPException(status_code=404, detail="User not found") + user.firebase_token = token + await user.save() + +async def edit_profile(user_id: UUID, file: UploadFile): + user: User = await User.get_or_none(id=user_id) + if user is None: + raise HTTPException(status_code=404, detail="User not found") + url = await storage_service.upload(id=user_id, file=file) + user.image = url + await user.save() + return url + +async def delete_profile(user_id: UUID): + user: User = await User.get_or_none(id=user_id) + if user is None: + raise HTTPException(status_code=404, detail="User not found") + if user.image is None: + return + await storage_service.delete(user.image) + user.image = None + await user.save() + +async def edit_password(user_id: UUID, password: str): + user: User = await User.get_or_none(id=user_id) + if user is None: + raise HTTPException(status_code=404, detail="User not found") + hashed_password = pwd_context.hash(password) + user.password = hashed_password + await user.save() + +# --- توابع گزارش‌گیری با لاگ دقیق --- + +async def coin_usage_report( + user_id: UUID, start_date: date | None, end_date: date | None +): + print(f"\n======== [Coin Usage Report] Start for User: {user_id} ========") + + # ۱. فیلترهای پایه (زمانی) + time_filters = Q() + if start_date: + start_date_dt = datetime.combine(start_date, datetime.min.time()) + start_date_dt -= timedelta(hours=3, minutes=30) + time_filters &= Q(created_at__gte=start_date_dt) + + if end_date: + end_date_dt = datetime.combine(end_date, datetime.max.time()) + end_date_dt -= timedelta(hours=3, minutes=30) + time_filters &= Q(created_at__lte=end_date_dt) + + report = defaultdict(lambda: {"messages_count": 0, "coin_usage": 0}) + + # ۲. محاسبه مصرف خود کاربر + user_filters = Q(chatbot__user_id=user_id) & time_filters + user_messages = ( + await Message.filter(user_filters) + .prefetch_related("chatbot__bot") + .values("chatbot__bot__id", "chatbot__bot__name", "chatbot__bot__cost") + ) + + for msg in user_messages: + bot_name = msg["chatbot__bot__name"] + bot_cost = msg["chatbot__bot__cost"] + report[bot_name]["messages_count"] += 1 + report[bot_name]["coin_usage"] += bot_cost + + # ۳. محاسبه مصرف زیرمجموعه‌ها (فقط آنهایی که از Gift والد کسر شده) + + # دریافت لیست زیرمجموعه‌ها به همراه نام برای لاگ + sub_users_data = await User.filter(parent_id=user_id).values("id", "username", "name") + sub_user_ids = [u["id"] for u in sub_users_data] + + print(f"[LOG] Found {len(sub_user_ids)} sub-users: {sub_users_data}") + + if sub_user_ids: + # فیلتر پیام‌هایی که توسط این زیرمجموعه‌ها ارسال شده و از گیفت کم شده + sub_filters = Q(chatbot__user_id__in=sub_user_ids) & time_filters + sub_filters &= Q(cost_from_gift__gt=0) + + # برای لاگ دقیق، یوزرنیم کاربر مصرف کننده را هم می‌گیریم + sub_messages = ( + await Message.filter(sub_filters) + .select_related("chatbot__user") + .values("cost_from_gift", "chatbot__user__username") + ) + + total_sub_gift_usage = 0 + sub_msg_count = 0 + + # دیکشنری برای لاگ مصرف هر زیرمجموعه + sub_usage_breakdown = defaultdict(int) + + for msg in sub_messages: + cost = msg.get("cost_from_gift", 0) or 0 + username = msg.get("chatbot__user__username", "Unknown") + + sub_usage_breakdown[username] += cost + total_sub_gift_usage += cost + sub_msg_count += 1 + + print(f"[LOG] Sub-user Usage Breakdown (Gift Only): {dict(sub_usage_breakdown)}") + print(f"[LOG] Total Sub-user Usage: {total_sub_gift_usage}, Total Messages: {sub_msg_count}") + + # ۴. اضافه کردن زیرمجموعه به گزارش به عنوان یک آیتم جدا + if total_sub_gift_usage > 0: + report["زیرمجموعه‌ها"]["messages_count"] += sub_msg_count + report["زیرمجموعه‌ها"]["coin_usage"] += total_sub_gift_usage + else: + print("[LOG] No sub-users found.") + + print("======== [Coin Usage Report] End ========\n") + + # ۵. مرتب‌سازی و خروجی + sorted_report = sorted( + [ + { + "bot_name": bot_name, + "messages_count": data["messages_count"], + "coin_usage": data["coin_usage"], + } + for bot_name, data in report.items() + if data["coin_usage"] > 0 + ], + key=lambda x: x["coin_usage"], + reverse=True, + ) + + top_5 = sorted_report[:5] + others = sorted_report[5:] + if others: + others_aggregated = { + "bot_name": "سایر", + "messages_count": sum(item["messages_count"] for item in others), + "coin_usage": sum(item["coin_usage"] for item in others), + } + top_5.append(others_aggregated) + + return top_5 + + +async def periodic_usage_report(user_id: UUID, start_date: date, end_date: date): + print(f"\n======== [Periodic Usage Report] Start for User: {user_id} ========") + # تبدیل تاریخ‌ها + start_date_dt = datetime.combine(start_date, datetime.min.time()) - timedelta(hours=3, minutes=30) + end_date_dt = datetime.combine(end_date, datetime.max.time()) - timedelta(hours=3, minutes=30) + + report = defaultdict(lambda: {"messages_count": 0, "coin_usage": 0}) + + # ۱. مصرف خود کاربر + user_msgs = ( + await Message.filter( + chatbot__user_id=user_id, + created_at__gte=start_date_dt, + created_at__lte=end_date_dt, + ) + .select_related("chatbot__bot") + .values("created_at", "chatbot__bot__cost") + ) + + for msg in user_msgs: + adjusted_time = msg["created_at"] + timedelta(hours=3, minutes=30) + date_only = adjusted_time.date() + report[date_only]["messages_count"] += 1 + report[date_only]["coin_usage"] += msg["chatbot__bot__cost"] + + # ۲. مصرف زیرمجموعه‌ها (سکه باسا) + sub_user_ids = await User.filter(parent_id=user_id).values_list("id", flat=True) + print(f"[LOG] Checking periodic usage for sub-users: {sub_user_ids}") + + if sub_user_ids: + sub_msgs = ( + await Message.filter( + chatbot__user_id__in=sub_user_ids, + created_at__gte=start_date_dt, + created_at__lte=end_date_dt, + cost_from_gift__gt=0 + ) + .select_related("chatbot__user") + .values("created_at", "cost_from_gift", "chatbot__user__username") + ) + + sub_usage_count = 0 + sub_total_cost = 0 + + for msg in sub_msgs: + adjusted_time = msg["created_at"] + timedelta(hours=3, minutes=30) + date_only = adjusted_time.date() + cost = msg.get("cost_from_gift", 0) or 0 + + report[date_only]["messages_count"] += 1 + report[date_only]["coin_usage"] += cost + + sub_usage_count += 1 + sub_total_cost += cost + + print(f"[LOG] Found {sub_usage_count} messages from sub-users totaling {sub_total_cost} coins in this period.") + + print("======== [Periodic Usage Report] End ========\n") + + return [ + { + "date": str(date_val), + "messages_count": data["messages_count"], + "coin_usage": data["coin_usage"], + } + for date_val, data in sorted(report.items()) + ] + + +async def weekly_usage_report(user_id: UUID, start_date: date): + return await periodic_usage_report(user_id, start_date, start_date + timedelta(days=7)) + + +async def daily_usage_report(user_id: UUID, start_date: date): + print(f"\n======== [Daily Usage Report] Start for User: {user_id} on {start_date} ========") + start_date_dt = datetime.combine(start_date, datetime.min.time()) - timedelta(hours=3, minutes=30) + end_date_dt = start_date_dt + timedelta(days=1) + + report = defaultdict(lambda: {"messages_count": 0, "coin_usage": 0}) + + # ۱. مصرف خود کاربر + user_msgs = ( + await Message.filter( + chatbot__user_id=user_id, + created_at__gte=start_date_dt, + created_at__lte=end_date_dt, + ) + .select_related("chatbot__bot") + .values("created_at", "chatbot__bot__cost") + ) + + for msg in user_msgs: + adjusted_time = msg["created_at"] + timedelta(hours=3, minutes=30) + hour = adjusted_time.hour + report[hour]["messages_count"] += 1 + report[hour]["coin_usage"] += msg["chatbot__bot__cost"] + + # ۲. مصرف زیرمجموعه‌ها (سکه باسا) + sub_user_ids = await User.filter(parent_id=user_id).values_list("id", flat=True) + print(f"[LOG] Checking daily usage for sub-users: {sub_user_ids}") + + if sub_user_ids: + sub_msgs = ( + await Message.filter( + chatbot__user_id__in=sub_user_ids, + created_at__gte=start_date_dt, + created_at__lte=end_date_dt, + cost_from_gift__gt=0 + ) + .values("created_at", "cost_from_gift") + ) + + sub_usage_count = 0 + + for msg in sub_msgs: + adjusted_time = msg["created_at"] + timedelta(hours=3, minutes=30) + hour = adjusted_time.hour + cost = msg.get("cost_from_gift", 0) or 0 + + report[hour]["messages_count"] += 1 + report[hour]["coin_usage"] += cost + sub_usage_count += 1 + + print(f"[LOG] Found {sub_usage_count} sub-user messages today.") + + print("======== [Daily Usage Report] End ========\n") + + return [ + { + "hour": str(hour), + "messages_count": data["messages_count"], + "coin_usage": data["coin_usage"], + } + for hour, data in sorted(report.items()) + ] + +# ... (بقیه توابع بدون تغییر) +async def register_code(user_id: UUID, code: str): + user: User = await User.get_or_none(id=user_id) + if user is None: + raise HTTPException(status_code=404, detail="User not found") + invitingـuser: User = await User.get_or_none(code=code.lower()) + invitingـcode: Code = await Code.get_or_none(code=code.lower()) + if invitingـuser is None and invitingـcode is None: + raise HTTPException(status_code=400, detail=MESSAGES["code"]["not_valid"]) + billing: Billing = await Billing.get_or_none(user_id=user_id, type=BillingType.CODE) + if billing is not None: + raise HTTPException(status_code=400, detail=MESSAGES["code"]["not_allow"]) + user.free_credit = user.free_credit + 20 + await user.save() + await Billing.create(user_id=user_id, amount=0, credit=20, type=BillingType.CODE, status=BillingStatus.SUCCESS) + await Notification.create(title=MESSAGES["user"]["code"][0], message=MESSAGES["user"]["code"][1], user_id=user_id) + if invitingـuser: + invitingـuser.free_credit = invitingـuser.free_credit + 20 + await invitingـuser.save() + await Billing.create(user_id=invitingـuser.id, amount=0, credit=20, type=BillingType.INVITE, status=BillingStatus.SUCCESS) + await Notification.create(title=MESSAGES["user"]["invite"][0], message=MESSAGES["user"]["invite"][1], user_id=invitingـuser.id) + if invitingـcode: + invitingـcode.usage_count += 1 + await invitingـcode.save() + return invitingـcode.message if invitingـcode else None + +async def mark_all_notifications_as_seen(user_id: UUID): + await Notification.filter(user_id=user_id).update(seen=True) + +async def admin_login(username: str, password: str): + user: User = await User.get_or_none(username=username, role=UserRole.ADMIN) + if user is None: + raise HTTPException(status_code=404, detail="User not found") + if not pwd_context.verify(password, user.password): + raise HTTPException(status_code=403, detail="Password is wrong") + await user.save() + return await auth_service.create_admin_access_token({"id": str(user.id)}) + +async def edit_level(user_id: UUID, level: int): + user: User = await User.get_or_none(id=user_id) + if user is None: + raise HTTPException(status_code=404, detail="User not found") + user.level = level + await user.save() + +async def get_users(page: int, q: str | None): + limit = 10 + offset = (page - 1) * limit + query = User.all() + if q: + query = query.filter(Q(name__icontains=q)| Q(username__icontains=q)| Q(mobile_number__icontains=q)) + total_count = await query.count() + users = await query.offset(offset).limit(limit) + return {"users": users, "page": page, "total_count": total_count, "last_page": math.ceil(total_count / limit)} + +async def admin_get_user(username: str): + return await User.get(username=username).prefetch_related("bots__category", "notifications", "sub_users").select_related("parent") + +# async def create_sub_user(parent_id: UUID, mobile_number: str, level: int): +# existing_user = await User.get_or_none(mobile_number=mobile_number) + +# if existing_user: +# raise HTTPException(status_code=409, detail="این شماره موبایل قبلا در سیستم ثبت شده است") +# current_subs_count = await User.filter(parent_id=parent_id).count() +# if current_subs_count >= 5: +# raise HTTPException(status_code=403, detail="ظرفیت زیرمجموعه‌های شما تکمیل شده است (حداکثر ۵ نفر).") +# code = await generate_unique_code() +# parent = await User.get_or_none(id=parent_id) +# sub_user = await User.create(mobile_number=mobile_number, username=code, code=code, name=code, parent_id=parent_id, level=level, verified=True,gift_credit=parent.gift_credit) +# if mobile_number: +# sms_service.send_welcome_sub_user( +# receptor=mobile_number, +# parent_name=parent.name +# ) +# return sub_user + +async def create_sub_user(parent_id: UUID, mobile_number: str, level: int): + # ✅ چک ظرفیت + current_subs_count = await User.filter(parent_id=parent_id, verified=True).count() + if current_subs_count >= 5: + raise HTTPException( + status_code=403, + detail="ظرفیت زیرمجموعه‌های شما تکمیل شده است (حداکثر ۵ نفر)." + ) + + existing_user = await User.get_or_none(mobile_number=mobile_number) + + # ✅ اگر کاربر وجود دارد + if existing_user: + if existing_user.verified is False: + parent = await User.get(id=parent_id) + + existing_user.parent_id = parent_id + existing_user.level = level + existing_user.verified = True + existing_user.gift_credit = parent.gift_credit # ✅ همسان با والد + + await existing_user.save() + sms_service.send_welcome_sub_user( + receptor=mobile_number, + parent_name=parent.name + ) + return existing_user + + raise HTTPException( + status_code=409, + detail="این کاربر قبلاً در سیستم ثبت شده است" + ) + + # ✅ ایجاد کاربر جدید + code = await generate_unique_code() + parent = await User.get(id=parent_id) + + sub_user = await User.create( + mobile_number=mobile_number, + username=code, + code=code, + name=code, + parent_id=parent_id, + level=level, + verified=True, + gift_credit=parent.gift_credit, + ) + + sms_service.send_welcome_sub_user( + receptor=mobile_number, + parent_name=parent.name + ) + + return sub_user + + + +async def get_my_sub_users(user_id: UUID): + sub_users = await User.filter(parent_id=user_id,verified=True).order_by("-created_at").all() + count = await User.filter(parent_id=user_id).count() + return {"sub_users": sub_users, "total_count": count} + +# async def delete_sub_user(parent_id: UUID, sub_user_id: UUID): +# sub_user = await User.get_or_none(id=sub_user_id, parent_id=parent_id) + +# if sub_user is None: +# raise HTTPException( +# status_code=404, +# detail="کاربر زیرمجموعه یافت نشد یا متعلق به شما نیست." +# ) +# await sub_user.delete() +async def delete_sub_user(parent_id: UUID, sub_user_id: UUID): + sub_user = await User.get_or_none(id=sub_user_id, parent_id=parent_id) + + if sub_user is None: + raise HTTPException( + status_code=404, + detail="کاربر زیرمجموعه یافت نشد یا متعلق به شما نیست." + ) + + sub_user.verified = False + await sub_user.save() + +async def add_group_score(coins: int, price: int): + # ۱. آپدیت کردیت کاربران + await User.all().update(gift_credit=F("gift_credit") + coins) + + # ۲. درج Billing برای همه کاربران با ref_id داینامیک + conn = Tortoise.get_connection("default") + + sql = """ + INSERT INTO billings ( + amount, + credit, + discount, + type, + status, + user_id, + ref_id, + created_at, + updated_at + ) + SELECT + $1, -- amount + $2, -- credit + 0, -- discount + 'free', -- type + 'success', -- status + id, -- user_id + 'FREE-' || id, -- ref_id ✅ + NOW(), + NOW() + FROM users + """ + + await conn.execute_query(sql, [price, coins]) + diff --git a/src/settings.py b/src/settings.py new file mode 100644 index 0000000..84f4519 --- /dev/null +++ b/src/settings.py @@ -0,0 +1,14 @@ +import os + + +TORTOISE_ORM = { + "connections": { + "default": os.getenv("DATABASE_URL"), + }, + "apps": { + "models": { + "models": ["models", "aerich.models"], + "default_connection": "default", + }, + }, +} diff --git a/src/tasks/__init__.py b/src/tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tasks/user.py b/src/tasks/user.py new file mode 100644 index 0000000..92fc349 --- /dev/null +++ b/src/tasks/user.py @@ -0,0 +1,47 @@ +import logging +from ..services.firebase import send_notification +from ..models import User, Billing, BillingStatus, BillingType, Notification +from ..messages import MESSAGES + +logger = logging.getLogger(__name__) + + +async def free_credit(): + # logger.info('Free credit task started.') + # users: list[User] = await User.raw( + # 'SELECT * FROM "users" WHERE credit + free_credit < 10' + # ) + # logger.info(f'Free credit task found {len(users)} users.') + + # for user in users: + # logger.info(f'Adding free credit to user {user}.') + # free_coin = 7 + # user.free_credit += free_coin + # logger.info(f"Saving user {user} free credit update.") + # await user.save() + + # logger.info(f"Creating free credit notification for user {user}.") + # await Notification.create( + # title=MESSAGES["user"]["free"][0], + # message=f"{free_coin} {MESSAGES['user']['free'][1]}", + # user_id=user.id, + # ) + + # if user.firebase_token: + # logger.info(f'Sending firebase notification to user {user}.') + # await send_notification( + # token=user.firebase_token, + # title=MESSAGES["user"]["free"][0], + # body=f"{free_coin} {MESSAGES['user']['free'][1]}", + # ) + + # logger.info(f'Creating new Billing for user {user}.') + # await Billing.create( + # user_id=user.id, + # amount=0, + # credit=free_coin, + # status=BillingStatus.SUCCESS, + # type=BillingType.FREE, + # ) + + return diff --git a/static/.well-known/assetlinks.json b/static/.well-known/assetlinks.json new file mode 100644 index 0000000..911b408 --- /dev/null +++ b/static/.well-known/assetlinks.json @@ -0,0 +1,12 @@ +[ + { + "relation": ["delegate_permission/common.handle_all_urls"], + "target": { + "namespace": "android_app", + "package_name": "com.example.hoshan", + "sha256_cert_fingerprints": [ + "7B:34:6E:46:20:5D:61:91:8F:E5:F0:85:9F:41:4F:81:B8:91:CB:B9:6A:BA:49:DD:46:5D:BC:29:A1:7C:7B:06" + ] + } + } + ] \ No newline at end of file