10 Commits
12 changed files with 583 additions and 115 deletions
Submodule projects/232801087/myapp updated: ded79a9a9e...a6bf56fafb
Submodule projects/78649634/bildirchin added at e109f5d777
Submodule projects/78649634/copykar updated: f8468cdd1a...6e4b7e364d
Submodule projects/78649634/gasemi_tier updated: d27520ec0f...aca18b7229
+7
View File
@@ -360,6 +360,13 @@ class SessionManager:
"created_at": p_obj.created_at,
"description": p_obj.description,
"conversation_titles": p_obj.conversation_titles,
"active_branch": p_obj.active_branch,
"ftp_host": p_obj.ftp_host,
"ftp_port": p_obj.ftp_port,
"ftp_user": p_obj.ftp_user,
"ftp_password": p_obj.ftp_password,
"ftp_path": p_obj.ftp_path,
"ftp_tls": p_obj.ftp_tls,
}
curr = self.get_current_project(sess.chat_id)
data[str(k)] = {
+241 -19
View File
@@ -109,6 +109,9 @@ def get_lang_display(lang_code_or_name: str) -> str:
norm = LANG_ALIASES.get(norm, norm)
return AVAILABLE_LANGUAGES.get(norm, lang_code_or_name)
# Global active user input states for interactive prompts (e.g. FTP configuration wizards)
USER_INPUT_STATES: dict[int, dict] = {}
# Logging configuration
logging.basicConfig(
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
@@ -389,34 +392,61 @@ async def build_ftp_menu(chat_id: int) -> tuple[str, InlineKeyboardMarkup]:
user=curr_proj.ftp_user,
path=curr_proj.ftp_path,
tls=curr_proj.ftp_tls,
password=curr_proj.ftp_password,
lang=session.language,
)
tls_toggle_label = f"🔒 پروتکل امن: {'روشن ✅' if curr_proj.ftp_tls else 'خاموش ❌'}" if is_fa else f"🔒 FTPS/TLS: {'ON ✅' if curr_proj.ftp_tls else 'OFF ❌'}"
if is_fa:
keyboard = [
[
InlineKeyboardButton("🚀 دیپلوی روی FTP (Clean Deploy)", callback_data="btn_ftp_deploy"),
InlineKeyboardButton("🚀 دیپلوی نسخه پروداکشن روی FTP", callback_data="btn_ftp_deploy"),
InlineKeyboardButton("🔍 تست اتصال FTP", callback_data="btn_ftp_test"),
],
[
InlineKeyboardButton("🐙 منوی گیت و برنچ‌ها", callback_data="btn_git_menu"),
InlineKeyboardButton("📁 مدیریت پروژه‌ها", callback_data="proj_menu"),
InlineKeyboardButton("🌿 انتقال از dev به پروداکشن (Publish)", callback_data="btn_git_publish"),
],
[
InlineKeyboardButton("🌐 ویرایش هاست/پورت", callback_data="ftp_set:host"),
InlineKeyboardButton("👤 ویرایش نام کاربری", callback_data="ftp_set:user"),
],
[
InlineKeyboardButton("🔑 ویرایش رمز عبور", callback_data="ftp_set:pass"),
InlineKeyboardButton("📂 ویرایش مسیر مقصد", callback_data="ftp_set:path"),
],
[
InlineKeyboardButton(tls_toggle_label, callback_data="ftp_toggle_tls"),
InlineKeyboardButton("🗑️ پاک‌سازی اطلاعات", callback_data="ftp_clear_conf"),
],
[
InlineKeyboardButton("🐙 منوی گیت و برنچ‌ها", callback_data="btn_git_menu"),
InlineKeyboardButton("🏠 منوی اصلی", callback_data="btn_dashboard"),
],
]
else:
keyboard = [
[
InlineKeyboardButton("🚀 Deploy via FTP (Clean)", callback_data="btn_ftp_deploy"),
InlineKeyboardButton("🚀 Deploy Production to FTP", callback_data="btn_ftp_deploy"),
InlineKeyboardButton("🔍 Test FTP Connection", callback_data="btn_ftp_test"),
],
[
InlineKeyboardButton("🐙 Git & Branches Menu", callback_data="btn_git_menu"),
InlineKeyboardButton("📁 Projects Menu", callback_data="proj_menu"),
InlineKeyboardButton("🌿 Publish dev to Production", callback_data="btn_git_publish"),
],
[
InlineKeyboardButton("🌐 Edit Host/Port", callback_data="ftp_set:host"),
InlineKeyboardButton("👤 Edit Username", callback_data="ftp_set:user"),
],
[
InlineKeyboardButton("🔑 Edit Password", callback_data="ftp_set:pass"),
InlineKeyboardButton("📂 Edit Remote Path", callback_data="ftp_set:path"),
],
[
InlineKeyboardButton(tls_toggle_label, callback_data="ftp_toggle_tls"),
InlineKeyboardButton("🗑️ Clear Config", callback_data="ftp_clear_conf"),
],
[
InlineKeyboardButton("🐙 Git & Branches Menu", callback_data="btn_git_menu"),
InlineKeyboardButton("🏠 Main Dashboard", callback_data="btn_dashboard"),
],
]
@@ -4687,11 +4717,11 @@ async def callback_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
await query.message.reply_html(text, reply_markup=markup, disable_web_page_preview=True)
elif data == "btn_git_publish":
await query.answer("🚀 در حال انتشار به پروداکشن..." if is_fa else "🚀 Publishing to production...")
await query.answer("🚀 در حال انتقال تغییرات از dev به production..." if is_fa else "🚀 Publishing to production...")
if curr_proj:
ok, res_msg, _ = await git_manager.git_publish(curr_proj.workspace, message="Publish via bot button", repo_name=curr_proj.name)
ok, res_msg, _ = await git_manager.git_publish(curr_proj.workspace, message="Publish dev to production via bot button", repo_name=curr_proj.name)
try:
await query.answer("✅ انتشار به پروداکشن با موفقیت انجام شد!" if ok else "⚠️ خطا در انتشار", show_alert=not ok)
await query.message.reply_html(res_msg, disable_web_page_preview=True)
except Exception:
pass
text, markup = await build_git_menu(chat_id)
@@ -4707,6 +4737,96 @@ async def callback_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
except Exception:
await query.message.reply_html(text, reply_markup=markup, disable_web_page_preview=True)
elif data.startswith("ftp_set:"):
field_name = data.split(":")[1]
if not curr_proj:
await query.answer("⚠️ پروژه‌ای یافت نشد." if is_fa else "⚠️ No project found.", show_alert=True)
return
USER_INPUT_STATES[chat_id] = {
"type": "ftp_set",
"field": field_name,
"project": curr_proj.name,
}
field_prompts = {
"host": (
"🌐 <b>لطفاً آدرس هاست یا سرور FTP را ارسال کنید:</b>\n\n"
"<i>مثال:</i> <code>ftp.mysite.com</code> یا <code>185.120.30.40:21</code>"
) if is_fa else (
"🌐 <b>Please enter the FTP Host address:</b>\n\n"
"<i>Example:</i> <code>ftp.mysite.com</code> or <code>185.120.30.40:21</code>"
),
"user": (
"👤 <b>لطفاً نام کاربری (Username) اتصال FTP را ارسال کنید:</b>\n\n"
"<i>مثال:</i> <code>deployer@mysite.com</code> یا <code>ftpuser</code>"
) if is_fa else (
"👤 <b>Please enter the FTP Username:</b>\n\n"
"<i>Example:</i> <code>deployer@mysite.com</code> or <code>ftpuser</code>"
),
"pass": (
"🔑 <b>لطفاً رمز عبور (Password) اتصال FTP را ارسال کنید:</b>\n\n"
"<i>(پیام پس از ثبت، جهت امنیت حذف یا پوشانده می‌شود)</i>"
) if is_fa else (
"🔑 <b>Please enter the FTP Password:</b>\n\n"
"<i>(Message will be stored securely)</i>"
),
"path": (
"📂 <b>لطفاً مسیر پوشه ریموت مقصد روی هاست را ارسال کنید:</b>\n\n"
"<i>مثال:</i> <code>/public_html</code> یا <code>/domains/site.com/public_html</code>"
) if is_fa else (
"📂 <b>Please enter the remote target path:</b>\n\n"
"<i>Example:</i> <code>/public_html</code>"
),
}
prompt_text = field_prompts.get(field_name, "لطفاً مقدار جدید را ارسال کنید:")
cancel_kb = InlineKeyboardMarkup([[InlineKeyboardButton("❌ انصراف و بازگشت" if is_fa else "❌ Cancel", callback_data="btn_ftp_menu")]])
await query.edit_message_text(prompt_text, parse_mode=constants.ParseMode.HTML, reply_markup=cancel_kb)
elif data == "ftp_toggle_tls":
if curr_proj:
curr_proj.ftp_tls = not curr_proj.ftp_tls
session_manager.save()
status_str = "روشن (FTPS/TLS) 🔒" if curr_proj.ftp_tls else "خاموش (FTP معمولی) 🔓"
await query.answer(f"✅ پروتکل امن: {status_str}")
text, markup = await build_ftp_menu(chat_id)
try:
await query.edit_message_text(text, parse_mode=constants.ParseMode.HTML, reply_markup=markup, disable_web_page_preview=True)
except Exception:
await query.message.reply_html(text, reply_markup=markup, disable_web_page_preview=True)
elif data == "ftp_clear_conf":
confirm_text = (
"🗑️ <b>آیا از پاک‌سازی کامل اطلاعات FTP این پروژه اطمینان دارید؟</b>\n\n"
"تمام مشخصات سرور، کاربر و رمز عبور حذف خواهند شد."
) if is_fa else (
"🗑️ <b>Are you sure you want to clear FTP settings for this project?</b>"
)
keyboard = [
[
InlineKeyboardButton("💥 بله، پاک شود" if is_fa else "💥 Yes, Clear", callback_data="ftp_clear_do"),
InlineKeyboardButton("❌ انصراف" if is_fa else "❌ Cancel", callback_data="btn_ftp_menu"),
]
]
await query.edit_message_text(confirm_text, parse_mode=constants.ParseMode.HTML, reply_markup=InlineKeyboardMarkup(keyboard))
elif data == "ftp_clear_do":
if curr_proj:
curr_proj.ftp_host = None
curr_proj.ftp_port = 21
curr_proj.ftp_user = None
curr_proj.ftp_password = None
curr_proj.ftp_path = "/"
curr_proj.ftp_tls = False
session_manager.save()
await query.answer("🗑️ اطلاعات FTP با موفقیت پاک شد." if is_fa else "🗑️ FTP settings cleared.")
text, markup = await build_ftp_menu(chat_id)
try:
await query.edit_message_text(text, parse_mode=constants.ParseMode.HTML, reply_markup=markup, disable_web_page_preview=True)
except Exception:
await query.message.reply_html(text, reply_markup=markup, disable_web_page_preview=True)
elif data == "btn_ftp_test":
await query.answer("🔍 در حال بررسی اتصال FTP..." if is_fa else "🔍 Testing FTP connection...")
if curr_proj and curr_proj.ftp_host:
@@ -4718,9 +4838,24 @@ async def callback_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
path=curr_proj.ftp_path or "/",
tls=curr_proj.ftp_tls,
)
icon = "" if test_ok else ""
res_card = (
f"{icon} <b>نتیجه تست اتصال FTP:</b>\n\n"
f"• 🌐 <b>هاست:</b> <code>{escape_html(curr_proj.ftp_host)}:{curr_proj.ftp_port}</code>\n"
f"• 👤 <b>نام کاربری:</b> <code>{escape_html(curr_proj.ftp_user or '(بدون نام کاربری)')}</code>\n"
f"• 📂 <b>مسیر:</b> <code>{escape_html(curr_proj.ftp_path or '/')}</code>\n"
f"• 🔒 <b>پروتکل:</b> {'FTPS/TLS امن 🔒' if curr_proj.ftp_tls else 'FTP معمولی'}\n\n"
f"📋 <b>گزارش ارتباط:</b>\n{test_msg}"
) if is_fa else (
f"{icon} <b>FTP Connection Test Result:</b>\n\n"
f"• 🌐 <b>Host:</b> <code>{escape_html(curr_proj.ftp_host)}:{curr_proj.ftp_port}</code>\n"
f"• 👤 <b>User:</b> <code>{escape_html(curr_proj.ftp_user or '(none)')}</code>\n"
f"• 📂 <b>Path:</b> <code>{escape_html(curr_proj.ftp_path or '/')}</code>\n"
f"• 🔒 <b>Protocol:</b> {'Secure FTPS/TLS 🔒' if curr_proj.ftp_tls else 'Standard FTP'}\n\n"
f"📋 <b>Status:</b>\n{test_msg}"
)
try:
alert_text = ("✅ اتصال FTP برقرار است" if test_ok else "❌ خطا در اتصال FTP") + f"\n{test_msg}"
await query.answer(alert_text[:200], show_alert=True)
await query.message.reply_html(res_card, disable_web_page_preview=True)
except Exception:
pass
else:
@@ -4735,13 +4870,9 @@ async def callback_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
await query.message.reply_html(text, reply_markup=markup, disable_web_page_preview=True)
elif data == "btn_ftp_deploy":
await query.answer("🚀 در حال استقرار هوشمند روی FTP..." if is_fa else "🚀 Deploying to FTP...")
await query.answer("🚀 در حال استقرار نسخه پروداکشن روی FTP..." if is_fa else "🚀 Deploying production to FTP...")
if curr_proj and curr_proj.ftp_host:
# Auto publish before deploy
try:
await git_manager.git_publish(curr_proj.workspace, message="Auto-publish before UI FTP deploy", repo_name=curr_proj.name)
except Exception:
pass
status_msg = await query.message.reply_html("🚀 <i>در حال آماده‌سازی و ارسال نسخه شاخه production به سرور FTP...</i>" if is_fa else "🚀 <i>Preparing and uploading production branch files to FTP...</i>")
res = await ftp_manager.deploy_project(
workspace_path=curr_proj.workspace,
@@ -4751,18 +4882,50 @@ async def callback_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
password=curr_proj.ftp_password or "",
remote_path=curr_proj.ftp_path or "/",
tls=curr_proj.ftp_tls,
branch="production",
)
if res.get("success"):
files_up = res.get("files_uploaded", 0)
files_skip = res.get("files_skipped", 0)
dur = res.get("duration", 0)
b_trans = res.get("bytes_transferred", 0)
size_mb = f"{b_trans / (1024 * 1024):.2f} MB" if b_trans > 1024 * 1024 else f"{b_trans / 1024:.1f} KB"
report = (
f"✅ <b>استقرار روی FTP با موفقیت انجام شد!</b>\n\n"
f"• 📁 <b>پروژه:</b> <code>{escape_html(curr_proj.name)}</code>\n"
f"• 🌿 <b>شاخه سورس دیپلوی:</b> <code>production</code>\n"
f"• 🌐 <b>سرور مقصد:</b> <code>{escape_html(curr_proj.ftp_host)}:{curr_proj.ftp_port}</code>\n"
f"• 📂 <b>مسیر هاست:</b> <code>{escape_html(curr_proj.ftp_path or '/')}</code>\n"
f"• 📦 <b>تعداد فایل‌های آپلود شده:</b> {files_up}\n"
f"• 🧹 <b>فایل‌های زائد و فیلتر شده:</b> {files_skip}\n"
f"• 📊 <b>حجم کل منتقل شده:</b> {size_mb}\n"
f"• ⏱️ <b>مدت زمان دیپلوی:</b> {dur} ثانیه"
) if is_fa else (
f"✅ <b>FTP Deployment Completed Successfully!</b>\n\n"
f"• 📁 <b>Project:</b> <code>{escape_html(curr_proj.name)}</code>\n"
f"• 🌿 <b>Source Branch:</b> <code>production</code>\n"
f"• 🌐 <b>Target Server:</b> <code>{escape_html(curr_proj.ftp_host)}:{curr_proj.ftp_port}</code>\n"
f"• 📂 <b>Remote Path:</b> <code>{escape_html(curr_proj.ftp_path or '/')}</code>\n"
f"• 📦 <b>Files Uploaded:</b> {files_up}\n"
f"• 🧹 <b>Excluded/Skipped:</b> {files_skip}\n"
f"• 📊 <b>Transferred:</b> {size_mb}\n"
f"• ⏱️ <b>Duration:</b> {dur}s"
)
try:
await query.answer(f"✅ دیپلوی با موفقیت انجام شد ({files_up} فایل در {dur} ثانیه)", show_alert=True)
await status_msg.edit_text(report, parse_mode=constants.ParseMode.HTML)
except Exception:
pass
else:
err = res.get("error", "Error")
err_report = (
f"❌ <b>خطا در استقرار روی FTP:</b>\n\n"
f"• ⚠️ <b>پیام خطا:</b> {escape_html(str(err))}\n"
f"• 💡 <i>پیشنهاد: ابتدا دکمه تست اتصال را بزنید یا مشخصات هاست و مسیر را بررسی کنید.</i>"
) if is_fa else f"❌ <b>FTP Deployment Error:</b>\n{escape_html(str(err))}"
try:
await query.answer(f"❌ خطا در دیپلوی: {err}"[:200], show_alert=True)
await status_msg.edit_text(err_report, parse_mode=constants.ParseMode.HTML)
except Exception:
pass
else:
@@ -6501,6 +6664,65 @@ async def message_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
session = session_manager.get_or_create(chat_id)
is_fa = (session.language or "").lower() in ("fa", "farsi", "persian", "🇮🇷 persian / farsi (فارسی)")
# 1. Check if user is in an interactive input state (e.g. configuring FTP fields)
if chat_id in USER_INPUT_STATES:
state = USER_INPUT_STATES.pop(chat_id)
if state.get("type") == "ftp_set" and curr_proj:
field = state.get("field")
val = update.message.text.strip()
if field == "host":
# Check for host:port format
if ":" in val:
h, p = val.rsplit(":", 1)
curr_proj.ftp_host = h.strip()
try:
curr_proj.ftp_port = int(p.strip())
except Exception:
pass
else:
curr_proj.ftp_host = val
elif field == "user":
curr_proj.ftp_user = val
elif field == "pass":
curr_proj.ftp_password = val
# Delete password message from chat if possible for security
try:
await update.message.delete()
except Exception:
pass
elif field == "path":
curr_proj.ftp_path = val if val.startswith("/") else f"/{val}"
session_manager.save()
field_labels = {
"host": "هاست و پورت",
"user": "نام کاربری",
"pass": "رمز عبور",
"path": "مسیر مقصد",
}
label_fa = field_labels.get(field, field)
ack_msg = f"✅ <b>{label_fa} با موفقیت ذخیره شد.</b>" if is_fa else f"✅ <b>{field} updated successfully.</b>"
await update.message.reply_html(ack_msg)
# Auto-test connection if host, user and password are all present
if curr_proj.ftp_host and curr_proj.ftp_user and curr_proj.ftp_password:
test_ok, test_msg = await ftp_manager.test_connection(
host=curr_proj.ftp_host,
port=curr_proj.ftp_port,
user=curr_proj.ftp_user or "",
password=curr_proj.ftp_password or "",
path=curr_proj.ftp_path or "/",
tls=curr_proj.ftp_tls,
)
test_status = "✅ اتصال FTP با موفقیت برقرار و تایید شد." if test_ok else f"⚠️ هشدار در تست اتصال: {test_msg}"
await update.message.reply_html(f"<i>{test_status}</i>")
text, markup = await build_ftp_menu(chat_id)
await update.message.reply_html(text, reply_markup=markup, disable_web_page_preview=True)
return
if not curr_proj:
if is_fa:
msg = (
+32 -11
View File
@@ -1649,13 +1649,17 @@ async def execute_action(
created_task,
)
# Map types: 'project' -> project tier, 'user'/'private' -> user tier, 'global' -> rejected/downgraded for AI
# Map types: 'project' -> project tier, 'user'/'private' -> user tier, 'global' -> global tier (if admin)
is_admin_user = settings.is_admin(chat_id)
if raw_type in ("project", "proj"):
mem_type = "project"
proj_name = req_project
elif raw_type == "global":
# Global memories are strictly bot-managed by Admin. AI cannot auto-save global memories.
logger.info("AI attempted global memory save for user %s; safely converting to user memory.", chat_id)
if is_admin_user:
mem_type = "global"
proj_name = None
else:
logger.info("Non-admin user %s attempted global memory save; converting to user memory.", chat_id)
mem_type = "user"
proj_name = None
else:
@@ -1667,7 +1671,7 @@ async def execute_action(
type_=mem_type,
key=key,
content=content,
user_id=chat_id,
user_id=None if mem_type == "global" else chat_id,
project_name=proj_name,
category=category,
importance=importance,
@@ -1720,28 +1724,37 @@ async def execute_action(
created_task,
)
is_admin_user = settings.is_admin(chat_id)
if raw_type in ("project", "proj"):
mem_type = "project"
proj_name = req_project
elif raw_type == "global" and is_admin_user:
mem_type = "global"
proj_name = None
else:
mem_type = "user"
proj_name = None
try:
existing = memory_manager.get_by_key(type_=mem_type, key=key, user_id=chat_id, project_name=proj_name)
existing = memory_manager.get_by_key(type_=mem_type, key=key, user_id=None if mem_type == "global" else chat_id, project_name=proj_name)
cat = category or (existing.category if existing else "general")
imp = int(importance_raw) if importance_raw and importance_raw.isdigit() else (existing.importance if existing else 1)
item, is_created = memory_manager.save_or_update(
type_=mem_type,
key=key,
content=content,
user_id=chat_id,
user_id=None if mem_type == "global" else chat_id,
project_name=proj_name,
category=cat,
importance=imp,
created_by=chat_id,
)
type_label = f"📁 پروژه (<code>{escape_html(proj_name or '')}</code>)" if item.is_project else "👤 کاربر (سراسری)"
if item.is_project:
type_label = f"📁 پروژه (<code>{escape_html(proj_name or '')}</code>)"
elif item.is_user:
type_label = "👤 کاربر (سراسری)"
else:
type_label = "🌐 عمومی (سیستم)"
badge = (
f"\n\n🧠 <b>خاطره هوش مصنوعی به‌روزرسانی شد:</b>\n"
f"• 🏷️ <b>بخش:</b> {type_label}\n"
@@ -1768,16 +1781,24 @@ async def execute_action(
key = attrs.get("key") or attrs.get("slug") or attrs.get("_default")
raw_type = (attrs.get("type") or "user").lower().strip()
req_project = attrs.get("project") or attrs.get("proj") or current_project_name
is_admin_user = settings.is_admin(chat_id)
mem_type = "project" if raw_type in ("project", "proj") else "user"
proj_name = req_project if mem_type == "project" else None
if raw_type in ("project", "proj"):
mem_type = "project"
proj_name = req_project
elif raw_type == "global" and is_admin_user:
mem_type = "global"
proj_name = None
else:
mem_type = "user"
proj_name = None
try:
if mem_id_raw and mem_id_raw.isdigit():
ok = memory_manager.delete_by_id(int(mem_id_raw), user_id=chat_id, is_admin=is_admin)
ok = memory_manager.delete_by_id(int(mem_id_raw), user_id=chat_id, is_admin=is_admin_user)
elif key:
ok = memory_manager.delete_by_key(
type_=mem_type, key=key, user_id=chat_id, project_name=proj_name, is_admin=is_admin
type_=mem_type, key=key, user_id=None if mem_type == "global" else chat_id, project_name=proj_name, is_admin=is_admin_user
)
else:
return "\n⚠️ لطفاً شناسه یا کلید خاطره جهت حذف را مشخص کنید." if is_fa else "\n⚠️ Please specify memory id or key to delete.", side_effects, created_task
Binary file not shown.
+9 -5
View File
@@ -622,6 +622,7 @@ def format_ftp_info(
user: Optional[str] = None,
path: str = "/",
tls: bool = False,
password: Optional[str] = None,
lang: str = "fa",
) -> str:
"""Formats FTP connection and deployment status for Telegram HTML."""
@@ -630,18 +631,20 @@ def format_ftp_info(
host_str = f"<code>{escape_html(host)}:{port}</code>" if host else ("<i>(تنظیم نشده)</i>" if is_fa else "<i>(Not configured)</i>")
user_str = f"<code>{escape_html(user)}</code>" if user else ("<i>(ندارد)</i>" if is_fa else "<i>(None)</i>")
path_str = f"<code>{escape_html(path)}</code>"
pass_str = f"<code>{'' * min(len(password), 8)}</code> <i>(تنظیم شده)</i>" if password else ("<i>(ندارد)</i>" if is_fa else "<i>(None)</i>")
tls_str = "بله (FTPS/TLS امن) 🔒" if tls else "خیر (FTP معمولی)"
tls_str_en = "Yes (Secure FTPS/TLS) 🔒" if tls else "No (Standard FTP)"
if is_fa:
return (
f"🚀 <b>تنظیمات استقرار و دیپلوی FTP پروژه</b>\n\n"
f"🚀 <b>تنظیمات استقرار و مشخصات FTP پروژه</b>\n\n"
f"• 📁 <b>پروژه:</b> <code>{clean_p}</code>\n"
f"• 🌐 <b>سرور / هاست FTP:</b> {host_str}\n"
f"• 🌐 <b>سرور / هاست:</b> {host_str}\n"
f"• 👤 <b>نام کاربری:</b> {user_str}\n"
f"• 🔑 <b>رمز عبور:</b> {pass_str}\n"
f"• 📂 <b>مسیر مقصد روی هاست:</b> {path_str}\n"
f"• 🔒 <b>پروتکل امن (FTPS):</b> {tls_str}\n\n"
f"💡 <i>در زمان دیپلوی، هوش مصنوعی فایل‌های زائد، کش، سشن‌ها و مخزن گیت (.git) را به صورت خودکار فیلتر کرده و تنها سورس اصلی و تمیز به سرور منتقل می‌شود.</i>"
f"💡 <i>می‌توانید با دکمه‌های زیر هر فیلد را به دلخواه ویرایش، تست یا ذخیره کنید. در زمان دیپلوی، فایل‌های زائد، کش و .git به صورت خودکار فیلتر می‌شوند.</i>"
)
else:
return (
@@ -649,9 +652,10 @@ def format_ftp_info(
f"• 📁 <b>Project:</b> <code>{clean_p}</code>\n"
f"• 🌐 <b>FTP Host:</b> {host_str}\n"
f"• 👤 <b>Username:</b> {user_str}\n"
f"📂 <b>Remote Target Path:</b> {path_str}\n"
f"🔑 <b>Password:</b> {pass_str}\n"
f"• 📂 <b>Remote Path:</b> {path_str}\n"
f"• 🔒 <b>Secure FTPS/TLS:</b> {tls_str_en}\n\n"
f"💡 <i>During deployment, unnecessary cache, temp, sessions, and .git files are automatically excluded for a clean production upload.</i>"
f"💡 <i>Use the buttons below to edit fields, test connection, or deploy. Junk and cache files are excluded automatically.</i>"
)
+121 -23
View File
@@ -8,6 +8,9 @@ from pathlib import Path
from typing import Optional, Dict, Any, List, Tuple, Set
import ftplib
import socket
import subprocess
import tempfile
import shutil
logger = logging.getLogger("AGYFTPManager")
@@ -52,6 +55,10 @@ DEFAULT_FTP_EXCLUDES = [
"env",
"env/*",
# Dependencies & local packages
"node_modules",
"node_modules/*",
# Logs & temp files
"*.log",
"tmp",
@@ -77,20 +84,23 @@ def should_exclude(rel_path: str, custom_excludes: Optional[List[str]] = None) -
"""Checks if a relative path matches any exclusion pattern."""
excludes = DEFAULT_FTP_EXCLUDES + (custom_excludes or [])
norm_path = rel_path.replace("\\", "/").strip("/")
parts = norm_path.split("/")
parts = [p for p in norm_path.split("/") if p]
for pattern in excludes:
pat = pattern.replace("\\", "/").strip("/")
clean_pat = pat.rstrip("/*")
# Check against full relative path
if fnmatch.fnmatch(norm_path, pat):
if fnmatch.fnmatch(norm_path, pat) or fnmatch.fnmatch(norm_path, clean_pat):
return True
# If path starts with directory pattern
if norm_path == clean_pat or norm_path.startswith(clean_pat + "/"):
return True
# Check against individual directory / file parts
for part in parts:
if fnmatch.fnmatch(part, pat):
return True
# If pattern is a directory (e.g. .git/* or .git), match if path starts with it
clean_pat = pat.rstrip("/*")
if norm_path == clean_pat or norm_path.startswith(clean_pat + "/"):
if fnmatch.fnmatch(part, pat) or fnmatch.fnmatch(part, clean_pat):
return True
return False
@@ -109,17 +119,27 @@ class FTPManager:
timeout: int = 15,
) -> ftplib.FTP:
"""Helper to create and authenticate an FTP or FTPS client."""
clean_host = host.strip()
clean_user = user.strip() if user else ""
clean_pass = password.strip() if password else ""
if tls:
ftp = ftplib.FTP_TLS(timeout=timeout)
else:
ftp = ftplib.FTP(timeout=timeout)
ftp.connect(host=host, port=port, timeout=timeout)
ftp.login(user=user or "anonymous", passwd=password or "")
ftp.encoding = "utf-8"
ftp.connect(host=clean_host, port=int(port), timeout=timeout)
if tls and isinstance(ftp, ftplib.FTP_TLS):
ftp.auth()
ftp.login(user=clean_user or "anonymous", passwd=clean_pass)
ftp.prot_p() # Secure data connection
else:
ftp.login(user=clean_user or "anonymous", passwd=clean_pass)
# Force passive mode (standard for firewalls / NAT)
ftp.set_pasv(True)
return ftp
async def test_connection(
@@ -130,39 +150,72 @@ class FTPManager:
password: str = "",
path: str = "/",
tls: bool = False,
timeout: int = 10,
timeout: int = 12,
) -> Tuple[bool, str]:
"""Tests FTP credentials and verifies access to the target remote directory."""
if not host or not host.strip():
return False, "آدرس هاست (Host) FTP مشخص نشده است."
def _test():
ftp = None
try:
ftp = self._create_ftp_client(host.strip(), int(port), user.strip(), password, tls, timeout)
# Check root PWD
init_pwd = ftp.pwd()
# Test CWD to target path if specified
target_path = path.strip() if path else "/"
if target_path and target_path != "/":
try:
ftp.cwd(target_path)
except Exception as e:
pwd = ftp.pwd()
curr_pwd = ftp.pwd() if ftp else "/"
try:
ftp.quit()
return False, f"اتصال به FTP برقرار شد، اما مسیر ریموت «{target_path}» یافت نشد (مسیر فعلی: {pwd}): {e}"
except Exception:
pass
return False, f"اتصال به سرور برقرار شد، اما مسیر ریموت «{target_path}» یافت نشد (مسیر فعلی: {curr_pwd}): {e}"
pwd = ftp.pwd()
final_pwd = ftp.pwd()
# Test directory listing
item_count = 0
try:
listing = ftp.nlst()
count = len(listing)
item_count = len(listing)
except Exception:
count = 0
try:
lines = []
ftp.dir(lines.append)
item_count = len(lines)
except Exception:
item_count = 0
try:
ftp.quit()
return True, f"اتصال با موفقیت برقرار شد. مسیر فعلی: <code>{pwd}</code> (تعداد آیتم‌ها: {count})"
except (socket.gaierror, socket.timeout) as e:
return False, f"خطای شبکه / نامعتبر بودن آدرس سرور ({host}:{port}): {e}"
except Exception:
pass
tls_label = " (FTPS/TLS امن)" if tls else " (FTP معمولی)"
return True, f"اتصال با موفقیت برقرار شد{tls_label} | مسیر: <code>{final_pwd}</code> | تعداد فایل‌ها و پوشه‌ها: {item_count}"
except socket.gaierror as e:
return False, f"نام دامنه/هاست سرور یافت نشد ({host}): {e}"
except (socket.timeout, TimeoutError) as e:
return False, f"مهلت زمانی اتصال به سرور به پایان رسید (Timeout روی پورت {port}): {e}"
except ConnectionRefusedError as e:
return False, f"اتصال توسط سرور رد شد (پورت {port} بسته است یا FTP روی آن فعال نیست): {e}"
except ftplib.error_perm as e:
return False, f"خطای احراز هویت / دسترسی FTP: {e}"
return False, f"خطای نام کاربری یا رمز عبور (دسترسی نامعتبر): {e}"
except Exception as e:
return False, f"خطا در برقراری ارتباط با FTP: {e}"
finally:
if ftp:
try:
ftp.close()
except Exception:
pass
return await asyncio.to_thread(_test)
@@ -177,11 +230,16 @@ class FTPManager:
tls: bool = False,
custom_excludes: Optional[List[str]] = None,
dry_run: bool = False,
branch: str = "production",
) -> Dict[str, Any]:
"""
Deploys files from workspace_path to the remote FTP server cleanly.
Deploys files from the specified git branch (default: production) to the remote FTP server cleanly.
Extracts files directly from the git branch to ensure exact production code is deployed.
Skips all temporary, git, session, and junk files.
"""
import tempfile
import shutil
ws = Path(workspace_path).expanduser().resolve()
if not ws.exists() or not ws.is_dir():
return {
@@ -199,8 +257,42 @@ class FTPManager:
uploaded_files: List[str] = []
skipped_files: List[str] = []
total_bytes = 0
temp_export_dir = None
try:
# 1. Determine source directory: if git exists, export the exact production branch
source_dir = ws
if (ws / ".git").exists():
try:
# Check if branch exists
branches_proc = subprocess.run(
["git", "branch", "--list", branch],
cwd=str(ws),
capture_output=True,
text=True,
)
target_branch = branch if branch in branches_proc.stdout else "HEAD"
temp_export_dir = Path(tempfile.mkdtemp(prefix="ftp_deploy_prod_"))
# Use git archive to cleanly extract files from the target branch
archive_proc = subprocess.Popen(
["git", "archive", target_branch],
cwd=str(ws),
stdout=subprocess.PIPE,
)
tar_proc = subprocess.Popen(
["tar", "-x", "-C", str(temp_export_dir)],
stdin=archive_proc.stdout,
)
archive_proc.stdout.close()
tar_proc.communicate()
if tar_proc.returncode == 0:
source_dir = temp_export_dir
except Exception as ge:
logger.warning(f"Git archive export failed for {branch} (falling back to workspace): {ge}")
source_dir = ws
ftp = self._create_ftp_client(host.strip(), int(port), user.strip(), password, tls, timeout=20)
# Navigate or create remote root path
@@ -230,11 +322,11 @@ class FTPManager:
ensure_remote_dir(target_root)
# Scan workspace
# Scan source directory
all_files_to_upload: List[Tuple[Path, str]] = [] # (local_path, rel_path)
for root, dirs, files in os.walk(str(ws)):
rel_dir = os.path.relpath(root, str(ws))
for root, dirs, files in os.walk(str(source_dir)):
rel_dir = os.path.relpath(root, str(source_dir))
if rel_dir == ".":
rel_dir = ""
@@ -309,6 +401,12 @@ class FTPManager:
"duration": round(time.time() - start_time, 2),
"uploaded_list": uploaded_files,
}
finally:
if temp_export_dir and Path(temp_export_dir).exists():
try:
shutil.rmtree(temp_export_dir, ignore_errors=True)
except Exception:
pass
return await asyncio.to_thread(_run_deploy)
+6
View File
@@ -1,6 +1,7 @@
import os
import sys
import json
import html
import logging
import asyncio
import urllib.request
@@ -12,6 +13,11 @@ from config import settings
logger = logging.getLogger("AGYGitManager")
def escape_html(text: str) -> str:
if not text:
return ""
return html.escape(str(text), quote=False)
DEFAULT_GITIGNORE = """# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
File diff suppressed because one or more lines are too long