Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -227,3 +227,21 @@ ZEPTO_MCP_ENABLED=false
# Security boundary: Blacki never copies model, repository, search, Telegram, or
# application credentials into a general-purpose sandbox. SANDBOX_API_KEY is used
# only by the host-side OpenSandbox client and is not added to the sandbox process.

# ---------------------------------------------------------------------------
# Durable Telegram Attachments in Cloudflare R2 (Optional)
# ---------------------------------------------------------------------------
# Persist supported Telegram attachments indefinitely, catalog them per
# sender, and allow the Telegram root agent to restore them into a fresh
# sandbox. Use a private bucket and a bucket-scoped Object Read & Write token.
# R2_FILES_ENABLED=false
# R2_ENDPOINT_URL=https://ACCOUNT_ID.r2.cloudflarestorage.com
# R2_BUCKET_NAME=blacki-user-files
# R2_ACCESS_KEY_ID=
# R2_SECRET_ACCESS_KEY=
# R2_OWNER_HMAC_SECRET=
# R2_FILE_KEY_PREFIX=blacki/user-files
# Optional: bound retention instead of keeping files until the bucket is
# deleted. When set, configure a matching R2 lifecycle rule for prefix
# blacki/user-files/ so expired catalog rows and R2 objects stay in sync.
# R2_FILE_RETENTION_DAYS=90
12 changes: 12 additions & 0 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,12 @@ jobs:
BROWSER_USE_API_KEY: ${{ secrets.BROWSER_USE_API_KEY }}
SANDBOX_ENABLED: ${{ secrets.SANDBOX_ENABLED }}
SANDBOX_DOMAIN: ${{ secrets.SANDBOX_DOMAIN }}
R2_FILES_ENABLED: ${{ secrets.R2_FILES_ENABLED }}
R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }}
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_OWNER_HMAC_SECRET: ${{ secrets.R2_OWNER_HMAC_SECRET }}
GH_TOKEN: ${{ secrets.GH_TOKEN }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
GOOGLE_HEALTH_CLIENT_ID: ${{ secrets.GOOGLE_HEALTH_CLIENT_ID }}
Expand Down Expand Up @@ -191,6 +197,12 @@ jobs:
BROWSER_USE_API_KEY \
SANDBOX_ENABLED \
SANDBOX_DOMAIN \
R2_FILES_ENABLED \
R2_ENDPOINT_URL \
R2_BUCKET_NAME \
R2_ACCESS_KEY_ID \
R2_SECRET_ACCESS_KEY \
R2_OWNER_HMAC_SECRET \
GOOGLE_API_KEY \
GOOGLE_HEALTH_CLIENT_ID \
GOOGLE_HEALTH_CLIENT_SECRET \
Expand Down
27 changes: 27 additions & 0 deletions docs/base-infra/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,33 @@ and model usage.
Running a local OpenSandbox server adds Docker and resource requirements beyond
the Blacki golden path.

## Cloudflare R2 user files

| Variable | Default | Purpose |
| --- | --- | --- |
| `R2_FILES_ENABLED` | `false` | Persist supported Telegram attachments |
| `R2_ENDPOINT_URL` | unset | Account or jurisdiction-specific S3 endpoint |
| `R2_BUCKET_NAME` | unset | Private attachment bucket |
| `R2_ACCESS_KEY_ID` | unset | Bucket-scoped S3 access key |
| `R2_SECRET_ACCESS_KEY` | unset | Bucket-scoped S3 secret |
| `R2_OWNER_HMAC_SECRET` | unset | Secret used to hide Telegram IDs in object keys |
| `R2_FILE_KEY_PREFIX` | `blacki/user-files` | Private object-key prefix |
| `R2_FILE_RETENTION_DAYS` | unset (infinite) | Application availability window |

Create a private R2 bucket and grant Blacki only Object Read & Write
permission for that bucket. Files are retained until the bucket is deleted
unless `R2_FILE_RETENTION_DAYS` is set. If you do set it, also add a matching
R2 lifecycle rule that deletes `blacki/user-files/` objects after the same
number of days — the application only removes its own SQLite catalog rows
once they expire; it never issues a delete against R2 for passive expiry
(explicit user deletion still removes both). Files are catalogued in the
persistent SQLite volume; include that database in backups. R2 credentials
remain in the Blacki host and are never copied into a sandbox.

If R2 is unavailable, Telegram processing can continue with an explicit
temporary-storage warning. If the sandbox is unavailable, a successfully
stored object remains available for a later restore.

## Zepto MCP

| Variable | Default | Purpose |
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ dependencies = [
"opensandbox>=0.1.7",
"mem0ai>=0.1.0,<1.0.0",
"opensandbox-code-interpreter>=0.1.2",
"boto3>=1.40.0,<2.0.0",
]

[project.scripts]
Expand Down Expand Up @@ -111,7 +112,7 @@ strict_equality = true
show_error_codes = true

[[tool.mypy.overrides]]
module = ["apscheduler.*", "opensandbox.*", "mem0", "mem0.*"]
module = ["apscheduler.*", "opensandbox.*", "mem0", "mem0.*", "boto3.*", "botocore.*"]
ignore_missing_imports = true

[tool.coverage.run]
Expand Down
2 changes: 2 additions & 0 deletions src/blacki/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,13 +274,15 @@ def create_app(agent: LlmAgent | None = None) -> App:
DeclarativeDbPlugin,
StoredPreferencesPlugin,
)
from blacki.user_files import UserFilesPromptPlugin, user_files_enabled

