commit b8ab8b4b93b6d9d187fa0b5d500f4c978ab3d232 Author: vahidrezvani Date: Sat Feb 21 09:48:28 2026 +0330 firt commit 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