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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ TASK_WORKER_ENABLED=true
# Disabled in the full sample. Enable only after adding a real token.
TELEGRAM_ENABLED=false
# TELEGRAM_BOT_TOKEN=replace-me
# Optional shared gate for new private chats. Existing private chats with
# persisted Blacki history are grandfathered; groups are always rejected.
# New users authenticate with: /start <access-code>
# TELEGRAM_ACCESS_CODE=replace-with-a-dedicated-high-entropy-code
# Telegram turns automatically show one live-updating tool status message.

# Optional private text-to-speech delivery for Telegram replies. The Kokoro
Expand Down
7 changes: 6 additions & 1 deletion docs/base-infra/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,15 @@ its presence changes model routing.
| --- | --- | --- |
| `TELEGRAM_ENABLED` | `false` | Start Telegram long polling |
| `TELEGRAM_BOT_TOKEN` | unset | Token from BotFather |
| `TELEGRAM_ACCESS_CODE` | unset | Shared code required for new private Telegram chats |
| `KOKORO_TTS_BASE_URL` | unset | Register private Kokoro speech delivery for Telegram |
| `KOKORO_TTS_VOICE` | `af_heart` | Kokoro voice ID used for generated MP3 audio |

The token is required and format-validated when Telegram is enabled.
The token is required and format-validated when Telegram is enabled. When
`TELEGRAM_ACCESS_CODE` is set, new users enter it with `/start <access-code>`;
historical private chats with existing Blacki sessions remain authorized, while
groups and topics are rejected. Rotating the code requires code-authorized
users to authenticate again without deleting their stored Blacki data.

`KOKORO_TTS_BASE_URL` is an optional HTTP or HTTPS base URL without a path to
`/v1/audio/speech`; Blacki appends that fixed endpoint. The URL must be
Expand Down
11 changes: 11 additions & 0 deletions docs/telegram-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,22 @@ In `.env`:
```dotenv
TELEGRAM_ENABLED=true
TELEGRAM_BOT_TOKEN=replace-me
# Optional: restrict new private chats with one shared access code.
# TELEGRAM_ACCESS_CODE=replace-with-a-dedicated-high-entropy-code
```

Replace `replace-me` with the token from BotFather. Blacki validates that the
token is present and follows Telegram's `number:string` format at startup.

When `TELEGRAM_ACCESS_CODE` is set, new users must send `/start <access-code>`
in a private chat before Blacki processes their messages. The bot consumes this
command locally and attempts to delete it, but Telegram may retain it in
server-side history or backups, so use a dedicated code rather than a password
you use elsewhere. Historical private chats with persisted Blacki sessions are
grandfathered; group chats and forum topics are rejected. Changing the access
code requires passphrase-authorized users to authenticate again but does not
delete any chat history, preferences, reminders, files, or health data.

At least one model provider must also be configured. See
[Configuration](base-infra/environment-variables.md).

Expand Down
4 changes: 4 additions & 0 deletions src/blacki/adk_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,10 @@ async def create_next_session(
state=state,
)

async def has_existing_session(self, *, locator: SessionLocator) -> bool:
"""Return whether a locator has a persisted session without creating one."""
return await self._get_latest_session(locator=locator) is not None

async def run_user_turn(
self,
*,
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.telegram.access import TelegramAccessStorage
from blacki.user_files.storage import SqliteUserFileStorage
from blacki.utils.preferences import SqlitePreferencesStorage
from blacki.workouts.storage import SqliteWorkoutStorage
Expand Down Expand Up @@ -146,6 +147,9 @@ class AppContainer:
_user_file_storage: SqliteUserFileStorage | None = field(
default=None, init=False, repr=False
)
_telegram_access_storage: TelegramAccessStorage | None = field(
default=None, init=False, repr=False
)

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

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

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

Expand All @@ -211,6 +219,7 @@ async def initialize_all_storages(self) -> None:
await self.declarative_db_storage.initialize()
await self.google_health_storage.initialize()
await self.user_file_storage.initialize()
await self.telegram_access_storage.initialize()

@property
def lock(self) -> asyncio.Lock:
Expand Down Expand Up @@ -283,3 +292,12 @@ def user_file_storage(self) -> SqliteUserFileStorage:

self._user_file_storage = SqliteUserFileStorage(self.conn, self._lock)
return self._user_file_storage

@property
def telegram_access_storage(self) -> TelegramAccessStorage:
"""Get or create local Telegram access and identity storage."""
if self._telegram_access_storage is None:
from blacki.telegram.access import TelegramAccessStorage

self._telegram_access_storage = TelegramAccessStorage(self.conn, self._lock)
return self._telegram_access_storage
59 changes: 58 additions & 1 deletion src/blacki/dashboard/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
)

