Files

756 lines
27 KiB
Python

import os
import sys
import time
import json
import uuid
import zipfile
import shutil
import asyncio
import logging
from pathlib import Path
from typing import Dict, Any, Optional, Tuple
from aiohttp import web
from datetime import datetime
from config import settings
from agy_engine import session_manager
logger = logging.getLogger("AGYWebUploader")
UPLOAD_SUBDOMAIN = "upload.msa.artacloud.ir"
UPLOAD_SERVER_PORT = 35555
# In-memory auth tokens for project upload sessions:
# token -> { "chat_id": int, "project_name": str, "workspace": str, "created_at": float, "expires_at": float }
UPLOAD_TOKENS: Dict[str, Dict[str, Any]] = {}
TOKEN_TTL_SECONDS = 3600 * 4 # 4 hours validity
def create_upload_token(chat_id: int, project_name: str) -> str:
"""Generates an expiring upload token for a specific project."""
session = session_manager.get_or_create(chat_id)
accessible = session_manager.get_all_accessible_projects(chat_id)
proj = accessible.get(project_name)
if not proj:
for k, p in accessible.items():
if p.name == project_name:
proj = p
break
if not proj:
proj = session_manager.get_current_project(chat_id)
workspace = proj.workspace if proj else str(Path("/root/projects") / str(chat_id) / project_name)
resolved_name = proj.name if proj else project_name
token = uuid.uuid4().hex
now = time.time()
UPLOAD_TOKENS[token] = {
"chat_id": chat_id,
"project_name": resolved_name,
"workspace": workspace,
"created_at": now,
"expires_at": now + TOKEN_TTL_SECONDS,
}
_cleanup_expired_tokens()
return token
def get_token_info(token: str) -> Optional[Dict[str, Any]]:
_cleanup_expired_tokens()
info = UPLOAD_TOKENS.get(token)
if not info:
return None
if time.time() > info["expires_at"]:
UPLOAD_TOKENS.pop(token, None)
return None
return info
def _cleanup_expired_tokens():
now = time.time()
expired = [t for t, data in UPLOAD_TOKENS.items() if now > data.get("expires_at", 0)]
for t in expired:
UPLOAD_TOKENS.pop(t, None)
def get_upload_url(token: str) -> str:
return f"https://{UPLOAD_SUBDOMAIN}/?token={token}"
HTML_PAGE_TEMPLATE = """<!DOCTYPE html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>آپلود فایل به پروژه {project_name} | Antigravity</title>
<link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@300;400;600;700;800&display=swap" rel="stylesheet">
<style>
:root {
--primary: #3b82f6;
--primary-hover: #2563eb;
--bg-dark: #0f172a;
--card-bg: rgba(30, 41, 59, 0.75);
--card-border: rgba(255, 255, 255, 0.1);
--text-main: #f8fafc;
--text-muted: #94a3b8;
--accent-green: #10b981;
--accent-purple: #8b5cf6;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
font-family: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
body {
background-color: var(--bg-dark);
background-image:
radial-gradient(at 0% 0%, rgba(59, 130, 246, 0.15) 0px, transparent 50%),
radial-gradient(at 100% 100%, rgba(139, 92, 246, 0.15) 0px, transparent 50%);
color: var(--text-main);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
width: 100%;
max-width: 580px;
background: var(--card-bg);
backdrop-filter: blur(16px);
border: 1px solid var(--card-border);
border-radius: 24px;
padding: 32px 28px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
animation: fadeIn 0.5s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
.header {
text-align: center;
margin-bottom: 24px;
}
.logo-badge {
display: inline-flex;
align-items: center;
gap: 8px;
background: rgba(59, 130, 246, 0.12);
color: #60a5fa;
border: 1px solid rgba(59, 130, 246, 0.3);
padding: 6px 14px;
border-radius: 100px;
font-size: 0.85rem;
font-weight: 600;
margin-bottom: 12px;
}
h1 {
font-size: 1.5rem;
font-weight: 800;
color: #ffffff;
margin-bottom: 6px;
}
.project-badge {
color: #38bdf8;
background: rgba(56, 189, 248, 0.1);
padding: 2px 10px;
border-radius: 8px;
font-family: monospace;
direction: ltr;
display: inline-block;
}
.drop-zone {
border: 2px dashed rgba(59, 130, 246, 0.4);
border-radius: 18px;
padding: 36px 20px;
text-align: center;
background: rgba(15, 23, 42, 0.4);
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
margin-bottom: 20px;
}
.drop-zone:hover, .drop-zone.dragover {
border-color: #60a5fa;
background: rgba(59, 130, 246, 0.08);
transform: scale(1.01);
}
.drop-zone input[type="file"] {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
.upload-icon {
font-size: 3rem;
margin-bottom: 12px;
display: block;
}
.drop-title {
font-size: 1.1rem;
font-weight: 700;
margin-bottom: 6px;
}
.drop-desc {
font-size: 0.85rem;
color: var(--text-muted);
}
.options-box {
background: rgba(15, 23, 42, 0.5);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 14px;
padding: 16px;
margin-bottom: 20px;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
font-size: 0.92rem;
font-weight: 500;
color: #e2e8f0;
user-select: none;
}
.checkbox-label input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: var(--primary);
cursor: pointer;
}
.input-group {
margin-top: 12px;
}
.input-group label {
display: block;
font-size: 0.82rem;
color: var(--text-muted);
margin-bottom: 6px;
}
.input-group input[type="text"] {
width: 100%;
background: rgba(30, 41, 59, 0.8);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #fff;
padding: 10px 14px;
border-radius: 10px;
font-size: 0.9rem;
outline: none;
transition: border-color 0.2s;
}
.input-group input[type="text"]:focus {
border-color: var(--primary);
}
.file-info-box {
display: none;
background: rgba(59, 130, 246, 0.08);
border: 1px solid rgba(59, 130, 246, 0.2);
border-radius: 12px;
padding: 12px 16px;
margin-bottom: 20px;
font-size: 0.9rem;
align-items: center;
justify-content: space-between;
}
.file-name-txt {
font-weight: 600;
direction: ltr;
text-align: right;
word-break: break-all;
}
.file-size-txt {
color: #93c5fd;
font-size: 0.82rem;
margin-right: 8px;
}
.progress-bar-container {
display: none;
background: rgba(15, 23, 42, 0.8);
border-radius: 100px;
height: 10px;
overflow: hidden;
margin-bottom: 20px;
border: 1px solid rgba(255, 255, 255, 0.05);
}
.progress-bar {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
transition: width 0.2s ease;
border-radius: 100px;
}
.btn-upload {
width: 100%;
background: linear-gradient(135deg, #3b82f6, #2563eb);
color: #fff;
border: none;
padding: 14px 20px;
border-radius: 14px;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 14px 0 rgba(37, 99, 235, 0.39);
}
.btn-upload:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 6px 20px 0 rgba(37, 99, 235, 0.5);
background: linear-gradient(135deg, #2563eb, #1d4ed8);
}
.btn-upload:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.status-msg {
margin-top: 18px;
font-size: 0.9rem;
text-align: center;
border-radius: 12px;
padding: 12px;
display: none;
}
.status-success {
background: rgba(16, 185, 129, 0.12);
border: 1px solid rgba(16, 185, 129, 0.3);
color: #34d399;
}
.status-error {
background: rgba(239, 68, 68, 0.12);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #f87171;
}
.footer-note {
text-align: center;
font-size: 0.78rem;
color: var(--text-muted);
margin-top: 20px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo-badge">⚡ Antigravity Workspace File Drop</div>
<h1>آپلود فایل در پروژه</h1>
<p style="color: var(--text-muted); font-size: 0.9rem; margin-top: 6px;">
پروژه فعال: <span class="project-badge">{project_name}</span>
</p>
</div>
<form id="uploadForm">
<div class="drop-zone" id="dropZone">
<input type="file" id="fileInput" name="file" required>
<span class="upload-icon">📁</span>
<div class="drop-title">فایل خود را اینجا بکشید یا کلیک کنید</div>
<div class="drop-desc">پشتیبانی از فایل‌های فشرده (ZIP)، کدهای برنامه، تصاویر و اسناد (بدون محدودیت ۲۰ مگابایت)</div>
</div>
<div class="file-info-box" id="fileInfoBox">
<span class="file-name-txt" id="fileNameTxt"></span>
<span class="file-size-txt" id="fileSizeTxt"></span>
</div>
<div class="options-box">
<label class="checkbox-label">
<input type="checkbox" id="extractZip" name="extract_zip" checked>
<span>🗜️ در صورت فشرده بودن (ZIP/TAR)، محتویات مستقیماً در پوشه پروژه استخراج (Unzip) شود</span>
</label>
<div class="input-group">
<label for="captionInput">💬 پیام یا دستور برای هوش مصنوعی (اختیاری):</label>
<input type="text" id="captionInput" placeholder="مثال: فایل زیپ سورس پروژه است، آن را باز کن و بررسی کن...">
</div>
</div>
<div class="progress-bar-container" id="progressBarContainer">
<div class="progress-bar" id="progressBar"></div>
</div>
<button type="submit" class="btn-upload" id="submitBtn">🚀 شروع آپلود فایل</button>
</form>
<div id="statusMsg" class="status-msg"></div>
<div class="footer-note">
امنیت و ایزولاسیون: فایل‌ها مستقیماً در فضای ابری سرور و پروژه اختصاصی شما قرار می‌گیرند.
</div>
</div>
<script>
const token = "{token}";
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const fileInfoBox = document.getElementById('fileInfoBox');
const fileNameTxt = document.getElementById('fileNameTxt');
const fileSizeTxt = document.getElementById('fileSizeTxt');
const uploadForm = document.getElementById('uploadForm');
const submitBtn = document.getElementById('submitBtn');
const progressContainer = document.getElementById('progressBarContainer');
const progressBar = document.getElementById('progressBar');
const statusMsg = document.getElementById('statusMsg');
const extractZip = document.getElementById('extractZip');
const captionInput = document.getElementById('captionInput');
function formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
fileInput.addEventListener('change', () => {
if (fileInput.files.length > 0) {
const file = fileInput.files[0];
fileNameTxt.textContent = file.name;
fileSizeTxt.textContent = formatBytes(file.size);
fileInfoBox.style.display = 'flex';
// Auto check unzip for zip files
if (file.name.toLowerCase().endsWith('.zip') || file.name.toLowerCase().endsWith('.tar.gz')) {
extractZip.checked = true;
}
}
});
['dragenter', 'dragover'].forEach(eventName => {
dropZone.addEventListener(eventName, (e) => {
e.preventDefault();
e.stopPropagation();
dropZone.classList.add('dragover');
}, false);
});
['dragleave'].forEach(eventName => {
dropZone.addEventListener(eventName, (e) => {
e.preventDefault();
e.stopPropagation();
dropZone.classList.remove('dragover');
}, false);
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
dropZone.classList.remove('dragover');
if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length > 0) {
fileInput.files = e.dataTransfer.files;
const file = e.dataTransfer.files[0];
fileNameTxt.textContent = file.name;
fileSizeTxt.textContent = formatBytes(file.size);
fileInfoBox.style.display = 'flex';
if (file.name.toLowerCase().endsWith('.zip') || file.name.toLowerCase().endsWith('.tar.gz') || file.name.toLowerCase().endsWith('.tar')) {
extractZip.checked = true;
}
}
}, false);
uploadForm.addEventListener('submit', (e) => {
e.preventDefault();
if (!fileInput.files || !fileInput.files.length) {
alert('لطفاً ابتدا فایلی را انتخاب کنید یا به این کادر بکشید.');
return;
}
const file = fileInput.files[0];
const formData = new FormData();
formData.append('token', token);
formData.append('extract_zip', extractZip.checked ? 'true' : 'false');
formData.append('caption', captionInput.value.trim());
formData.append('file', file);
submitBtn.disabled = true;
submitBtn.textContent = '⏳ در حال آپلود و ذخیره‌سازی...';
progressContainer.style.display = 'block';
progressBar.style.width = '0%';
statusMsg.style.display = 'none';
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/upload', true);
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const percentComplete = (event.loaded / event.total) * 100;
progressBar.style.width = percentComplete + '%';
}
};
xhr.onload = function() {
submitBtn.disabled = false;
submitBtn.textContent = '🚀 شروع آپلود فایل';
if (xhr.status === 200) {
const resp = JSON.parse(xhr.responseText);
statusMsg.className = 'status-msg status-success';
statusMsg.style.display = 'block';
statusMsg.innerHTML = '✅ <b>فایل با موفقیت در پروژه ذخیره شد!</b><br><br>' + (resp.message || '') + '<br><br>💡 هوش مصنوعی در تلگرام مطلع شد و در حال پردازش درخواست شماست.';
uploadForm.reset();
fileInfoBox.style.display = 'none';
} else {
let errMsg = 'خطا در آپلود فایل.';
try {
const errResp = JSON.parse(xhr.responseText);
if (errResp.error) errMsg = errResp.error;
} catch(e) {}
statusMsg.className = 'status-msg status-error';
statusMsg.style.display = 'block';
statusMsg.innerHTML = '❌ ' + errMsg;
}
};
xhr.onerror = function() {
submitBtn.disabled = false;
submitBtn.textContent = '🚀 شروع آپلود فایل';
statusMsg.className = 'status-msg status-error';
statusMsg.style.display = 'block';
statusMsg.innerHTML = '❌ خطای شبکه در هنگام ارسال فایل. لطفاً دوباره تلاش کنید.';
};
xhr.send(formData);
});
</script>
</body>
</html>
"""
# Global reference to Telegram Application to trigger agent turn
TELEGRAM_APP = None
def set_telegram_app(app):
global TELEGRAM_APP
TELEGRAM_APP = app
async def handle_index_page(request: web.Request) -> web.Response:
token = request.query.get("token", "").strip()
if not token:
return web.Response(
text="<h1>⛔ دسترسی نامعتبر / Unauthorized</h1><p>لطفاً لینک آپلود را مستقیماً از ربات تلگرام دریافت کنید.</p>",
content_type="text/html",
status=401,
)
info = get_token_info(token)
if not info:
return web.Response(
text="<h1>⚠️ لینک آپلود منقضی شده است / Token Expired</h1><p>لطفاً از طریق ربات تلگرام مجدداً روی دکمه آپلود کلیک فرمایید.</p>",
content_type="text/html",
status=403,
)
html = HTML_PAGE_TEMPLATE.replace("{project_name}", info.get("project_name", "Unknown")).replace("{token}", token)
return web.Response(text=html, content_type="text/html")
async def handle_upload_api(request: web.Request) -> web.Response:
try:
reader = await request.multipart()
except Exception as e:
return web.json_response({"error": f"Invalid multipart request: {str(e)}"}, status=400)
token = None
extract_zip_opt = False
caption_txt = ""
saved_file_path: Optional[Path] = None
file_name = "uploaded_file"
file_size = 0
while True:
part = await reader.next()
if part is None:
break
if part.name == "token":
token = (await part.text()).strip()
elif part.name == "extract_zip":
val = (await part.text()).strip().lower()
extract_zip_opt = val in ("true", "1", "yes", "on")
elif part.name == "caption":
caption_txt = (await part.text()).strip()
elif part.name == "file":
raw_filename = part.filename or f"upload_{int(time.time())}.bin"
file_name = Path(raw_filename).name # sanitize
# Temporary save in a buffer or target directory
temp_dir = Path("/root/telegram-agy-bot/uploads_temp")
temp_dir.mkdir(parents=True, exist_ok=True)
saved_file_path = temp_dir / f"{uuid.uuid4().hex[:8]}_{file_name}"
with open(saved_file_path, "wb") as f:
while True:
chunk = await part.read_chunk(1024 * 1024) # positional size in bytes
if not chunk:
break
f.write(chunk)
file_size += len(chunk)
if not token or not saved_file_path or not saved_file_path.exists():
return web.json_response({"error": "فایل یا پارامترهای درخواست ناقص هستند."}, status=400)
token_info = get_token_info(token)
if not token_info:
if saved_file_path.exists():
saved_file_path.unlink()
return web.json_response({"error": "توکن آپلود منقضی شده یا نامعتبر است."}, status=403)
chat_id = token_info["chat_id"]
project_name = token_info["project_name"]
workspace = Path(token_info["workspace"])
workspace.mkdir(parents=True, exist_ok=True)
dest_uploads_dir = workspace / "uploads"
dest_uploads_dir.mkdir(parents=True, exist_ok=True)
final_dest_path = dest_uploads_dir / file_name
# Move from temp to destination
shutil.move(str(saved_file_path), str(final_dest_path))
extracted_msg = ""
is_zip = file_name.lower().endswith((".zip", ".tar.gz", ".tgz", ".tar"))
if is_zip and extract_zip_opt:
try:
if file_name.lower().endswith(".zip"):
with zipfile.ZipFile(final_dest_path, "r") as zf:
zf.extractall(workspace)
extracted_msg = f"📦 فایل زیپ مستقیماً در مسیر پروژه ({workspace}) استخراج شد."
elif file_name.lower().endswith((".tar.gz", ".tgz", ".tar")):
import tarfile
with tarfile.open(final_dest_path, "r:*") as tf:
tf.extractall(workspace)
extracted_msg = f"📦 آرشیو فشرده در مسیر پروژه ({workspace}) استخراج شد."
except Exception as ze:
logger.error(f"Failed to auto-extract archive {final_dest_path}: {ze}")
extracted_msg = f"⚠️ فایل در پوشه uploads ذخیره شد اما استخراج خودکار با خطا مواجه گردید: {ze}"
# Notify Telegram bot and run agent turn
asyncio.create_task(_notify_telegram_and_trigger_agent(
chat_id=chat_id,
project_name=project_name,
workspace=str(workspace),
file_path=str(final_dest_path),
file_name=file_name,
file_size=file_size,
extracted=bool(is_zip and extract_zip_opt and extracted_msg and "استخراج شد" in extracted_msg),
extracted_msg=extracted_msg,
caption=caption_txt,
))
size_mb = f"{file_size / (1024 * 1024):.2f} MB"
return web.json_response({
"success": True,
"filename": file_name,
"size": size_mb,
"message": f"فایل <b>{file_name}</b> ({size_mb}) با موفقیت آپلود گردید.<br>{extracted_msg}",
})
async def _notify_telegram_and_trigger_agent(
chat_id: int,
project_name: str,
workspace: str,
file_path: str,
file_name: str,
file_size: int,
extracted: bool,
extracted_msg: str,
caption: str,
):
"""Sends notification to Telegram and invokes AGY turn."""
if not TELEGRAM_APP:
logger.warning("TELEGRAM_APP not set in web_uploader")
return
size_mb = f"{file_size / (1024 * 1024):.2f} MB"
session = session_manager.get_or_create(chat_id)
is_fa = (session.language or "").lower() in ("fa", "farsi", "persian", "🇮🇷 persian / farsi (فارسی)")
notification_text = (
f"📥 <b>فایل جدید از طریق پنل وب آپلود شد!</b>\n\n"
f"• 📁 <b>پروژه:</b> <code>{project_name}</code>\n"
f"• 📄 <b>نام فایل:</b> <code>{file_name}</code>\n"
f"• 💾 <b>حجم:</b> <code>{size_mb}</code>\n"
f"• 📂 <b>مسیر ذخیره:</b> <code>{file_path}</code>\n"
)
if extracted:
notification_text += f"• 🗜️ <b>وضعیت آرشیو:</b> در ریشه پروژه (<code>{workspace}</code>) اکسترکت شد.\n"
if caption:
notification_text += f"\n💬 <b>پیام شما:</b> <i>«{caption}»</i>\n"
try:
await TELEGRAM_APP.bot.send_message(
chat_id=chat_id,
text=notification_text,
parse_mode="HTML",
)
except Exception as e:
logger.error(f"Failed to send telegram notification: {e}")
# Build prompt for AI
ai_prompt = (
f"[User uploaded file `{file_name}` ({size_mb}) via Web Uploader to `{file_path}`]\n"
f"Project workspace: `{workspace}`\n"
)
if extracted:
ai_prompt += f"The archive has been extracted into `{workspace}`.\n"
else:
ai_prompt += f"The file is saved at `{file_path}`.\n"
if caption:
ai_prompt += f"User instructions / caption: {caption}\n\n"
else:
ai_prompt += f"Please review the uploaded files in `{workspace}` and proceed with assisting the user.\n\n"
try:
from bot import process_agent_turn_by_chat_id
await process_agent_turn_by_chat_id(TELEGRAM_APP, chat_id, ai_prompt)
except Exception as turn_err:
logger.error(f"Failed to trigger agent turn for web upload: {turn_err}", exc_info=True)
async def start_web_uploader_server():
"""Starts the standalone aiohttp web server on port 35555."""
app = web.Application(client_max_size=2048 * 1024 * 1024) # 2GB max upload limit
app.router.add_get("/", handle_index_page)
app.router.add_post("/api/upload", handle_upload_api)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "127.0.0.1", UPLOAD_SERVER_PORT)
await site.start()
logger.info(f"Web Uploader server started on 127.0.0.1:{UPLOAD_SERVER_PORT}")