899 lines
37 KiB
Python
899 lines
37 KiB
Python
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import shutil
|
|
import tempfile
|
|
import zipfile
|
|
from datetime import datetime
|
|
from typing import Any, Callable, Dict, List, Optional
|
|
|
|
from fastapi import HTTPException, UploadFile
|
|
from sqlmodel import Session, select
|
|
|
|
from clients.edge.errors import log_edge_failure
|
|
from core.settings import BOTS_WORKSPACE_ROOT, DATA_ROOT
|
|
from models.bot import BotInstance
|
|
from models.skill import BotSkillInstall, SkillMarketItem
|
|
from services.platform_settings_service import get_platform_settings_snapshot
|
|
|
|
EdgeStateContextResolver = Callable[[str], Optional[tuple[Any, Optional[str], str]]]
|
|
|
|
|
|
class SkillService:
|
|
def _require_bot(self, *, session: Session, bot_id: str) -> BotInstance:
|
|
bot = session.get(BotInstance, bot_id)
|
|
if not bot:
|
|
raise HTTPException(status_code=404, detail="Bot not found")
|
|
return bot
|
|
|
|
def _workspace_root(self, bot_id: str) -> str:
|
|
return os.path.abspath(os.path.join(BOTS_WORKSPACE_ROOT, bot_id, ".nanobot", "workspace"))
|
|
|
|
def _skills_root(self, bot_id: str) -> str:
|
|
return os.path.join(self._workspace_root(bot_id), "skills")
|
|
|
|
def _skill_market_root(self) -> str:
|
|
return os.path.abspath(os.path.join(DATA_ROOT, "skills"))
|
|
|
|
def _is_valid_top_level_skill_name(self, name: str) -> bool:
|
|
text = str(name or "").strip()
|
|
if not text:
|
|
return False
|
|
if "/" in text or "\\" in text:
|
|
return False
|
|
if text in {".", ".."}:
|
|
return False
|
|
return True
|
|
|
|
def _read_skill_description(self, entry_path: str) -> str:
|
|
candidates: List[str] = []
|
|
if os.path.isdir(entry_path):
|
|
candidates = [
|
|
os.path.join(entry_path, "SKILL.md"),
|
|
os.path.join(entry_path, "skill.md"),
|
|
os.path.join(entry_path, "README.md"),
|
|
os.path.join(entry_path, "readme.md"),
|
|
]
|
|
elif entry_path.lower().endswith(".md"):
|
|
candidates = [entry_path]
|
|
|
|
for candidate in candidates:
|
|
if not os.path.isfile(candidate):
|
|
continue
|
|
try:
|
|
with open(candidate, "r", encoding="utf-8") as file:
|
|
for line in file:
|
|
text = line.strip()
|
|
if text and not text.startswith("#"):
|
|
return text[:240]
|
|
except Exception:
|
|
continue
|
|
return ""
|
|
|
|
def list_workspace_skills(
|
|
self,
|
|
*,
|
|
bot_id: str,
|
|
resolve_edge_state_context: EdgeStateContextResolver,
|
|
logger: logging.Logger,
|
|
) -> List[Dict[str, Any]]:
|
|
edge_context = resolve_edge_state_context(bot_id)
|
|
if edge_context is not None:
|
|
client, workspace_root, node_id = edge_context
|
|
try:
|
|
payload = client.list_tree(
|
|
bot_id=bot_id,
|
|
path="skills",
|
|
recursive=False,
|
|
workspace_root=workspace_root,
|
|
)
|
|
except Exception as exc:
|
|
log_edge_failure(
|
|
logger,
|
|
key=f"skills-list:{node_id}:{bot_id}",
|
|
exc=exc,
|
|
message=f"Failed to list skills from edge workspace for bot_id={bot_id}",
|
|
)
|
|
return []
|
|
rows: List[Dict[str, Any]] = []
|
|
for entry in list(payload.get("entries") or []):
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
name = str(entry.get("name") or "").strip()
|
|
if not name or name.startswith("."):
|
|
continue
|
|
if not self._is_valid_top_level_skill_name(name):
|
|
continue
|
|
entry_type = str(entry.get("type") or "").strip().lower()
|
|
if entry_type not in {"dir", "file"}:
|
|
continue
|
|
mtime = str(entry.get("mtime") or "").strip() or (datetime.utcnow().isoformat() + "Z")
|
|
size = entry.get("size")
|
|
rows.append(
|
|
{
|
|
"id": name,
|
|
"name": name,
|
|
"type": entry_type,
|
|
"path": f"skills/{name}",
|
|
"size": int(size) if isinstance(size, (int, float)) and entry_type == "file" else None,
|
|
"mtime": mtime,
|
|
"description": "",
|
|
}
|
|
)
|
|
rows.sort(key=lambda row: (row.get("type") != "dir", str(row.get("name") or "").lower()))
|
|
return rows
|
|
|
|
root = self._skills_root(bot_id)
|
|
if not os.path.isdir(root):
|
|
return []
|
|
rows: List[Dict[str, Any]] = []
|
|
names = sorted(os.listdir(root), key=lambda name: (not os.path.isdir(os.path.join(root, name)), name.lower()))
|
|
for name in names:
|
|
if not name or name.startswith("."):
|
|
continue
|
|
if not self._is_valid_top_level_skill_name(name):
|
|
continue
|
|
abs_path = os.path.join(root, name)
|
|
if not os.path.exists(abs_path):
|
|
continue
|
|
stat = os.stat(abs_path)
|
|
rows.append(
|
|
{
|
|
"id": name,
|
|
"name": name,
|
|
"type": "dir" if os.path.isdir(abs_path) else "file",
|
|
"path": f"skills/{name}",
|
|
"size": stat.st_size if os.path.isfile(abs_path) else None,
|
|
"mtime": datetime.utcfromtimestamp(stat.st_mtime).isoformat() + "Z",
|
|
"description": self._read_skill_description(abs_path),
|
|
}
|
|
)
|
|
return rows
|
|
|
|
def list_workspace_skills_for_bot(
|
|
self,
|
|
*,
|
|
session: Session,
|
|
bot_id: str,
|
|
resolve_edge_state_context: EdgeStateContextResolver,
|
|
logger: logging.Logger,
|
|
) -> List[Dict[str, Any]]:
|
|
self._require_bot(session=session, bot_id=bot_id)
|
|
return self.list_workspace_skills(
|
|
bot_id=bot_id,
|
|
resolve_edge_state_context=resolve_edge_state_context,
|
|
logger=logger,
|
|
)
|
|
|
|
def _parse_json_string_list(self, raw: Any) -> List[str]:
|
|
if not raw:
|
|
return []
|
|
try:
|
|
data = json.loads(str(raw))
|
|
except Exception:
|
|
return []
|
|
if not isinstance(data, list):
|
|
return []
|
|
rows: List[str] = []
|
|
for item in data:
|
|
text = str(item or "").strip()
|
|
if text and text not in rows:
|
|
rows.append(text)
|
|
return rows
|
|
|
|
def _is_ignored_skill_zip_top_level(self, name: str) -> bool:
|
|
text = str(name or "").strip()
|
|
if not text:
|
|
return True
|
|
lowered = text.lower()
|
|
if lowered == "__macosx":
|
|
return True
|
|
if text.startswith("."):
|
|
return True
|
|
return False
|
|
|
|
def _read_description_from_text(self, raw: str) -> str:
|
|
for line in str(raw or "").splitlines():
|
|
text = line.strip()
|
|
if text and not text.startswith("#"):
|
|
return text[:240]
|
|
return ""
|
|
|
|
def _extract_skill_zip_summary(self, zip_path: str) -> Dict[str, Any]:
|
|
entry_names: List[str] = []
|
|
description = ""
|
|
with zipfile.ZipFile(zip_path) as archive:
|
|
members = archive.infolist()
|
|
file_members = [member for member in members if not member.is_dir()]
|
|
for member in file_members:
|
|
raw_name = str(member.filename or "").replace("\\", "/").lstrip("/")
|
|
if not raw_name:
|
|
continue
|
|
first = raw_name.split("/", 1)[0].strip()
|
|
if self._is_ignored_skill_zip_top_level(first):
|
|
continue
|
|
if self._is_valid_top_level_skill_name(first) and first not in entry_names:
|
|
entry_names.append(first)
|
|
|
|
candidates = sorted(
|
|
[
|
|
str(member.filename or "").replace("\\", "/").lstrip("/")
|
|
for member in file_members
|
|
if str(member.filename or "").replace("\\", "/").rsplit("/", 1)[-1].lower() in {"skill.md", "readme.md"}
|
|
],
|
|
key=lambda value: (value.count("/"), value.lower()),
|
|
)
|
|
for candidate in candidates:
|
|
try:
|
|
with archive.open(candidate, "r") as file:
|
|
preview = file.read(4096).decode("utf-8", errors="ignore")
|
|
description = self._read_description_from_text(preview)
|
|
if description:
|
|
break
|
|
except Exception:
|
|
continue
|
|
return {
|
|
"entry_names": entry_names,
|
|
"description": description,
|
|
}
|
|
|
|
def _sanitize_skill_market_key(self, raw: Any) -> str:
|
|
value = str(raw or "").strip().lower()
|
|
value = re.sub(r"[^a-z0-9._-]+", "-", value)
|
|
value = re.sub(r"-{2,}", "-", value).strip("._-")
|
|
return value[:120]
|
|
|
|
def _sanitize_zip_filename(self, raw: Any) -> str:
|
|
filename = os.path.basename(str(raw or "").strip())
|
|
if not filename:
|
|
return ""
|
|
filename = filename.replace("\\", "/").rsplit("/", 1)[-1]
|
|
stem, ext = os.path.splitext(filename)
|
|
safe_stem = re.sub(r"[^A-Za-z0-9._-]+", "-", stem).strip("._-")
|
|
if not safe_stem:
|
|
safe_stem = "skill-package"
|
|
safe_ext = ".zip" if ext.lower() == ".zip" else ""
|
|
return f"{safe_stem[:180]}{safe_ext}"
|
|
|
|
def _resolve_unique_skill_market_key(self, session: Session, preferred_key: str, exclude_id: Optional[int] = None) -> str:
|
|
base_key = self._sanitize_skill_market_key(preferred_key) or "skill"
|
|
candidate = base_key
|
|
counter = 2
|
|
while True:
|
|
stmt = select(SkillMarketItem).where(SkillMarketItem.skill_key == candidate)
|
|
rows = session.exec(stmt).all()
|
|
conflict = next((row for row in rows if exclude_id is None or row.id != exclude_id), None)
|
|
if not conflict:
|
|
return candidate
|
|
candidate = f"{base_key}-{counter}"
|
|
counter += 1
|
|
|
|
def _resolve_unique_skill_market_zip_filename(
|
|
self,
|
|
session: Session,
|
|
filename: str,
|
|
*,
|
|
exclude_filename: Optional[str] = None,
|
|
exclude_id: Optional[int] = None,
|
|
) -> str:
|
|
root = self._skill_market_root()
|
|
os.makedirs(root, exist_ok=True)
|
|
safe_name = self._sanitize_zip_filename(filename)
|
|
if not safe_name.lower().endswith(".zip"):
|
|
raise HTTPException(status_code=400, detail="Only .zip skill package is supported")
|
|
candidate = safe_name
|
|
stem, ext = os.path.splitext(safe_name)
|
|
counter = 2
|
|
while True:
|
|
file_conflict = os.path.exists(os.path.join(root, candidate)) and candidate != str(exclude_filename or "").strip()
|
|
rows = session.exec(select(SkillMarketItem).where(SkillMarketItem.zip_filename == candidate)).all()
|
|
db_conflict = next((row for row in rows if exclude_id is None or row.id != exclude_id), None)
|
|
if not file_conflict and not db_conflict:
|
|
return candidate
|
|
candidate = f"{stem}-{counter}{ext}"
|
|
counter += 1
|
|
|
|
async def _store_skill_market_zip_upload(
|
|
self,
|
|
session: Session,
|
|
upload: UploadFile,
|
|
*,
|
|
exclude_filename: Optional[str] = None,
|
|
exclude_id: Optional[int] = None,
|
|
) -> Dict[str, Any]:
|
|
root = self._skill_market_root()
|
|
os.makedirs(root, exist_ok=True)
|
|
|
|
incoming_name = self._sanitize_zip_filename(upload.filename or "")
|
|
if not incoming_name.lower().endswith(".zip"):
|
|
raise HTTPException(status_code=400, detail="Only .zip skill package is supported")
|
|
|
|
target_filename = self._resolve_unique_skill_market_zip_filename(
|
|
session,
|
|
incoming_name,
|
|
exclude_filename=exclude_filename,
|
|
exclude_id=exclude_id,
|
|
)
|
|
max_bytes = get_platform_settings_snapshot().upload_max_mb * 1024 * 1024
|
|
total_size = 0
|
|
tmp_path: Optional[str] = None
|
|
try:
|
|
with tempfile.NamedTemporaryFile(prefix=".skill_market_", suffix=".zip", dir=root, delete=False) as tmp_zip:
|
|
tmp_path = tmp_zip.name
|
|
while True:
|
|
chunk = await upload.read(1024 * 1024)
|
|
if not chunk:
|
|
break
|
|
total_size += len(chunk)
|
|
if total_size > max_bytes:
|
|
raise HTTPException(
|
|
status_code=413,
|
|
detail=f"Zip package too large (max {max_bytes // (1024 * 1024)}MB)",
|
|
)
|
|
tmp_zip.write(chunk)
|
|
if total_size == 0:
|
|
raise HTTPException(status_code=400, detail="Zip package is empty")
|
|
summary = self._extract_skill_zip_summary(tmp_path)
|
|
if not summary["entry_names"]:
|
|
raise HTTPException(status_code=400, detail="Zip package has no valid skill entries")
|
|
final_path = os.path.join(root, target_filename)
|
|
os.replace(tmp_path, final_path)
|
|
tmp_path = None
|
|
return {
|
|
"zip_filename": target_filename,
|
|
"zip_size_bytes": total_size,
|
|
"entry_names": summary["entry_names"],
|
|
"description": summary["description"],
|
|
}
|
|
except zipfile.BadZipFile as exc:
|
|
raise HTTPException(status_code=400, detail="Invalid zip file") from exc
|
|
finally:
|
|
await upload.close()
|
|
if tmp_path and os.path.exists(tmp_path):
|
|
os.remove(tmp_path)
|
|
|
|
def serialize_skill_market_item(
|
|
self,
|
|
item: SkillMarketItem,
|
|
*,
|
|
install_count: int = 0,
|
|
install_row: Optional[BotSkillInstall] = None,
|
|
workspace_installed: Optional[bool] = None,
|
|
installed_entries: Optional[List[str]] = None,
|
|
) -> Dict[str, Any]:
|
|
zip_path = os.path.join(self._skill_market_root(), str(item.zip_filename or ""))
|
|
entry_names = self._parse_json_string_list(item.entry_names_json)
|
|
payload = {
|
|
"id": item.id,
|
|
"skill_key": item.skill_key,
|
|
"display_name": item.display_name or item.skill_key,
|
|
"description": item.description or "",
|
|
"zip_filename": item.zip_filename,
|
|
"zip_size_bytes": int(item.zip_size_bytes or 0),
|
|
"entry_names": entry_names,
|
|
"entry_count": len(entry_names),
|
|
"zip_exists": os.path.isfile(zip_path),
|
|
"install_count": int(install_count or 0),
|
|
"created_at": item.created_at.isoformat() + "Z" if item.created_at else None,
|
|
"updated_at": item.updated_at.isoformat() + "Z" if item.updated_at else None,
|
|
}
|
|
if install_row is not None:
|
|
resolved_entries = installed_entries if installed_entries is not None else self._parse_json_string_list(install_row.installed_entries_json)
|
|
resolved_installed = workspace_installed if workspace_installed is not None else install_row.status == "INSTALLED"
|
|
payload.update(
|
|
{
|
|
"installed": resolved_installed,
|
|
"install_status": install_row.status,
|
|
"installed_at": install_row.installed_at.isoformat() + "Z" if install_row.installed_at else None,
|
|
"installed_entries": resolved_entries,
|
|
"install_error": install_row.last_error,
|
|
}
|
|
)
|
|
return payload
|
|
|
|
def list_market_items(self, *, session: Session) -> List[Dict[str, Any]]:
|
|
items = session.exec(select(SkillMarketItem).order_by(SkillMarketItem.display_name, SkillMarketItem.id)).all()
|
|
installs = session.exec(select(BotSkillInstall)).all()
|
|
install_count_by_skill: Dict[int, int] = {}
|
|
for row in installs:
|
|
skill_id = int(row.skill_market_item_id or 0)
|
|
if skill_id <= 0 or row.status != "INSTALLED":
|
|
continue
|
|
install_count_by_skill[skill_id] = install_count_by_skill.get(skill_id, 0) + 1
|
|
return [
|
|
self.serialize_skill_market_item(item, install_count=install_count_by_skill.get(int(item.id or 0), 0))
|
|
for item in items
|
|
]
|
|
|
|
async def create_market_item(
|
|
self,
|
|
*,
|
|
session: Session,
|
|
skill_key: str,
|
|
display_name: str,
|
|
description: str,
|
|
file: UploadFile,
|
|
) -> Dict[str, Any]:
|
|
upload_meta = await self._store_skill_market_zip_upload(session, file)
|
|
try:
|
|
preferred_key = skill_key or display_name or os.path.splitext(upload_meta["zip_filename"])[0]
|
|
next_key = self._resolve_unique_skill_market_key(session, preferred_key)
|
|
item = SkillMarketItem(
|
|
skill_key=next_key,
|
|
display_name=str(display_name or next_key).strip() or next_key,
|
|
description=str(description or upload_meta["description"] or "").strip(),
|
|
zip_filename=upload_meta["zip_filename"],
|
|
zip_size_bytes=int(upload_meta["zip_size_bytes"] or 0),
|
|
entry_names_json=json.dumps(upload_meta["entry_names"], ensure_ascii=False),
|
|
)
|
|
session.add(item)
|
|
session.commit()
|
|
session.refresh(item)
|
|
return self.serialize_skill_market_item(item, install_count=0)
|
|
except Exception:
|
|
target_path = os.path.join(self._skill_market_root(), upload_meta["zip_filename"])
|
|
if os.path.exists(target_path):
|
|
os.remove(target_path)
|
|
raise
|
|
|
|
async def update_market_item(
|
|
self,
|
|
*,
|
|
session: Session,
|
|
skill_id: int,
|
|
skill_key: str,
|
|
display_name: str,
|
|
description: str,
|
|
file: Optional[UploadFile],
|
|
) -> Dict[str, Any]:
|
|
item = session.get(SkillMarketItem, skill_id)
|
|
if not item:
|
|
raise HTTPException(status_code=404, detail="Skill market item not found")
|
|
|
|
old_filename = str(item.zip_filename or "").strip()
|
|
upload_meta: Optional[Dict[str, Any]] = None
|
|
if file is not None:
|
|
upload_meta = await self._store_skill_market_zip_upload(
|
|
session,
|
|
file,
|
|
exclude_filename=old_filename or None,
|
|
exclude_id=item.id,
|
|
)
|
|
|
|
next_key = self._resolve_unique_skill_market_key(
|
|
session,
|
|
skill_key or item.skill_key or display_name or os.path.splitext(upload_meta["zip_filename"] if upload_meta else old_filename)[0],
|
|
exclude_id=item.id,
|
|
)
|
|
item.skill_key = next_key
|
|
item.display_name = str(display_name or item.display_name or next_key).strip() or next_key
|
|
item.description = str(description or (upload_meta["description"] if upload_meta else item.description) or "").strip()
|
|
item.updated_at = datetime.utcnow()
|
|
if upload_meta:
|
|
item.zip_filename = upload_meta["zip_filename"]
|
|
item.zip_size_bytes = int(upload_meta["zip_size_bytes"] or 0)
|
|
item.entry_names_json = json.dumps(upload_meta["entry_names"], ensure_ascii=False)
|
|
session.add(item)
|
|
session.commit()
|
|
session.refresh(item)
|
|
|
|
if upload_meta and old_filename and old_filename != upload_meta["zip_filename"]:
|
|
old_path = os.path.join(self._skill_market_root(), old_filename)
|
|
if os.path.exists(old_path):
|
|
os.remove(old_path)
|
|
|
|
installs = session.exec(select(BotSkillInstall).where(BotSkillInstall.skill_market_item_id == skill_id)).all()
|
|
install_count = sum(1 for row in installs if row.status == "INSTALLED")
|
|
return self.serialize_skill_market_item(item, install_count=install_count)
|
|
|
|
def delete_market_item(self, *, session: Session, skill_id: int) -> Dict[str, Any]:
|
|
item = session.get(SkillMarketItem, skill_id)
|
|
if not item:
|
|
raise HTTPException(status_code=404, detail="Skill market item not found")
|
|
zip_filename = str(item.zip_filename or "").strip()
|
|
installs = session.exec(select(BotSkillInstall).where(BotSkillInstall.skill_market_item_id == skill_id)).all()
|
|
for row in installs:
|
|
session.delete(row)
|
|
session.delete(item)
|
|
session.commit()
|
|
if zip_filename:
|
|
zip_path = os.path.join(self._skill_market_root(), zip_filename)
|
|
if os.path.exists(zip_path):
|
|
os.remove(zip_path)
|
|
return {"status": "deleted", "id": skill_id}
|
|
|
|
def list_bot_market_items(
|
|
self,
|
|
*,
|
|
bot_id: str,
|
|
session: Session,
|
|
resolve_edge_state_context: EdgeStateContextResolver,
|
|
logger: logging.Logger,
|
|
) -> List[Dict[str, Any]]:
|
|
items = session.exec(select(SkillMarketItem).order_by(SkillMarketItem.display_name, SkillMarketItem.id)).all()
|
|
install_rows = session.exec(select(BotSkillInstall).where(BotSkillInstall.bot_id == bot_id)).all()
|
|
install_lookup = {int(row.skill_market_item_id): row for row in install_rows}
|
|
all_install_rows = session.exec(select(BotSkillInstall)).all()
|
|
install_count_by_skill: Dict[int, int] = {}
|
|
for row in all_install_rows:
|
|
skill_id = int(row.skill_market_item_id or 0)
|
|
if skill_id <= 0 or row.status != "INSTALLED":
|
|
continue
|
|
install_count_by_skill[skill_id] = install_count_by_skill.get(skill_id, 0) + 1
|
|
workspace_skill_names = {
|
|
str(row.get("name") or "").strip()
|
|
for row in self.list_workspace_skills(bot_id=bot_id, resolve_edge_state_context=resolve_edge_state_context, logger=logger)
|
|
}
|
|
return [
|
|
self.serialize_skill_market_item(
|
|
item,
|
|
install_count=install_count_by_skill.get(int(item.id or 0), 0),
|
|
install_row=install_lookup.get(int(item.id or 0)),
|
|
workspace_installed=(
|
|
None
|
|
if install_lookup.get(int(item.id or 0)) is None
|
|
else (
|
|
install_lookup[int(item.id or 0)].status == "INSTALLED"
|
|
and all(
|
|
name in workspace_skill_names
|
|
for name in self._parse_json_string_list(install_lookup[int(item.id or 0)].installed_entries_json)
|
|
)
|
|
)
|
|
),
|
|
installed_entries=(
|
|
None
|
|
if install_lookup.get(int(item.id or 0)) is None
|
|
else self._parse_json_string_list(install_lookup[int(item.id or 0)].installed_entries_json)
|
|
),
|
|
)
|
|
for item in items
|
|
]
|
|
|
|
def list_bot_market_items_for_bot(
|
|
self,
|
|
*,
|
|
session: Session,
|
|
bot_id: str,
|
|
resolve_edge_state_context: EdgeStateContextResolver,
|
|
logger: logging.Logger,
|
|
) -> List[Dict[str, Any]]:
|
|
self._require_bot(session=session, bot_id=bot_id)
|
|
return self.list_bot_market_items(
|
|
bot_id=bot_id,
|
|
session=session,
|
|
resolve_edge_state_context=resolve_edge_state_context,
|
|
logger=logger,
|
|
)
|
|
|
|
def _install_skill_zip_into_workspace(
|
|
self,
|
|
*,
|
|
bot_id: str,
|
|
zip_path: str,
|
|
resolve_edge_state_context: EdgeStateContextResolver,
|
|
logger: logging.Logger,
|
|
) -> Dict[str, Any]:
|
|
try:
|
|
archive = zipfile.ZipFile(zip_path)
|
|
except Exception as exc:
|
|
raise HTTPException(status_code=400, detail="Invalid zip file") from exc
|
|
|
|
edge_context = resolve_edge_state_context(bot_id)
|
|
skills_root = self._skills_root(bot_id)
|
|
installed: List[str] = []
|
|
with archive:
|
|
members = archive.infolist()
|
|
file_members = [member for member in members if not member.is_dir()]
|
|
if not file_members:
|
|
raise HTTPException(status_code=400, detail="Zip package has no files")
|
|
|
|
top_names: List[str] = []
|
|
for member in file_members:
|
|
raw_name = str(member.filename or "").replace("\\", "/").lstrip("/")
|
|
if not raw_name:
|
|
continue
|
|
first = raw_name.split("/", 1)[0].strip()
|
|
if self._is_ignored_skill_zip_top_level(first):
|
|
continue
|
|
if not self._is_valid_top_level_skill_name(first):
|
|
raise HTTPException(status_code=400, detail=f"Invalid skill entry name in zip: {first}")
|
|
if first not in top_names:
|
|
top_names.append(first)
|
|
|
|
if not top_names:
|
|
raise HTTPException(status_code=400, detail="Zip package has no valid skill entries")
|
|
|
|
if edge_context is not None:
|
|
existing_names = {
|
|
str(item.get("name") or "").strip()
|
|
for item in self.list_workspace_skills(
|
|
bot_id=bot_id,
|
|
resolve_edge_state_context=resolve_edge_state_context,
|
|
logger=logger,
|
|
)
|
|
if isinstance(item, dict)
|
|
}
|
|
conflicts = [name for name in top_names if name in existing_names]
|
|
else:
|
|
os.makedirs(skills_root, exist_ok=True)
|
|
conflicts = [name for name in top_names if os.path.exists(os.path.join(skills_root, name))]
|
|
if conflicts:
|
|
raise HTTPException(status_code=400, detail=f"Skill already exists: {', '.join(conflicts)}")
|
|
|
|
temp_dir_root = skills_root if edge_context is None else None
|
|
with tempfile.TemporaryDirectory(prefix=".skill_upload_", dir=temp_dir_root) as tmp_dir:
|
|
tmp_root = os.path.abspath(tmp_dir)
|
|
for member in members:
|
|
raw_name = str(member.filename or "").replace("\\", "/").lstrip("/")
|
|
if not raw_name:
|
|
continue
|
|
target = os.path.abspath(os.path.join(tmp_root, raw_name))
|
|
if os.path.commonpath([tmp_root, target]) != tmp_root:
|
|
raise HTTPException(status_code=400, detail=f"Unsafe zip entry path: {raw_name}")
|
|
if member.is_dir():
|
|
os.makedirs(target, exist_ok=True)
|
|
continue
|
|
os.makedirs(os.path.dirname(target), exist_ok=True)
|
|
with archive.open(member, "r") as source, open(target, "wb") as dest:
|
|
shutil.copyfileobj(source, dest)
|
|
|
|
if edge_context is not None:
|
|
client, workspace_root, _node_id = edge_context
|
|
upload_groups: Dict[str, List[str]] = {}
|
|
for name in top_names:
|
|
src = os.path.join(tmp_root, name)
|
|
if not os.path.exists(src):
|
|
continue
|
|
if os.path.isfile(src):
|
|
upload_groups.setdefault("skills", []).append(src)
|
|
installed.append(name)
|
|
continue
|
|
for walk_root, _dirs, files in os.walk(src):
|
|
for filename in files:
|
|
local_path = os.path.join(walk_root, filename)
|
|
relative_path = os.path.relpath(local_path, tmp_root).replace("\\", "/")
|
|
relative_dir = os.path.dirname(relative_path).strip("/")
|
|
target_dir = f"skills/{relative_dir}" if relative_dir else "skills"
|
|
upload_groups.setdefault(target_dir, []).append(local_path)
|
|
installed.append(name)
|
|
for target_dir, local_paths in upload_groups.items():
|
|
client.upload_local_files(
|
|
bot_id=bot_id,
|
|
local_paths=local_paths,
|
|
path=target_dir,
|
|
workspace_root=workspace_root,
|
|
)
|
|
else:
|
|
for name in top_names:
|
|
src = os.path.join(tmp_root, name)
|
|
dst = os.path.join(skills_root, name)
|
|
if not os.path.exists(src):
|
|
continue
|
|
shutil.move(src, dst)
|
|
installed.append(name)
|
|
|
|
if not installed:
|
|
raise HTTPException(status_code=400, detail="No skill entries installed from zip")
|
|
|
|
return {
|
|
"installed": installed,
|
|
"skills": self.list_workspace_skills(
|
|
bot_id=bot_id,
|
|
resolve_edge_state_context=resolve_edge_state_context,
|
|
logger=logger,
|
|
),
|
|
}
|
|
|
|
def install_market_item_for_bot(
|
|
self,
|
|
*,
|
|
bot_id: str,
|
|
skill_id: int,
|
|
session: Session,
|
|
resolve_edge_state_context: EdgeStateContextResolver,
|
|
logger: logging.Logger,
|
|
) -> Dict[str, Any]:
|
|
item = session.get(SkillMarketItem, skill_id)
|
|
if not item:
|
|
raise HTTPException(status_code=404, detail="Skill market item not found")
|
|
|
|
zip_path = os.path.join(self._skill_market_root(), str(item.zip_filename or ""))
|
|
if not os.path.isfile(zip_path):
|
|
raise HTTPException(status_code=404, detail="Skill zip package not found")
|
|
|
|
install_row = session.exec(
|
|
select(BotSkillInstall).where(
|
|
BotSkillInstall.bot_id == bot_id,
|
|
BotSkillInstall.skill_market_item_id == skill_id,
|
|
)
|
|
).first()
|
|
|
|
try:
|
|
install_result = self._install_skill_zip_into_workspace(
|
|
bot_id=bot_id,
|
|
zip_path=zip_path,
|
|
resolve_edge_state_context=resolve_edge_state_context,
|
|
logger=logger,
|
|
)
|
|
now = datetime.utcnow()
|
|
if not install_row:
|
|
install_row = BotSkillInstall(
|
|
bot_id=bot_id,
|
|
skill_market_item_id=skill_id,
|
|
)
|
|
install_row.installed_entries_json = json.dumps(install_result["installed"], ensure_ascii=False)
|
|
install_row.source_zip_filename = str(item.zip_filename or "")
|
|
install_row.status = "INSTALLED"
|
|
install_row.last_error = None
|
|
install_row.installed_at = now
|
|
install_row.updated_at = now
|
|
session.add(install_row)
|
|
session.commit()
|
|
session.refresh(install_row)
|
|
return {
|
|
"status": "installed",
|
|
"bot_id": bot_id,
|
|
"skill_market_item_id": skill_id,
|
|
"installed": install_result["installed"],
|
|
"skills": install_result["skills"],
|
|
"market_item": self.serialize_skill_market_item(item, install_count=0, install_row=install_row),
|
|
}
|
|
except HTTPException as exc:
|
|
now = datetime.utcnow()
|
|
if not install_row:
|
|
install_row = BotSkillInstall(
|
|
bot_id=bot_id,
|
|
skill_market_item_id=skill_id,
|
|
installed_at=now,
|
|
)
|
|
install_row.source_zip_filename = str(item.zip_filename or "")
|
|
install_row.status = "FAILED"
|
|
install_row.last_error = str(exc.detail or "Install failed")
|
|
install_row.updated_at = now
|
|
session.add(install_row)
|
|
session.commit()
|
|
raise
|
|
except Exception as exc:
|
|
now = datetime.utcnow()
|
|
if not install_row:
|
|
install_row = BotSkillInstall(
|
|
bot_id=bot_id,
|
|
skill_market_item_id=skill_id,
|
|
installed_at=now,
|
|
)
|
|
install_row.source_zip_filename = str(item.zip_filename or "")
|
|
install_row.status = "FAILED"
|
|
install_row.last_error = str(exc or "Install failed")[:1000]
|
|
install_row.updated_at = now
|
|
session.add(install_row)
|
|
session.commit()
|
|
raise HTTPException(status_code=500, detail="Skill install failed unexpectedly") from exc
|
|
|
|
def install_market_item_for_bot_checked(
|
|
self,
|
|
*,
|
|
session: Session,
|
|
bot_id: str,
|
|
skill_id: int,
|
|
resolve_edge_state_context: EdgeStateContextResolver,
|
|
logger: logging.Logger,
|
|
) -> Dict[str, Any]:
|
|
self._require_bot(session=session, bot_id=bot_id)
|
|
return self.install_market_item_for_bot(
|
|
bot_id=bot_id,
|
|
skill_id=skill_id,
|
|
session=session,
|
|
resolve_edge_state_context=resolve_edge_state_context,
|
|
logger=logger,
|
|
)
|
|
|
|
async def upload_bot_skill_zip(
|
|
self,
|
|
*,
|
|
bot_id: str,
|
|
file: UploadFile,
|
|
resolve_edge_state_context: EdgeStateContextResolver,
|
|
logger: logging.Logger,
|
|
) -> Dict[str, Any]:
|
|
tmp_zip_path: Optional[str] = None
|
|
try:
|
|
with tempfile.NamedTemporaryFile(prefix=".skill_upload_", suffix=".zip", delete=False) as tmp_zip:
|
|
tmp_zip_path = tmp_zip.name
|
|
filename = str(file.filename or "").strip()
|
|
if not filename.lower().endswith(".zip"):
|
|
raise HTTPException(status_code=400, detail="Only .zip skill package is supported")
|
|
max_bytes = get_platform_settings_snapshot().upload_max_mb * 1024 * 1024
|
|
total_size = 0
|
|
while True:
|
|
chunk = await file.read(1024 * 1024)
|
|
if not chunk:
|
|
break
|
|
total_size += len(chunk)
|
|
if total_size > max_bytes:
|
|
raise HTTPException(
|
|
status_code=413,
|
|
detail=f"Zip package too large (max {max_bytes // (1024 * 1024)}MB)",
|
|
)
|
|
tmp_zip.write(chunk)
|
|
if total_size == 0:
|
|
raise HTTPException(status_code=400, detail="Zip package is empty")
|
|
finally:
|
|
await file.close()
|
|
try:
|
|
install_result = self._install_skill_zip_into_workspace(
|
|
bot_id=bot_id,
|
|
zip_path=tmp_zip_path,
|
|
resolve_edge_state_context=resolve_edge_state_context,
|
|
logger=logger,
|
|
)
|
|
finally:
|
|
if tmp_zip_path and os.path.exists(tmp_zip_path):
|
|
os.remove(tmp_zip_path)
|
|
|
|
return {
|
|
"status": "installed",
|
|
"bot_id": bot_id,
|
|
"installed": install_result["installed"],
|
|
"skills": install_result["skills"],
|
|
}
|
|
|
|
async def upload_bot_skill_zip_for_bot(
|
|
self,
|
|
*,
|
|
session: Session,
|
|
bot_id: str,
|
|
file: UploadFile,
|
|
resolve_edge_state_context: EdgeStateContextResolver,
|
|
logger: logging.Logger,
|
|
) -> Dict[str, Any]:
|
|
self._require_bot(session=session, bot_id=bot_id)
|
|
return await self.upload_bot_skill_zip(
|
|
bot_id=bot_id,
|
|
file=file,
|
|
resolve_edge_state_context=resolve_edge_state_context,
|
|
logger=logger,
|
|
)
|
|
|
|
def delete_workspace_skill(
|
|
self,
|
|
*,
|
|
bot_id: str,
|
|
skill_name: str,
|
|
resolve_edge_state_context: EdgeStateContextResolver,
|
|
) -> Dict[str, Any]:
|
|
if resolve_edge_state_context(bot_id) is not None:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Edge bot skill delete is disabled here. Use edge workspace file management.",
|
|
)
|
|
name = str(skill_name or "").strip()
|
|
if not self._is_valid_top_level_skill_name(name):
|
|
raise HTTPException(status_code=400, detail="Invalid skill name")
|
|
root = self._skills_root(bot_id)
|
|
target = os.path.abspath(os.path.join(root, name))
|
|
if os.path.commonpath([os.path.abspath(root), target]) != os.path.abspath(root):
|
|
raise HTTPException(status_code=400, detail="Invalid skill path")
|
|
if not os.path.exists(target):
|
|
raise HTTPException(status_code=404, detail="Skill not found in workspace")
|
|
if os.path.isdir(target):
|
|
shutil.rmtree(target, ignore_errors=False)
|
|
else:
|
|
os.remove(target)
|
|
return {"status": "deleted", "bot_id": bot_id, "skill": name}
|
|
|
|
def delete_workspace_skill_for_bot(
|
|
self,
|
|
*,
|
|
session: Session,
|
|
bot_id: str,
|
|
skill_name: str,
|
|
resolve_edge_state_context: EdgeStateContextResolver,
|
|
) -> Dict[str, Any]:
|
|
self._require_bot(session=session, bot_id=bot_id)
|
|
return self.delete_workspace_skill(
|
|
bot_id=bot_id,
|
|
skill_name=skill_name,
|
|
resolve_edge_state_context=resolve_edge_state_context,
|
|
)
|