_SESSION_VERSION_RE = re.compile(r"^(?P<prefix>.+)-v(?P<version>[0-9]+)$")
_TELEGRAM_DIRECT_USER_ID_RE = re.compile(r"^telegram-chat-(?P<user_id>[0-9]+)$")
_ABSOLUTE_PATH_RE = re.compile(
r"(?<![A-Za-z0-9_])/(?:Users|home|var|tmp|private|app|workspace|opt|srv|etc)/[^\s\"']+"
)
Expand Down Expand Up @@ -438,6 +439,36 @@ def _load_snapshot_sync(path: Path, app_name: str) -> _Snapshot:
)


def _load_telegram_identities_sync(path: Path) -> dict[int, tuple[str, str | None]]:
"""Read dashboard-only Telegram labels without modifying the tools database."""
if not path.is_file():
return {}
connection: sqlite3.Connection | None = None
try:
uri = f"file:{quote(path.resolve().as_posix(), safe='/')}?mode=ro"
connection = sqlite3.connect(uri, uri=True, timeout=1.0, isolation_level=None)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA query_only = ON")
rows = connection.execute(
"""
SELECT telegram_user_id, display_name, username
FROM telegram_identities
"""
).fetchall()
return {
int(row["telegram_user_id"]): (str(row["display_name"]), row["username"])
for row in rows
if isinstance(row["telegram_user_id"], int)
and isinstance(row["display_name"], str)
and (row["username"] is None or isinstance(row["username"], str))
}
except (OSError, sqlite3.Error, ValueError):
return {}
finally:
if connection is not None:
connection.close()


def _iter_raw_jsonl_lines(handle: Any) -> Iterator[tuple[bytes, bool]]:
"""Yield bounded lines and flag oversized lines without allocating them."""
buffer = bytearray()
Expand Down Expand Up @@ -1166,10 +1197,12 @@ def __init__(
session_db_path: Path,
log_dir: Path,
app_name: str = "blacki",
identity_db_path: Path | None = None,
) -> None:
self.session_db_path = Path(session_db_path)
self.log_dir = Path(log_dir)
self.app_name = str(app_name)
self.identity_db_path = Path(identity_db_path) if identity_db_path else None

