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:
co-authored by
Claude Opus 4.8
parent
882da7cdfc
commit
0096aef1d2
@@ -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)
|
||||
|
||||
@@ -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
|
||||
# ==============================================
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -12,3 +12,4 @@ build/
|
||||
*.log
|
||||
*.tmp
|
||||
*.swp
|
||||
.local/
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user