File size: 3,398 Bytes
e2b893f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
"""Durable storage for bots, backed by a private Hugging Face dataset.

Space disks are ephemeral: a rebuild, a sleep/wake cycle or a maintenance restart wipes
everything under /home/user. Without this module the host would silently lose every bot it
was given. On boot we pull the dataset into DATA_DIR; on every deploy/start/stop we push the
affected bot back up.

The dataset is PRIVATE — it holds Nerimity bot tokens in bots/<id>/secret.json.
"""
from __future__ import annotations

import os
import shutil
import threading
import traceback
from pathlib import Path

from huggingface_hub import HfApi, snapshot_download

DATA_DIR = Path(os.environ.get("BB_DATA_DIR", "/home/user/data"))
BOTS_DIR = DATA_DIR / "bots"
DATASET_ID = os.environ.get("BB_DATASET", "joddabod/bbuilder-store")
HF_TOKEN = os.environ.get("HF_TOKEN")

# Only these ever leave the container. Notably excludes node_modules.
SYNC_FILES = ("bot.js", "bb-runtime.js", "package.json", "project.bbproj", "meta.json", "secret.json")

_push_lock = threading.Lock()


def available() -> bool:
    return bool(HF_TOKEN)


def _api() -> HfApi:
    return HfApi(token=HF_TOKEN)


def pull_all() -> str:
    """Restore every bot from the dataset into DATA_DIR. Returns a human-readable summary."""
    if not available():
        return "no HF_TOKEN — running without durable storage (bots will not survive restarts)"
    try:
        snap = snapshot_download(
            repo_id=DATASET_ID,
            repo_type="dataset",
            token=HF_TOKEN,
            allow_patterns=["bots/**"],
        )
    except Exception as exc:  # noqa: BLE001
        return f"restore skipped: {exc}"

    src = Path(snap) / "bots"
    if not src.exists():
        return "store is empty (no bots yet)"

    BOTS_DIR.mkdir(parents=True, exist_ok=True)
    restored = 0
    for bot_dir in src.iterdir():
        if not bot_dir.is_dir():
            continue
        dest = BOTS_DIR / bot_dir.name
        dest.mkdir(parents=True, exist_ok=True)
        for name in SYNC_FILES:
            f = bot_dir / name
            if f.exists():
                shutil.copy2(f, dest / name)
        if (dest / "secret.json").exists():
            (dest / "secret.json").chmod(0o600)
        restored += 1
    return f"restored {restored} bot(s) from {DATASET_ID}"


def push_bot(bot_id: str) -> None:
    """Mirror one bot's directory up to the dataset. Best-effort, never fatal."""
    if not available():
        return
    src = BOTS_DIR / bot_id
    if not src.is_dir():
        return
    with _push_lock:
        try:
            _api().upload_folder(
                repo_id=DATASET_ID,
                repo_type="dataset",
                folder_path=str(src),
                path_in_repo=f"bots/{bot_id}",
                allow_patterns=list(SYNC_FILES),
                commit_message=f"sync {bot_id}",
            )
        except Exception:  # noqa: BLE001
            traceback.print_exc()


def push_bot_async(bot_id: str) -> None:
    threading.Thread(target=push_bot, args=(bot_id,), daemon=True).start()


def delete_bot(bot_id: str) -> None:
    if not available():
        return
    try:
        _api().delete_folder(
            path_in_repo=f"bots/{bot_id}",
            repo_id=DATASET_ID,
            repo_type="dataset",
            commit_message=f"delete {bot_id}",
        )
    except Exception:  # noqa: BLE001
        pass