async def _snapshot(self) -> _Snapshot:
return await asyncio.to_thread(
Expand Down Expand Up @@ -1321,6 +1354,13 @@ async def get_overview(self, window: str) -> JsonObject:

async def list_users(self, search: str, limit: int, offset: int) -> JsonObject:
snapshot = await self._snapshot()
identities = (
await asyncio.to_thread(
_load_telegram_identities_sync, self.identity_db_path
)
if self.identity_db_path is not None
else {}
)
query = bounded_search(search).lower()
page_limit = clamp_limit(limit)
page_offset = clamp_offset(offset)
Expand All @@ -1330,7 +1370,22 @@ async def list_users(self, search: str, limit: int, offset: int) -> JsonObject:
)
items: list[JsonObject] = []
for user_id in user_ids:
if query and query not in user_id.lower():
identity_match = _TELEGRAM_DIRECT_USER_ID_RE.match(user_id)
identity = (
identities.get(int(identity_match.group("user_id")))
if identity_match is not None
else None
)
display_name = identity[0] if identity else None
username = identity[1] if identity else None
searchable_identity = " ".join(
item for item in (display_name, username) if item
).lower()
if (
query
and query not in user_id.lower()
and query not in searchable_identity
):
continue
sessions = [
session for session in snapshot.sessions if session.user_id == user_id
Expand All @@ -1349,6 +1404,8 @@ async def list_users(self, search: str, limit: int, offset: int) -> JsonObject:
items.append(
{
"user_id": user_id,
"display_name": display_name,
"username": username,
"session_count": len(sessions),
"reset_count": max(0, max(versions, default=1) - 1),
"retained_history": len(sessions) > 1,
Expand Down
10 changes: 10 additions & 0 deletions src/blacki/dashboard/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,15 @@ def _session_db_path(env: ServerEnv) -> Path:
return Path(env.agent_dir) / ".adk" / "sessions.db"


def _tools_db_path(env: ServerEnv) -> Path:
"""Return the local SQLite database containing dashboard identity labels."""
return (
Path(env.sqlite_path)
if env.sqlite_path
else Path(env.agent_dir) / ".adk" / "tools.db"
)


def create_dashboard_router(
env: ServerEnv,
store: DashboardStoreProtocol | None = None,
Expand All @@ -235,6 +244,7 @@ def create_dashboard_router(
_session_db_path(env),
get_log_dir(),
"blacki",
_tools_db_path(env),
)
except Exception:
logger.exception("Dashboard store initialization failed")
Expand Down
7 changes: 6 additions & 1 deletion src/blacki/dashboard/static/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -294,11 +294,16 @@
target.replaceChildren();
state.users.forEach((item) => {
const userId = String(first(item, ["user_id", "userId", "chat_id", "chatId", "id"], "—"));
const displayName = first(item, ["display_name", "displayName"]);
const username = first(item, ["username"]);
const button = el("button", "btn btn-ghost h-auto min-h-0 w-full justify-between gap-3 rounded-xl px-3 py-3 text-left normal-case hover:bg-base-200");
button.type = "button";
button.setAttribute("aria-label", `Inspect user ${userId}`);
const copy = el("span", "min-w-0");
copy.append(el("span", "block break-all font-mono text-xs font-semibold", userId));
copy.append(el("span", "block break-all font-mono text-xs font-semibold", displayName || userId));
if (displayName) {
copy.append(el("span", "mt-1 block break-all font-mono text-xs text-base-content/55", username ? `${userId} · @${username}` : userId));
}
const lastSeen = first(item, ["last_seen", "lastSeen", "updated_at", "updatedAt", "latest_update_at"]);
copy.append(el("span", "mt-1 block text-xs text-base-content/55", lastSeen === undefined ? "Stored user ID" : `Seen ${formatDate(lastSeen)}`));
const count = first(item, ["sessions", "session_count", "message_count", "messages"]);
Expand Down
1 change: 1 addition & 0 deletions src/blacki/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ async def _start_telegram_bot() -> None:
{
"TELEGRAM_ENABLED": env.telegram_enabled,
"TELEGRAM_BOT_TOKEN": env.telegram_bot_token,
"TELEGRAM_ACCESS_CODE": env.telegram_access_code,
}
)
telegram_app = create_app(
Expand Down
11 changes: 11 additions & 0 deletions src/blacki/telegram/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ class TelegramConfig(BaseModel):
description="Telegram bot token obtained from @BotFather",
)

telegram_access_code: str | None = Field(
default=None,
alias="TELEGRAM_ACCESS_CODE",
description="Shared access code required for new private Telegram users",
)

model_config = ConfigDict(
populate_by_name=True,
extra="ignore",
Expand All @@ -41,3 +47,8 @@ def is_configured(self) -> bool:
True if enabled and has a bot token, False otherwise.
"""
return self.telegram_enabled and self.telegram_bot_token is not None

@property
def access_control_enabled(self) -> bool:
"""Return whether new-user access control is configured."""
return bool(self.telegram_access_code)
Loading
Loading