plugins: list[BasePlugin] = [
TelegramModelOverridePlugin(name="telegram_model_override"),
GlobalInstructionPlugin(return_global_instruction),
DomainPolicyPlugin(name="domain_policy"),
DeclarativeDbPlugin(name="declarative_db"),
StoredPreferencesPlugin(name="stored_preferences"),
*([UserFilesPromptPlugin(name="user_files")] if user_files_enabled() else []),
ResponsePolicyPlugin(name="response_policy"),
]
if not private_tool_privacy_enabled():
Expand Down
18 changes: 18 additions & 0 deletions src/blacki/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from blacki.declarative_db.storage import SqliteDeclarativeDbStorage
from blacki.health.storage import SqliteGoogleHealthStorage
from blacki.reminders.storage import SqliteReminderStorage
from blacki.user_files.storage import SqliteUserFileStorage
from blacki.utils.preferences import SqlitePreferencesStorage
from blacki.workouts.storage import SqliteWorkoutStorage

Expand Down Expand Up @@ -142,6 +143,9 @@ class AppContainer:
_google_health_storage: SqliteGoogleHealthStorage | None = field(
default=None, init=False, repr=False
)
_user_file_storage: SqliteUserFileStorage | None = field(
default=None, init=False, repr=False
)

@classmethod
async def create(cls, sqlite_path: str | Path) -> Self:
Expand Down Expand Up @@ -190,6 +194,10 @@ async def _close_storages(self) -> None:
await self._google_health_storage.close()
self._google_health_storage = None

if self._user_file_storage is not None:
await self._user_file_storage.close()
self._user_file_storage = None

async def initialize_all_storages(self) -> None:
"""Initialize all storage instances.

Expand All @@ -202,6 +210,7 @@ async def initialize_all_storages(self) -> None:
await self.preferences_storage.initialize()
await self.declarative_db_storage.initialize()
await self.google_health_storage.initialize()
await self.user_file_storage.initialize()

@property
def lock(self) -> asyncio.Lock:
Expand Down Expand Up @@ -265,3 +274,12 @@ def google_health_storage(self) -> SqliteGoogleHealthStorage:
self.conn, self._lock
)
return self._google_health_storage

@property
def user_file_storage(self) -> SqliteUserFileStorage:
"""Get or create the durable user-file catalog."""
if self._user_file_storage is None:
from blacki.user_files.storage import SqliteUserFileStorage

self._user_file_storage = SqliteUserFileStorage(self.conn, self._lock)
return self._user_file_storage
22 changes: 20 additions & 2 deletions src/blacki/privacy.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,15 @@

_ENABLED_VALUES = frozenset({"1", "true", "yes"})
_ZEPTO_TOOL_PREFIX = "zepto_"
_PRIVATE_TOOL_NAMES = frozenset({"get_health_summary", "send_text_to_speech"})
_PRIVATE_TOOL_NAMES = frozenset(
{
"get_health_summary",
"send_text_to_speech",
"list_user_files",
"restore_user_file",
"delete_user_file",
}
)


def zepto_mcp_enabled() -> bool:
Expand All @@ -25,6 +33,11 @@ def kokoro_tts_enabled() -> bool:
return bool(os.getenv("KOKORO_TTS_BASE_URL", "").strip())


def r2_files_enabled() -> bool:
"""Return whether private durable-file tools are configured."""
return os.getenv("R2_FILES_ENABLED", "false").strip().lower() in _ENABLED_VALUES


def google_health_enabled() -> bool:
"""Return whether a complete Google Health connector is configured."""
from .health.config import google_health_configured_from_environment
Expand All @@ -34,7 +47,12 @@ def google_health_enabled() -> bool:

def private_tool_privacy_enabled() -> bool:
"""Return whether any configured tool needs content-level redaction."""
return zepto_mcp_enabled() or kokoro_tts_enabled() or google_health_enabled()
return (
zepto_mcp_enabled()
or kokoro_tts_enabled()
or google_health_enabled()
or r2_files_enabled()
)


def configure_zepto_privacy() -> bool:
Expand Down
18 changes: 18 additions & 0 deletions src/blacki/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ class ToolConfig:
zepto_mcp_enabled: bool = False
zepto_mcp_config_dir: Path = Path("data/credentials/zepto-mcp-remote")
zepto_mcp_allowed_chat_ids: frozenset[str] = frozenset()
r2_files_enabled: bool = False


def build_tools(
Expand Down Expand Up @@ -104,6 +105,9 @@ def build_tools(
if include_user_scoped_tools and config.google_health_enabled:
tools.extend(_build_health_tools())

if include_user_scoped_tools and config.r2_files_enabled:
tools.extend(_build_user_file_tools())

tools.extend(_build_memory_tools())

return tools
Expand Down Expand Up @@ -321,6 +325,18 @@ def _build_tts_tools(*, base_url: str, voice: str) -> list[Any]:
return []


def _build_user_file_tools() -> list[Any]:
"""Build private Telegram sender-scoped durable file tools."""
try:
from blacki.user_files import create_user_file_tools

logger.info("Durable R2 file tools enabled for the Telegram root agent")
return create_user_file_tools()
except (ImportError, ValueError) as exc:
logger.warning("Durable R2 file tools disabled: %s", exc)
return []


def _build_health_tools() -> list[Any]:
"""Build the private, read-only Google Health tool."""
try:
Expand Down Expand Up @@ -404,6 +420,8 @@ def build_tool_config_from_env() -> ToolConfig:
).strip()
),
zepto_mcp_allowed_chat_ids=allowed_zepto_chat_ids,
r2_files_enabled=os.getenv("R2_FILES_ENABLED", "false").strip().lower()
in ("true", "1", "yes"),
)


Expand Down
3 changes: 3 additions & 0 deletions src/blacki/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,9 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
await _stop_telegram_bot()

if _container is not None:
from .user_files import reset_user_file_service

reset_user_file_service()
await close_container()
_container = None

Expand Down
Loading
Loading