feat(telegram): port wave-1 fixes + @api set_secret write-door (DPLAN-0220)

Surfaced by a full completeness audit of the telegram skill against
TELEGRAM_PORT_MAP.md (366 tags, ~83% ported, 452/452 tests green).

@api — in-process set_secret(provider, slug, value, *, as_json) writer
mirroring get_secret (0o600 files / 0o700 dirs, no stdout echo). The store
was read-only; this is the GAP1 enabler the telegram mother-bot needs to
persist a created bot's config. 515 @api tests, seedgo 100%.

@skills telegram wave-1 (fix-forward, no deletions):
- GAP2: bot_factory + telegram-bot@.service launched a non-existent
  ~/.venv/bin/python3; now sys.executable -m ...base_bot (+ lib/__init__.py
  and lib/telegram/__init__.py for package resolution).
- Reboot survival: enable_service now installs the unit to
  ~/.config/systemd/user/ + daemon-reload (was never installed).
- GAP9: gitignore lib/telegram/.local/ so runtime state stops leaking to git.
- prax-monitor: log_streamer now resolves repo-root system_logs (honoring
  AIPASS_TEST_LOG_DIR) instead of a hardcoded ~/system_logs.

Verified: telegram 452/452 green (twice), base_bot imports via -m.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QEQZXCtgnF3NQtcttTErpq
This commit is contained in:
AIOSAI
2026-06-24 16:39:08 -07:00
co-authored by Claude Opus 4.8
parent 882da7cdfc
commit 0096aef1d2
11 changed files with 304 additions and 17 deletions
+8 -7
View File
@@ -5,8 +5,8 @@
> Centralized external API gateway — authenticated service clients for all external APIs
**Module:** `aipass.api` | **Role:** `api_gateway`
**Seedgo:** 100% (37/37 at 100%) | **Tests:** 504 pass | **Functions:** 82 public (82 tested)
**Last Updated:** 2026-06-15
**Seedgo:** 100% (38/38 at 100%) | **Tests:** 515 pass | **Functions:** 84 public (84 tested)
**Last Updated:** 2026-06-24
---
@@ -68,7 +68,7 @@ api/
│ │ └── usage/aggregation.py, cleanup.py, tracking.py
│ └── integrations/ # Private driver space (gitignored)
│ └── {project}/driver.py
└── tests/ # 504 tests across 28 files
└── tests/ # 515 tests across 28 files
```
Three-tier: entry point routes to modules (orchestration), modules delegate to handlers (business logic). Modules auto-discovered from `apps/modules/*.py` via `handle_command()`.
@@ -88,11 +88,12 @@ service = get_drive_service(thread_safe=True) # For concurrent workers
from aipass.api.apps.modules.google_client import get_google_service
service = get_google_service("calendar", "v3")
from aipass.api.apps.modules.secrets import get_secret, list_secrets
from aipass.api.apps.modules.secrets import get_secret, set_secret, list_secrets
token = get_secret("telegram", "bot") # Returns bot_token string
config = get_secret("telegram", "bot", as_json=True) # Returns full dict
slugs = list_secrets("telegram") # Returns ["bot", "webhook", ...]
# CLI never prints raw values — use the Python API above for programmatic access
set_secret("telegram", "newbot", cfg, as_json=True) # Writes ~/.secrets/aipass/telegram/newbot.json
slugs = list_secrets("telegram") # Returns ["bot", "newbot", ...]
# Values never reach stdout — use the Python API above for programmatic access
```
---
@@ -128,6 +129,6 @@ Private drivers in `apps/integrations/{project}/driver.py` (gitignored) register
---
*Last Updated: 2026-05-16*
*Last Updated: 2026-06-24*
[← Back to AIPass](../../../README.md)
+51 -2
View File
@@ -9,17 +9,19 @@
"""
Secrets Store Handler
Reads structured secrets from ~/.secrets/aipass/<provider>/<slug>.
Reads and writes structured secrets in ~/.secrets/aipass/<provider>/<slug>.
Supports JSON config files and raw secret files.
Functions:
get_secret() - Read a secret by provider/slug
set_secret() - Write a secret to the provider store
list_secrets() - List available slugs for a provider
"""
import json
import os
from pathlib import Path
from typing import Any, List, Optional
from typing import Any, List, Optional, Union
from aipass.prax import logger
from aipass.api.apps.handlers.json import json_handler
@@ -115,6 +117,53 @@ def list_secrets(provider: str) -> List[str]:
return sorted(slugs)
# ==============================================
# SECRET WRITING
# ==============================================
def set_secret(provider: str, slug: str, value: Union[str, dict], *, as_json: bool = False) -> Path:
"""
Write a secret to the provider store.
Destination: ~/.secrets/aipass/<provider>/<slug>.json
Args:
provider: Provider directory name (e.g., 'telegram')
slug: Secret identifier (without .json extension)
value: Secret value — string or dict
as_json: If True, json.dump the value; else write as plain string
Returns:
Path to the written file
Raises:
OSError: If directory creation or file write fails
"""
provider_dir = SECRETS_BASE / provider
provider_dir.mkdir(parents=True, exist_ok=True)
os.chmod(provider_dir, 0o700)
target = provider_dir / f"{slug}.json"
if as_json:
content = json.dumps(value, indent=2).encode("utf-8")
else:
content = json.dumps(str(value)).encode("utf-8")
fd = os.open(str(target), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
try:
os.write(fd, content)
finally:
os.close(fd)
json_handler.log_operation(
"secret_written",
{"provider": provider, "slug": slug, "format": "json" if as_json else "raw"},
)
return target
# ==============================================
# PRIVATE HELPERS
# ==============================================
+29 -2
View File
@@ -9,17 +9,19 @@
"""
Secrets Module
Cross-branch in-process API for reading secrets from the provider store.
Cross-branch in-process API for the secrets provider store.
Consumers import directly instead of shelling out to the CLI.
Functions:
get_secret() - Read a secret by provider/slug
set_secret() - Write a secret to the provider store
list_secrets() - List available slugs for a provider
handle_command() - Route CLI commands (seedgo module discovery)
"""
import sys
from typing import Any, List, Optional
from pathlib import Path
from typing import Any, List, Optional, Union
from aipass.prax import logger # noqa: F401 — seedgo imports standard
from aipass.cli.apps.modules import console, header
@@ -42,6 +44,7 @@ def print_introspection():
console.print("[cyan]Available Workflows:[/cyan]")
console.print(" • get_secret() - Read secret by provider/slug")
console.print(" • set_secret() - Write secret to provider store")
console.print(" • list_secrets() - List slugs for a provider")
console.print()
@@ -96,6 +99,30 @@ def get_secret(provider: str, slug: str, as_json: bool = False) -> Optional[Any]
return result
def set_secret(provider: str, slug: str, value: Union[str, dict], *, as_json: bool = False) -> Path:
"""
Write a secret to the provider store.
This is the sanctioned cross-branch write path. Consumers call this
instead of shelling out to the CLI.
Args:
provider: Provider directory name (e.g., 'telegram')
slug: Secret identifier (without .json extension)
value: Secret value — string or dict
as_json: If True, json.dump the value; else write as plain string
Returns:
Path to the written file
Raises:
OSError: If directory creation or file write fails
"""
result = _handler.set_secret(provider, slug, value, as_json=as_json)
json_handler.log_operation("secrets_set", {"provider": provider, "slug": slug, "wrote": str(result)})
return result
def list_secrets(provider: str) -> List[str]:
"""
List available secret slugs for a provider.
+140
View File
@@ -21,8 +21,19 @@ Tests — handlers/auth/secrets.py (get_secret, list_secrets):
- list_secrets: non-existent provider returns empty list
- list_secrets: skips dotfiles, __pycache__, directories
Tests — handlers/auth/secrets.py (set_secret):
- set_secret: writes string value to provider/slug.json
- set_secret: as_json writes JSON-serialized dict
- set_secret: creates provider directory if missing
- set_secret: file has 0o600 permissions (POSIX)
- set_secret: provider dir has 0o700 permissions (POSIX)
- set_secret: overwrites existing secret
- set_secret: round-trip with get_secret returns same value
- set_secret: round-trip with get_secret as_json returns same dict
Tests — modules/secrets.py (in-process door):
- get_secret wraps handler and logs operation
- set_secret wraps handler and logs operation
- list_secrets wraps handler
Tests — api_key.py (get_secret_cmd — hardened, no raw values to stdout):
@@ -52,6 +63,7 @@ from aipass.api.apps.modules.api_key import handle_command as _hc # noqa: F401
from aipass.api.apps.modules.secrets import handle_command as _hc2 # noqa: F401 — seedgo test_coverage detection
from aipass.api.apps.handlers.auth.secrets import (
get_secret,
set_secret,
list_secrets,
)
from aipass.api.apps.modules.api_key import get_secret_cmd
@@ -491,3 +503,131 @@ class TestGetSecretCmd:
get_secret_cmd(["empty_provider", "--list"])
mock_secrets.list_secrets.assert_called_once_with("empty_provider")
# =============================================
# set_secret (handler)
# =============================================
class TestSetSecret:
"""Verifies secret writing under various conditions."""
def test_writes_string_value(self, tmp_path: Path) -> None:
"""Writes a plain string value to provider/slug.json."""
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER):
result = set_secret("telegram", "bot", "my-token-value")
assert result == tmp_path / "telegram" / "bot.json"
assert json.loads(result.read_text(encoding="utf-8")) == "my-token-value"
def test_as_json_writes_dict(self, tmp_path: Path) -> None:
"""as_json=True writes JSON-serialized dict."""
data = {"bot_token": "abc123", "webhook_url": "https://example.com"}
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER):
result = set_secret("telegram", "bot", data, as_json=True)
written = json.loads(result.read_text(encoding="utf-8"))
assert written == data
def test_creates_provider_directory(self, tmp_path: Path) -> None:
"""Creates provider directory if it doesn't exist."""
assert not (tmp_path / "newprovider").exists()
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER):
set_secret("newprovider", "cred", "value")
assert (tmp_path / "newprovider").is_dir()
@pytest.mark.skipif(sys.platform == "win32", reason="File permission checks are POSIX-only")
def test_file_has_0600_permissions(self, tmp_path: Path) -> None:
"""Written file has 0o600 permissions."""
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER):
result = set_secret("telegram", "bot", "token")
file_mode = stat.S_IMODE(os.stat(result).st_mode)
assert file_mode == 0o600
@pytest.mark.skipif(sys.platform == "win32", reason="File permission checks are POSIX-only")
def test_provider_dir_has_0700_permissions(self, tmp_path: Path) -> None:
"""Provider directory has 0o700 permissions."""
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER):
set_secret("telegram", "bot", "token")
dir_mode = stat.S_IMODE(os.stat(tmp_path / "telegram").st_mode)
assert dir_mode == 0o700
def test_overwrites_existing_secret(self, tmp_path: Path) -> None:
"""Overwrites an existing secret file."""
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER):
set_secret("telegram", "bot", "old-value")
set_secret("telegram", "bot", "new-value")
content = json.loads((tmp_path / "telegram" / "bot.json").read_text(encoding="utf-8"))
assert content == "new-value"
def test_round_trip_string(self, tmp_path: Path) -> None:
"""set_secret then get_secret returns the same string value."""
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER):
set_secret("telegram", "bot", "round-trip-token")
result = get_secret("telegram", "bot")
assert result == "round-trip-token"
def test_round_trip_json(self, tmp_path: Path) -> None:
"""set_secret as_json then get_secret as_json returns the same dict."""
data = {"bot_token": "abc123", "chat_id": 42}
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER):
set_secret("telegram", "bot", data, as_json=True)
result = get_secret("telegram", "bot", as_json=True)
assert result == data
def test_logs_operation(self, tmp_path: Path) -> None:
"""set_secret logs the write operation via json_handler."""
mock_jh = MagicMock()
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER, mock_jh), patch(PATCH_LOGGER):
set_secret("telegram", "bot", "token")
mock_jh.log_operation.assert_called_once()
call_args = mock_jh.log_operation.call_args[0]
assert call_args[0] == "secret_written"
assert call_args[1]["provider"] == "telegram"
assert call_args[1]["slug"] == "bot"
# =============================================
# set_secret (module door)
# =============================================
class TestSetSecretModule:
"""Verifies the module-level set_secret wrapper."""
def test_set_secret_wraps_handler(self, tmp_path: Path) -> None:
"""Module set_secret delegates to handler and logs the operation."""
mock_handler = MagicMock()
mock_handler.set_secret.return_value = tmp_path / "telegram" / "bot.json"
mock_jh = MagicMock()
with patch(PATCH_MOD_HANDLER, mock_handler), patch(PATCH_MOD_JSON_HANDLER, mock_jh):
result = secrets_module.set_secret("telegram", "bot", "token-val")
assert result == tmp_path / "telegram" / "bot.json"
mock_handler.set_secret.assert_called_once_with("telegram", "bot", "token-val", as_json=False)
mock_jh.log_operation.assert_called_once()
assert mock_jh.log_operation.call_args[0][0] == "secrets_set"
def test_set_secret_as_json(self) -> None:
"""Module set_secret passes as_json through to handler."""
mock_handler = MagicMock()
mock_handler.set_secret.return_value = Path("/fake/path.json")
mock_jh = MagicMock()
data = {"bot_token": "abc"}
with patch(PATCH_MOD_HANDLER, mock_handler), patch(PATCH_MOD_JSON_HANDLER, mock_jh):
secrets_module.set_secret("telegram", "bot", data, as_json=True)
mock_handler.set_secret.assert_called_once_with("telegram", "bot", data, as_json=True)
+1
View File
@@ -12,3 +12,4 @@ build/
*.log
*.tmp
*.swp
.local/
View File
@@ -31,7 +31,9 @@ All HTTP calls use urllib (stdlib). No external dependencies.
# Standard library
import json
import shutil
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
@@ -200,11 +202,34 @@ def set_bot_commands(bot_token: str, commands: list[dict]) -> bool:
# =============================================
def _install_service_unit() -> bool:
"""Copy telegram-bot@.service into ~/.config/systemd/user/ and reload."""
UNIT_SRC = Path(__file__).resolve().parents[2] / "telegram-bot@.service"
UNIT_DST_DIR = Path.home() / ".config" / "systemd" / "user"
UNIT_DST = UNIT_DST_DIR / "telegram-bot@.service"
try:
UNIT_DST_DIR.mkdir(parents=True, exist_ok=True)
shutil.copy2(UNIT_SRC, UNIT_DST)
subprocess.run(
["systemctl", "--user", "daemon-reload"],
capture_output=True,
text=True,
timeout=10,
)
logger.info("Installed service unit: %s", UNIT_DST)
return True
except OSError as e:
logger.warning("Failed to install service unit: %s", e)
return False
def enable_service(bot_id: str) -> bool:
"""
Enable the systemd user service for a bot (does not start it).
Runs: systemctl --user enable telegram-bot@{bot_id}
Installs the unit file if missing, then runs:
systemctl --user enable telegram-bot@{bot_id}
Args:
bot_id: Bot identifier used in the service template.
@@ -212,6 +237,7 @@ def enable_service(bot_id: str) -> bool:
Returns:
True if the service was enabled successfully, False otherwise.
"""
_install_service_unit()
SERVICE_NAME = f"telegram-bot@{bot_id}"
try:
result = subprocess.run(
@@ -283,12 +309,11 @@ def start_bot_process(bot_id: str) -> bool:
Returns:
True if the process was launched successfully, False otherwise.
"""
BASE_BOT_PATH = Path(__file__).parent / "base_bot.py"
PYTHON = str(Path.home() / ".venv" / "bin" / "python3")
MODULE_PATH = "aipass.skills.lib.telegram.apps.handlers.base_bot"
try:
proc = subprocess.Popen(
[PYTHON, str(BASE_BOT_PATH), "--bot-id", bot_id],
[sys.executable, "-m", MODULE_PATH, "--bot-id", bot_id],
start_new_session=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
@@ -37,11 +37,31 @@ from aipass.skills.apps.handlers.json import json_handler
# CONSTANTS
# =============================================
SYSTEM_LOGS_DIR = Path.home() / "system_logs"
BATCH_INTERVAL = 5.0
TELEGRAM_MAX_LENGTH = 4000
def _get_system_logs_dir():
"""Resolve system_logs dir, honoring AIPASS_TEST_LOG_DIR."""
import os
test_dir = os.environ.get("AIPASS_TEST_LOG_DIR")
if test_dir:
p = Path(test_dir) / "system"
p.mkdir(parents=True, exist_ok=True)
return p
here = Path(__file__).resolve()
for parent in here.parents:
if (parent / "pyproject.toml").exists():
d = parent / "system_logs"
d.mkdir(parents=True, exist_ok=True)
return d
return Path.home() / "system_logs"
SYSTEM_LOGS_DIR = _get_system_logs_dir()
# =============================================
# LOG STREAMER
# =============================================
@@ -19,7 +19,7 @@ Wants=network-online.target
[Service]
Type=simple
ExecStart=%h/.venv/bin/python3 %h/Projects/AIPass/src/aipass/skills/lib/telegram/apps/handlers/base_bot.py --bot-id %i
ExecStart=%h/Projects/AIPass/.venv/bin/python3 -m aipass.skills.lib.telegram.apps.handlers.base_bot --bot-id %i
WorkingDirectory=%h/Projects/AIPass
Environment=AIPASS_BOT_ID=%i
Environment=AIPASS_SESSION_TYPE=telegram